From 0d4d1be198db57293859101cfc4e5a01ee6bcba9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 03:41:10 +0000 Subject: [PATCH] Anchor render workspace decisions to a committed source message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/document-renderer.playwright.ts | 69 +++--- src/api.ts | 6 +- src/components/DocumentRenderWorkspace.tsx | 19 +- src/documentDecision.ts | 124 ++++++++++ src/documentRenderPresentation.ts | 16 ++ src/rdmManagement.ts | 16 ++ test/document-decision.test.ts | 250 +++++++++++++++++++++ 7 files changed, 464 insertions(+), 36 deletions(-) create mode 100644 src/documentDecision.ts create mode 100644 test/document-decision.test.ts diff --git a/e2e/document-renderer.playwright.ts b/e2e/document-renderer.playwright.ts index 438dd4b..fea5c8d 100644 --- a/e2e/document-renderer.playwright.ts +++ b/e2e/document-renderer.playwright.ts @@ -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) diff --git a/src/api.ts b/src/api.ts index 5a67fb2..10ed62a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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(id, { ...params, projectId: rdmCommandProjectId(project) }) }, - rdmCommand: (id: string, params: Record = {}): Promise => + rdmCommand: (id: string, params: Record = {}, source?: CommandSource): Promise => 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 } diff --git a/src/components/DocumentRenderWorkspace.tsx b/src/components/DocumentRenderWorkspace.tsx index 7d55222..c9b839d 100644 --- a/src/components/DocumentRenderWorkspace.tsx +++ b/src/components/DocumentRenderWorkspace.tsx @@ -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; onDone?: (r: unknown) => void } | null>(null) const [state, setState] = useState({ 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, 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(当前绑定与选项以服务端为准)。 diff --git a/src/documentDecision.ts b/src/documentDecision.ts new file mode 100644 index 0000000..3739f8a --- /dev/null +++ b/src/documentDecision.ts @@ -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 + createConversation: (projectId: string, title?: string) => Promise + sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) => Promise<{ message?: Message; command?: string }> + rdmCommand: (id: string, params: Record, source?: CommandSource) => Promise +} + +export interface DecisionRequest { + host: Project | undefined + /** 同一用户动作的编号:重试复用,服务端据此幂等返回同一条来源消息。 */ + operationId: string + decision: DecisionKind + doc: { id: string; title: string } + round: number + note: string + command: string + params: Record +} + +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): 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 { + 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 { + 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 { + 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, + }) +} diff --git a/src/documentRenderPresentation.ts b/src/documentRenderPresentation.ts index 6ec5b1e..1870e93 100644 --- a/src/documentRenderPresentation.ts +++ b/src/documentRenderPresentation.ts @@ -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(task: () => Promise): Promise { + inFlight = true + let started: Promise + 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]) } diff --git a/src/rdmManagement.ts b/src/rdmManagement.ts index cdd0972..ab62ede 100644 --- a/src/rdmManagement.ts +++ b/src/rdmManagement.ts @@ -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, source?: CommandSource) => + source + ? { params, conversationId: source.conversationId, anchorMessageId: source.anchorMessageId, entrance: source.entrance } + : { params } + /** 矩阵来源标签(人话映射;未知来源回退原文——服务端新增来源时不至于空白)。 */ export function describeSource(source: string): string { switch (source) { diff --git a/test/document-decision.test.ts b/test/document-decision.test.ts new file mode 100644 index 0000000..7b942dd --- /dev/null +++ b/test/document-decision.test.ts @@ -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 => ({ + 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 => ({ + 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 = {}) { + 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): Promise { + try { await p } catch (e) { return e as Error } + throw new Error('expected the decision to be refused') +} + +function request(over: Partial = {}): 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('项目已关闭') + }) +})