Anchor render workspace decisions to a committed source message

Why: The base gate (cmdbus.RequiresProjectWorkDecision) refuses rdm.review.doc.approve/reject/final unless the request carries a committed conversation source as top-level conversationId and anchorMessageId. The workspace sent bare params, so the decision buttons were refused in the real product.

Evidence: vitest 39 files / 441 tests passed (22 new in test/document-decision.test.ts); npx tsc --noEmit clean; npm run build ok; check-knip unused exports 51 <= 51; e2e/document-renderer.playwright.ts type-checks and lists 13 tests. Request shape from pins/agent-system-backend: internal/server/server.go:1276-1304 (body decode), internal/cmdbus/cmdbus.go:109-134 and 385, internal/app/app.go:646-665 (preflight), server.go:1010-1150 (message POST returns the committed message).

Impact: approve/reject/final first post a Chinese decision message to the host project's 文档审签记录 conversation (reused, or created), then dispatch with its id as the anchor (entrance button). Identity is never sent. Any failing step shows a readable refusal and nothing is dispatched. Double-click is ignored while an action is in flight, and retries reuse the operation id so the server returns the same source message. rdmCommand takes an optional source; rdm.render.* is unchanged. The e2e spec drives §11-5c through the workspace only and asserts the anchored, committed receipt. Not run against a live backend or browser here.

