Files
Agent-System-Frontend/test/document-decision.test.ts
T
weichenandCursor 0d4d1be198 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>
2026-10-17 03:41:10 +00:00

251 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 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('项目已关闭')
})
})