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>
251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
// 统一渲染 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('项目已关闭')
|
||
})
|
||
})
|