Refs: unified renderer plan Task 11; RDM cmdbus gate (pins/agent-system-backend internal/cmdbus/cmdbus.go:109-134); live-verify sequence /tmp/live-verify/run.sh lines 56-70 and 140-146.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-17 03:41:10 +00:00
co-authored by Cursor
parent 22f59cd1b2
commit 0d4d1be198
7 changed files with 464 additions and 36 deletions
+40 -29
View File
@@ -169,6 +169,27 @@ async function expectReadableFailures(page: Page) {
}
}
const parseJson = (text: string): any => { try { return JSON.parse(text) } catch { return null } }
/**
* 工作区整文决定的取证:先有同会话的已提交来源消息(POST /messages 响应),命令再以它为顶层锚点分发,
* params 不含来源字段,入口为页面动作;回执必须 committed。
*/
async function expectCommittedDecision(s: { net: NetEntry[] }, command: string, label: string) {
const cmdUrl = `/api/commands/${command}`
await expect.poll(() => s.net.some(n => n.url.includes(cmdUrl)), { message: `应分发 ${command}`, timeout: 15_000 }).toBe(true)
const dispatch = s.net.filter(n => n.url.includes(cmdUrl)).at(-1)!
const body = parseJson(dispatch.request)
expect(body.params, '命令参数不携带来源字段').not.toHaveProperty('anchorMessageId')
expect(body.entrance, '工作区动作入口').toBe('button')
const findSource = () => s.net.find(n => n.method === 'POST'
&& n.url.includes(`/api/conversations/${body.conversationId}/messages`)
&& parseJson(n.body)?.message?.id === body.anchorMessageId)
await expect.poll(() => findSource() !== undefined, { message: '锚点必须是同会话已提交的来源消息', timeout: 15_000 }).toBe(true)
expect(parseJson(findSource()!.request)?.text, '来源消息写明中文审签决定').toContain(`审签决定:${label}`)
expect(parseJson(dispatch.body)?.receipt?.state, `${command} 回执 committed`).toBe('committed')
}
// ---- §11 ----
test('§11-1 文档树 → 结构画布与阅读视图(共用选中与过滤)', async ({ browser }) => {
@@ -416,7 +437,7 @@ test('§11-5b 审核人添加批注与修订', async ({ browser }) => {
test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
items(5)
const { submissionId, round } = readState()
const { submissionId } = readState()
const r = await session(browser, U.reviewer, 'item5c-reviewer')
await openAudit(r.page, A.name)
await openDoc(r.page, A.title)
@@ -429,23 +450,18 @@ test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
expect(submissionId, 'BLOCKER[RDM] 无渲染审阅提交(见 §11-5a),审核与批准签署无从进行').not.toBe('')
}
await expect(reviewPanel.locator('.drw-mono')).toHaveAttribute('title', submissionId, { timeout: 20_000 })
// 工作区「整文审签」入口只在服务端允许(审核人且未审)时出现;成功与可读失败都必须可见。
// 整文审签只走工作区入口:审核人待审时必须出现「审核通过」;成功与可读失败都必须可见。
const decisionPanel = panel(r.page, '整文审签')
const uiApprove = decisionPanel.getByRole('button', { name: '审核通过', exact: true })
if ((await uiApprove.count()) > 0) {
await uiApprove.click()
const done = decisionPanel.getByText('整文审核通过已完成')
const failed = decisionPanel.locator('.drw-failure-msg')
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(r.page)
await shot(r.page, 'item5c-reviewer-approved', decisionPanel)
} else {
expect.soft(false, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBe(true)
apiAssisted('rdm.review.doc.approve 以审核人会话 + 本人会话来源锚点调用(工作区入口不可用时的取证路径)')
const approve = await command(r.page, 'rdm.review.doc.approve', { projectId: A.pid, docId: A.doc, note: `§11 审核通过 ${RUN}` }, env('RBA_SRC_REVIEWER_A'))
expect(approve.body?.receipt?.state, `approve 回执:${approve.text.slice(0, 400)}`).toBe('committed')
}
await expect(uiApprove, 'GAP[Frontend] 审核人在待审状态下工作区没有「审核通过」入口').toBeVisible({ timeout: 20_000 })
await uiApprove.click()
const done = decisionPanel.getByText('整文审核通过已完成')
const failed = decisionPanel.locator('.drw-failure-msg')
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await done.count(), `审核通过应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(r.page)
await expectCommittedDecision(r, 'rdm.review.doc.approve', '审核通过')
await shot(r.page, 'item5c-reviewer-approved', decisionPanel)
await r.close()
const a = await session(browser, U.approver, 'item5c-approver')
@@ -456,19 +472,14 @@ test('§11-5c 审核通过与批准(签署)', async ({ browser }) => {
// 终批入口只在批准人且全员已审核通过时出现;载荷绑定界面所示轮次。
const finalPanel = panel(a.page, '整文审签')
const uiFinal = finalPanel.getByRole('button', { name: '终批', exact: true })
if ((await uiFinal.count()) > 0) {
await uiFinal.click()
const done = finalPanel.getByText('整文终批已完成')
const failed = finalPanel.locator('.drw-failure-msg')
await expect(done.or(failed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await done.count(), `终批应成功:${(await failed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(a.page)
} else {
expect.soft(false, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBe(true)
apiAssisted('rdm.review.doc.final 以批准人会话 + 本人会话来源锚点调用(工作区入口不可用时的取证路径)')
const final = await command(a.page, 'rdm.review.doc.final', { projectId: A.pid, docId: A.doc, round, reason: `§11 批准 ${RUN}` }, env('RBA_SRC_APPROVER_A'))
expect(final.body?.receipt?.state, `final 回执:${final.text.slice(0, 400)}`).toBe('committed')
}
await expect(uiFinal, 'GAP[Frontend] 批准人在全员审核通过后工作区没有「终批」入口').toBeVisible({ timeout: 20_000 })
await uiFinal.click()
const finalDone = finalPanel.getByText('整文终批已完成')
const finalFailed = finalPanel.locator('.drw-failure-msg')
await expect(finalDone.or(finalFailed).first()).toBeVisible({ timeout: 60_000 })
expect.soft(await finalDone.count(), `终批应成功:${(await finalFailed.allTextContents()).join(' | ')}`).toBe(1)
await expectReadableFailures(a.page)
await expectCommittedDecision(a, 'rdm.review.doc.final', '终批')
await a.page.reload()
await openAudit(a.page, A.name)
await openDoc(a.page, A.title)
+3 -3
View File
@@ -1,7 +1,7 @@
// API 客户端(API-first:UI 只是第一个客户端)与 SSE 流客户端(游标重放 + 终态对账)。
// 认证:服务端开启 API key 时经 auth.fetch/withKey 注入凭证(嵌入式档无认证则透传)。
import { auth } from './auth'
import { rdmCommandProjectId, rdmProjectCommandError } from './rdmManagement'
import { commandRequestBody, rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
@@ -232,10 +232,10 @@ export const api = {
if (error) throw new Error(error)
return api.rdmCommand<T>(id, { ...params, projectId: rdmCommandProjectId(project) })
},
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}, source?: CommandSource): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
body: JSON.stringify(commandRequestBody(params, source)),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
+15 -4
View File
@@ -6,6 +6,7 @@ import { Btn, Chip, Collapsible, SectionLabel } from '@ui'
import { api } from '../api'
import { store, useStore } from '../store'
import { permCheckPayload } from '../rdmManagement'
import { commitDecision, hostProjectFor } from '../documentDecision'
import { ReviewDecisionPanel, ProfileBindingPanel, ACTION_OP_LABELS } from './DocumentRenderActions'
import { OperationStatus, RenderFailure, type OperationState } from './renderFeedback'
import {
@@ -17,7 +18,7 @@ import {
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
revisePayload, submissionIdOf, truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules,
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel,
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel, createSingleFlight,
type BindSource, type DecisionKind, type RuleRef,
type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind,
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation,
@@ -313,14 +314,17 @@ function useOperationRunner(identity: number) {
const guard = useRef(createRequestGuard())
const last = useRef<{ op: RenderOperation; exec: (operationId: string) => Promise<unknown>; onDone?: (r: unknown) => void } | null>(null)
const [state, setState] = useState<OperationState>({ op: null, status: 'idle', error: '' })
const flight = useRef(createSingleFlight())
useEffect(() => { const g = guard.current; return () => g.cancel() }, [])
const start = (op: RenderOperation, exec: (operationId: string) => Promise<unknown>, onDone?: (r: unknown) => void) => {
// 单一所有者:上一动作未结束时忽略新的触发(双击不会铸第二个编号、不会第二次写来源)。
if (flight.current.busy()) return
last.current = { op, exec, onDone }
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState({ op, status: 'running', error: '' })
exec(op.operationId).then(
flight.current.run(() => exec(op.operationId)).then(
result => { if (!live()) return; setState({ op, status: 'done', error: '' }); onDone?.(result) },
e => { if (live()) setState({ op, status: 'failed', error: errText(e) }) },
)
@@ -651,6 +655,9 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
const receiptDiagnostics = useReceiptDiagnostics(projectId, identity)
const decisionOp = useOperationRunner(identity)
const bindOp = useOperationRunner(identity)
// 审签来源写入宿主对话项目(研发项目 rdm.projectId 绑定的那个项目)。
const projects = useStore(s => s.projects)
const hostProject = useMemo(() => hostProjectFor(projects, projectId), [projects, projectId])
// 身份只取会话;入口与绑定权限分别来自服务端冻结职责(docStatus)与 rdm.perm.check 的 project.settings。
const me = useStore(s => s.me?.owner ?? '')
@@ -751,10 +758,14 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
}
// 审签动作:载荷只含 projectId/docId 与 note|reason|round,轮次取界面所示的在途轮次;结果后刷新审签状态与批注。
// 先在宿主项目的审签记录对话写入已提交的来源消息,再以它为锚点分发命令;任一步失败不分发。
const decide = (kind: DecisionKind, text: string) => {
if (!submission) return
const { command, params } = decisionPayload(kind, projectId, docId, text, submission.round)
decisionOp.run(kind, () => api.rdmCommand(command, params), () => { docStatus.reload(); review.reload() })
const round = submission.round
const { command, params } = decisionPayload(kind, projectId, docId, text, round)
decisionOp.run(kind, operationId => commitDecision(api, {
host: hostProject, operationId, decision: kind, doc: { id: docId, title: docTitle }, round, note: text, command, params,
}), () => { docStatus.reload(); review.reload() })
}
// 规则绑定:绑定/解绑成功后刷新 rdm.render.profiles(当前绑定与选项以服务端为准)。
+124
View File
@@ -0,0 +1,124 @@
// 统一渲染 Task 11(审签来源):整文审签的正式命令必须锚定一条已提交的对话消息(基座来源门禁)。
// 流程:宿主项目的审签记录对话 → 提交一条中文审签说明(用户消息,幂等编号 = 动作编号)→ 以该消息为锚点分发命令。
// 任一步失败都可读地拒绝且绝不分发命令;身份只由服务端认证主体决定,载荷不含任何身份字段。
import type { Conversation, Message, Project } from './contracts'
import { describeRenderFailure, type DecisionKind } from './documentRenderPresentation'
import { rdmCommandProjectId, rdmProjectCommandError, type CommandSource } from './rdmManagement'
export const DECISION_RECORD_TITLE = '文档审签记录'
const DECISION_ENTRANCE: CommandSource['entrance'] = 'button'
const CONVERSATION_STAGE = '审签记录对话'
const SOURCE_STAGE = '审签来源消息'
/** 只需要的会话端口:调用方传入共享的 api 对象,不另起一层 API。 */
export interface DecisionPort {
listConversations: (projectId: string) => Promise<Conversation[]>
createConversation: (projectId: string, title?: string) => Promise<Conversation>
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }>
rdmCommand: (id: string, params: Record<string, unknown>, source?: CommandSource) => Promise<unknown>
}
export interface DecisionRequest {
host: Project | undefined
/** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */
operationId: string
decision: DecisionKind
doc: { id: string; title: string }
round: number
note: string
command: string
params: Record<string, unknown>
}
function decisionLabel(kind: DecisionKind): string {
switch (kind) {
case 'approve': return '审核通过'
case 'reject': return '驳回'
case 'final': return '终批'
default: {
const exhaustive: never = kind
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
}
}
}
/** 审签来源正文(中文):决定、文档、轮次与说明/原因;不含任何身份字段,首字符不为 “/” 以免被当作命令。 */
export function decisionSourceText(input: Pick<DecisionRequest, 'decision' | 'doc' | 'round' | 'note'>): string {
const doc = input.doc.title ? `${input.doc.title}(${input.doc.id})` : input.doc.id
const noteLabel = input.decision === 'reject' ? '驳回原因' : '说明'
return [
`审签决定:${decisionLabel(input.decision)}`,
`文档:${doc}`,
`轮次:第 ${input.round} 轮`,
`${noteLabel}:${input.note.trim() || '无'}`,
'来源:文档渲染工作区',
].join('\n')
}
/** 研发项目 → 其宿主对话项目(settings 中绑定的 rdm.projectId 相同者)。 */
export function hostProjectFor(projects: Project[], rdmProjectId: string): Project | undefined {
return projects.find(p => {
try { return rdmCommandProjectId(p) === rdmProjectId } catch { return false }
})
}
function refusal(stage: string, detail: string): Error {
return new Error(`${stage}未能提交,审签未执行:${detail}`)
}
function stepFailure(stage: string, err: unknown): Error {
const raw = err instanceof Error ? err.message : String(err ?? '')
if (/\b403\b/.test(raw)) return refusal(stage, '当前账号无权在该对话中记录审签')
if (/\b409\b/.test(raw)) return refusal(stage, '会话正在处理其他任务,请稍后重试')
if (/\b429\b/.test(raw)) return refusal(stage, '会话排队已满,请稍后重试')
return refusal(stage, describeRenderFailure(err).message)
}
/** 记录对话:复用宿主项目中标题为审签记录、未归档的对话;没有则新建。 */
async function recordConversation(port: DecisionPort, projectId: string): Promise<Conversation> {
let list: Conversation[]
try {
list = await port.listConversations(projectId)
} catch (e) {
throw stepFailure(CONVERSATION_STAGE, e)
}
const existing = list.find(c => c.title === DECISION_RECORD_TITLE && !c.archived)
if (existing) return existing
try {
return await port.createConversation(projectId, DECISION_RECORD_TITLE)
} catch (e) {
throw stepFailure(CONVERSATION_STAGE, e)
}
}
/** 提交来源消息:必须是已提交的同会话用户消息;被当作命令路由的说明不算来源。 */
async function recordSource(port: DecisionPort, convId: string, req: DecisionRequest): Promise<string> {
let out: { message?: Message; command?: string }
try {
out = await port.sendMessage(convId, decisionSourceText(req), [], `decision:${req.operationId}`)
} catch (e) {
throw stepFailure(SOURCE_STAGE, e)
}
if (out.command) throw refusal(SOURCE_STAGE, '审签说明被识别为命令,未作为来源记录')
const msg = out.message
if (!msg?.id || msg.role !== 'user' || msg.conversationId !== convId) {
throw refusal(SOURCE_STAGE, '服务端未返回已提交的对话消息')
}
return msg.id
}
/**
* 整文审签的唯一写路径:先记录来源,再以来源消息为锚点分发命令。
* 宿主项目缺失、项目未就绪、任一记录步骤失败时,命令一律不分发。
*/
export async function commitDecision(port: DecisionPort, req: DecisionRequest): Promise<unknown> {
const host = req.host
if (!host) throw new Error('未找到该研发项目对应的对话项目,请刷新项目列表后重试;审签未执行')
const gate = rdmProjectCommandError(host, req.command)
if (gate) throw new Error(`${gate};审签未执行`)
const conversation = await recordConversation(port, host.id)
const anchorMessageId = await recordSource(port, conversation.id, req)
return port.rdmCommand(req.command, req.params, {
conversationId: conversation.id, anchorMessageId, entrance: DECISION_ENTRANCE,
})
}
+16
View File
@@ -100,6 +100,8 @@ const REFUSAL_MESSAGES: { code: string; message: string }[] = [
{ code: 'controlled_requires_exact_profile', message: '受控文档只能使用其精确绑定的规则,不能回退到项目或公司默认规则' },
{ code: 'profile_not_published', message: '该规则尚未发布,不能用于绑定或生成' },
{ code: 'binding_mismatch', message: '当前绑定已变化,请刷新规则列表后重试' },
// 基座正式写命令的来源校验(cmdbus projectWorkPreflight):来源消息已记录,但尚未被接受为正式来源。
{ code: 'project source preflight', message: '审签来源尚未通过校验(来源消息已记录),请稍后重试;审签未生效' },
]
export function describeRenderFailure(err: unknown): RenderFailureView {
@@ -403,6 +405,20 @@ export function createRequestGuard(): RequestGuard {
}
}
/** 同一动作的单飞:未结束前再次触发被忽略(双击只提交一次);结束(成功或失败)后才释放。 */
export function createSingleFlight() {
let inFlight = false
return {
busy: () => inFlight,
run<T>(task: () => Promise<T>): Promise<T> {
inFlight = true
let started: Promise<T>
try { started = task() } catch (e) { started = Promise.reject(e) }
return started.finally(() => { inFlight = false })
},
}
}
export function renderScope(identity: number, projectId: string, docId: string): string {
return JSON.stringify([identity, projectId, docId])
}
+16
View File
@@ -152,6 +152,22 @@ export const docStatusPayload = (projectId: string, docId: string) => ({ project
export const permCheckPayload = (projectId: string, member: string) => ({ projectId, member })
/**
* 命令来源(基座 cmdbus.RequiresProjectWorkDecision):正式写命令必须带已提交的对话来源,
* 且位于请求体顶层(conversationId/anchorMessageId/entrance),不得放进 params。
* entrance 取服务端 CanonicalEntrance 集合;页面动作为 button。
*/
export interface CommandSource {
conversationId: string
anchorMessageId: string
entrance: 'button' | 'card' | 'slash' | 'chat' | 'api' | 'agent'
}
export const commandRequestBody = (params: Record<string, unknown>, source?: CommandSource) =>
source
? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance }
: { params }
/** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */
export function describeSource(source: string): string {
switch (source) {
+250
View File
@@ -0,0 +1,250 @@
// 统一渲染 Task 11(审签来源):整文审签先在项目对话写一条已提交的用户消息作为来源,
// 再以该消息为锚点分发命令。覆盖:调用顺序、顶层锚点字段、复用/新建记录对话、
// 每一步失败的可读拒绝(失败即不分发)、同一动作的重试幂等编号、双击只提交一次。
import { describe, expect, it, vi } from 'vitest'
import type { Conversation, Message, Project } from '../src/contracts'
import { commandRequestBody, rdmProjectCommandError } from '../src/rdmManagement'
import { createSingleFlight, describeRenderFailure, decisionPayload } from '../src/documentRenderPresentation'
import {
DECISION_RECORD_TITLE, commitDecision, decisionSourceText, hostProjectFor,
type DecisionPort, type DecisionRequest,
} from '../src/documentDecision'
const conv = (id: string, over: Partial<Conversation> = {}): Conversation => ({
id, projectId: 'host-1', title: DECISION_RECORD_TITLE, createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z', ...over,
})
const userMsg = (id: string, conversationId: string): Message => ({
id, conversationId, role: 'user', parts: [], createdAt: '2026-10-17T00:00:01Z',
})
const hostProject = (over: Partial<Project> = {}): Project => ({
id: 'host-1', name: '宿主项目', createdAt: '2026-10-17T00:00:00Z', updatedAt: '2026-10-17T00:00:00Z',
settings: { 'rdm.state': 'ready', 'rdm.projectId': 'rdm-1' }, ...over,
})
function harness(over: Partial<DecisionPort> = {}) {
const calls: string[] = []
const port: DecisionPort = {
listConversations: vi.fn(async () => { calls.push('list'); return [conv('c-1')] }),
createConversation: vi.fn(async (_pid: string, title?: string) => { calls.push('create'); return conv('c-new', { title: title ?? '' }) }),
sendMessage: vi.fn(async (convId: string) => { calls.push('send'); return { message: userMsg('m-1', convId) } }),
rdmCommand: vi.fn(async () => { calls.push('command'); return { receipt: { state: 'committed' } } }),
...over,
}
return { port, calls }
}
/** 断言并取出拒绝原因;若意外成功则失败。 */
async function failureOf(p: Promise<unknown>): Promise<Error> {
try { await p } catch (e) { return e as Error }
throw new Error('expected the decision to be refused')
}
function request(over: Partial<DecisionRequest> = {}): DecisionRequest {
const payload = decisionPayload('approve', 'rdm-1', 'doc-9', '同意发布', 2)
return {
host: hostProject(), operationId: 'op_a', decision: 'approve',
doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布',
command: payload.command, params: payload.params, ...over,
}
}
describe('decisionSourceText', () => {
it('states the decision, document, round and note in Chinese without identity fields', () => {
const text = decisionSourceText({ decision: 'approve', doc: { id: 'doc-9', title: '采购流程说明' }, round: 2, note: '同意发布' })
expect(text).toContain('审签决定:审核通过')
expect(text).toContain('文档:采购流程说明(doc-9)')
expect(text).toContain('轮次:第 2 轮')
expect(text).toContain('说明:同意发布')
expect(text.startsWith('/')).toBe(false)
expect(text).not.toMatch(/owner|actor|author|identity/i)
})
it('labels a rejection reason and writes 无 when the note is empty', () => {
const reject = decisionSourceText({ decision: 'reject', doc: { id: 'd', title: '' }, round: 1, note: '依据不足' })
expect(reject).toContain('审签决定:驳回')
expect(reject).toContain('驳回原因:依据不足')
expect(reject).toContain('文档:d')
const final = decisionSourceText({ decision: 'final', doc: { id: 'd', title: 'T' }, round: 1, note: ' ' })
expect(final).toContain('审签决定:终批')
expect(final).toContain('说明:无')
})
})
describe('commitDecision', () => {
it('posts the committed source message first, then dispatches with it as the top-level anchor', async () => {
const { port, calls } = harness()
const req = request()
await commitDecision(port, req)
expect(calls).toEqual(['list', 'send', 'command'])
expect(port.sendMessage).toHaveBeenCalledWith('c-1', decisionSourceText(req), [], 'decision:op_a')
expect(port.rdmCommand).toHaveBeenCalledWith(req.command, req.params, { conversationId: 'c-1', anchorMessageId: 'm-1', entrance: 'button' })
})
it('keeps the command params free of conversation fields and identity', async () => {
const { port } = harness()
await commitDecision(port, request())
const [, params] = vi.mocked(port.rdmCommand).mock.calls[0]
expect(params).toEqual({ projectId: 'rdm-1', docId: 'doc-9', note: '同意发布' })
expect(params).not.toHaveProperty('conversationId')
expect(params).not.toHaveProperty('anchorMessageId')
expect(params).not.toHaveProperty('entrance')
})
it('reuses the titled, non-archived decision record conversation of the host project', async () => {
const { port } = harness({
listConversations: vi.fn(async () => [
conv('c-other', { title: '闲聊' }),
conv('c-archived', { archived: true }),
conv('c-record'),
]),
})
await commitDecision(port, request())
expect(port.listConversations).toHaveBeenCalledWith('host-1')
expect(port.createConversation).not.toHaveBeenCalled()
expect(port.sendMessage).toHaveBeenCalledWith('c-record', expect.any(String), [], 'decision:op_a')
})
it('creates the decision record conversation when the host project has none', async () => {
const { port } = harness({ listConversations: vi.fn(async () => []) })
await commitDecision(port, request())
expect(port.createConversation).toHaveBeenCalledWith('host-1', DECISION_RECORD_TITLE)
expect(port.sendMessage).toHaveBeenCalledWith('c-new', expect.any(String), [], 'decision:op_a')
})
it('anchors on the message id returned by the committed post, never on a different conversation', async () => {
const { port } = harness({
sendMessage: vi.fn(async () => ({ message: userMsg('m-9', 'other-conv') })),
})
await expect(commitDecision(port, request())).rejects.toThrow('未能提交,审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses when the response is not a committed user message', async () => {
const assistant = { ...userMsg('m-2', 'c-1'), role: 'assistant' as const }
const { port } = harness({ sendMessage: vi.fn(async () => ({ message: assistant })) })
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses when the post was treated as a command rather than recorded as source text', async () => {
const { port } = harness({
sendMessage: vi.fn(async (convId: string) => ({ message: userMsg('m-1', convId), command: 'system.intent' })),
})
await expect(commitDecision(port, request())).rejects.toThrow('审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses without dispatching when the host project is not found', async () => {
const { port } = harness()
await expect(commitDecision(port, request({ host: undefined }))).rejects.toThrow('未找到该研发项目对应的对话项目')
expect(port.listConversations).not.toHaveBeenCalled()
expect(port.sendMessage).not.toHaveBeenCalled()
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('refuses without any write when the host project is not ready', async () => {
const { port } = harness()
const host = hostProject({ settings: { 'rdm.state': 'provisioning' } })
await expect(commitDecision(port, request({ host }))).rejects.toThrow('项目正在准备')
expect(port.listConversations).not.toHaveBeenCalled()
expect(port.sendMessage).not.toHaveBeenCalled()
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('shows a readable refusal and does not dispatch when the session has lapsed', async () => {
const { port } = harness({ listConversations: vi.fn(async () => { throw new Error('401: unauthorized') }) })
const err = await failureOf(commitDecision(port, request()))
expect(err.message).toContain('登录已失效')
expect(port.sendMessage).not.toHaveBeenCalled()
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('reports a busy conversation as retryable and does not dispatch', async () => {
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('409: conversation busy') }) })
const err = await failureOf(commitDecision(port, request()))
expect(err.message).toContain('会话正在处理其他任务,请稍后重试')
expect(err.message).toContain('审签未执行')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('reports a forbidden source post as a refusal and does not dispatch', async () => {
const { port } = harness({ sendMessage: vi.fn(async () => { throw new Error('403: forbidden') }) })
const err = await failureOf(commitDecision(port, request()))
expect(err.message).toContain('当前账号无权在该对话中记录审签')
expect(port.rdmCommand).not.toHaveBeenCalled()
})
it('surfaces a source-preflight rejection from the command step as a readable refusal', async () => {
const { port } = harness({
rdmCommand: vi.fn(async () => { throw new Error('project source preflight: formal project command requires an authenticated conversation source') }),
})
const err = await failureOf(commitDecision(port, request()))
const view = describeRenderFailure(err)
expect(view.message).toContain('审签来源尚未通过校验')
expect(view.message.startsWith('{')).toBe(false)
})
it('retries the same operation with the same request id so the server returns the same source message', async () => {
let fail = true
const { port } = harness({
sendMessage: vi.fn(async (convId: string) => {
if (fail) { fail = false; throw new Error('503: unavailable') }
return { message: userMsg('m-1', convId) }
}),
})
await expect(commitDecision(port, request())).rejects.toThrow()
await commitDecision(port, request())
const ids = vi.mocked(port.sendMessage).mock.calls.map(c => c[3])
expect(ids).toEqual(['decision:op_a', 'decision:op_a'])
expect(port.rdmCommand).toHaveBeenCalledTimes(1)
})
})
describe('double click', () => {
it('runs one source post and one command when the action fires twice before settling', async () => {
const { port } = harness()
const flight = createSingleFlight()
const click = () => {
if (flight.busy()) return
void flight.run(() => commitDecision(port, request())).catch(() => undefined)
}
click()
click()
await vi.waitFor(() => expect(port.rdmCommand).toHaveBeenCalledTimes(1))
expect(port.sendMessage).toHaveBeenCalledTimes(1)
expect(flight.busy()).toBe(false)
})
it('releases the flight after a failure so the readable refusal can be retried', async () => {
const flight = createSingleFlight()
await expect(flight.run(() => Promise.reject(new Error('x')))).rejects.toThrow('x')
expect(flight.busy()).toBe(false)
})
})
describe('hostProjectFor', () => {
it('finds the host project bound to the RDM project id and ignores unbound projects', () => {
const bound = hostProject()
const unbound = hostProject({ id: 'host-2', settings: {} })
expect(hostProjectFor([unbound, bound], 'rdm-1')).toBe(bound)
expect(hostProjectFor([unbound], 'rdm-1')).toBeUndefined()
})
})
describe('command request body', () => {
it('keeps the plain body without a source', () => {
expect(commandRequestBody({ a: 1 })).toEqual({ params: { a: 1 } })
})
it('places the source fields at the top level and leaves params untouched', () => {
expect(commandRequestBody({ a: 1 }, { conversationId: 'c', anchorMessageId: 'm', entrance: 'button' }))
.toEqual({ params: { a: 1 }, conversationId: 'c', anchorMessageId: 'm', entrance: 'button' })
})
})
describe('rdmProjectCommandError for decisions', () => {
it('gates decision commands on project work readiness', () => {
expect(rdmProjectCommandError(hostProject(), 'rdm.review.doc.approve')).toBeNull()
expect(rdmProjectCommandError(hostProject({ settings: { 'rdm.state': 'closed' } }), 'rdm.review.doc.approve')).toContain('项目已关闭')
})
})