Files
Agent-System-Frontend/src/documentDecision.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

125 lines
5.8 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 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,
})
}