diff --git a/apps/kcode-cli/packages/bootstrap/src/kcode-protocol-v4/ask-user-question-projection.test.ts b/apps/kcode-cli/packages/bootstrap/src/kcode-protocol-v4/ask-user-question-projection.test.ts new file mode 100644 index 0000000..d355c90 --- /dev/null +++ b/apps/kcode-cli/packages/bootstrap/src/kcode-protocol-v4/ask-user-question-projection.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createSessionEvent, + SessionEventType, + type MessageId, + type SessionId, + type ToolCallId, + type TraceId, + type TurnId, +} from "@kcode/contracts"; +import { conversationRowSchema, type ToolCallRow } from "@kcode/shared/kcode-protocol-v4"; +import { ProductProjection } from "./product-projection.js"; + +// AskUserQuestion 的答案只在 display 通道里;工具入参(模型声明的 questions)与模型可见文本 +// (formatModelContent 拍的 `"Q"="A"`)都不是答案来源。投影必须原样把 display 带到 row.output, +// 少一个 strict union 成员就会被整块剥掉——卡片退回「未提供回答」(原 bug 形态)。 +const sessionId = "session-ask-user-question" as SessionId; +const turnId = "turn-ask-user-question" as TurnId; +const traceId = "trace-ask-user-question" as TraceId; +const toolCallId = "tool-ask-user-question" as ToolCallId; + +const QUESTIONS = [ + { + question: "用哪个方案?", + header: "方案", + options: [ + { label: "A 方案", description: "先做 A" }, + { label: "B 方案", description: "先做 B" }, + ], + multiSelect: false, + }, +]; +const MODEL_TEXT = 'User has answered your questions: "用哪个方案?"="A 方案".'; + +function projectionWithAnswers(answers: Record | undefined): ProductProjection { + const target = new ProductProjection(sessionId, "ask-user-question-projection-test"); + target.applyEvent( + createSessionEvent( + SessionEventType.TurnStarted, + sessionId, + { turnNumber: 1, input: "先问一下", messageId: "msg-ask-1" as MessageId }, + { turnId, traceId, sequenceNumber: 1 }, + ), + ); + target.applyEvent( + createSessionEvent( + SessionEventType.ToolCallScheduled, + sessionId, + { + toolCallId, + assistantMessageId: "msg-ask-2" as MessageId, + toolName: "AskUserQuestion", + input: { questions: QUESTIONS }, + schedule: { parallelGroups: [[toolCallId]], executionOrder: [toolCallId] }, + }, + { turnId, traceId, sequenceNumber: 2 }, + ), + ); + target.applyEvent( + createSessionEvent( + SessionEventType.ToolCallResult, + sessionId, + { + toolCallId, + duration: 12, + result: { + success: true, + content: MODEL_TEXT, + ...(answers === undefined + ? {} + : { display: { kind: "ask_user_question" as const, answers } }), + }, + }, + { turnId, traceId, sequenceNumber: 3 }, + ), + ); + return target; +} + +function toolRowOf(target: ProductProjection): ToolCallRow { + const row = target + .getSnapshot() + .rows.window.find((candidate): candidate is ToolCallRow => candidate.kind === "toolCall"); + assert.ok(row); + return row; +} + +test("结构化答案经 row.output.display 到达前端,协议校验后仍在场", () => { + const row = toolRowOf(projectionWithAnswers({ "用哪个方案?": "A 方案" })); + assert.deepEqual(row.output?.display, { + kind: "ask_user_question", + answers: { "用哪个方案?": "A 方案" }, + }); + // 协议 schema 缺成员时 display 会被 .catch(undefined) 静默剥掉,因此在这里直接断言往返结果。 + const parsed = conversationRowSchema.parse(row); + assert.equal(parsed.kind, "toolCall"); + assert.deepEqual( + parsed.kind === "toolCall" ? parsed.output?.display : undefined, + row.output?.display, + ); +}); + +test("display 不改模型可见文本,工具入参仍是模型声明的原始 questions", () => { + const row = toolRowOf(projectionWithAnswers({ "用哪个方案?": "A 方案" })); + assert.equal(row.output?.text, MODEL_TEXT); + assert.deepEqual(row.input, { questions: QUESTIONS }); +}); + +test("answers 为空对象仍保留 display(未作答、自动继续的显式语义)", () => { + const row = toolRowOf(projectionWithAnswers({})); + assert.deepEqual(row.output?.display, { kind: "ask_user_question", answers: {} }); +}); + +test("没有 display 的结果不产生 display 字段(拒绝/取消/旧数据回落)", () => { + const row = toolRowOf(projectionWithAnswers(undefined)); + assert.equal(row.output?.display, undefined); +}); diff --git a/apps/kcode-cli/packages/contracts/src/tools/ask-user-question.ts b/apps/kcode-cli/packages/contracts/src/tools/ask-user-question.ts index 3949c85..6139fa4 100644 --- a/apps/kcode-cli/packages/contracts/src/tools/ask-user-question.ts +++ b/apps/kcode-cli/packages/contracts/src/tools/ask-user-question.ts @@ -185,6 +185,33 @@ export const AskUserQuestionOutputSchema = z export type AskUserQuestionOutput = z.infer; +/** + * AskUserQuestion 的结构化结果展示载荷:v4 工具卡回显用户答案的唯一通道。 + * + * Bug 修复:模型可见文本由 formatAskUserQuestionModelContent 决定,形态是 + * `User has answered your questions: "Q"="A"`;展示侧被设计为不解析该文本,因此答案为 + * `Record<问题原文, string>` 的结构化副本必须随工具结果一并下发。载荷随 + * completedToolPartMetadata 落进 tool part 的 metadata.display,实时链路与冷恢复同源。 + * + * 单值在构造侧按 4 KiB 字节截断(见 core 的 createAskUserQuestionDisplay),这里的字符上限 + * 是 4 KiB 字节的宽松超集。本 schema 与 packages/shared 的镜像同为 strict union 成员: + * 两侧不同步 = 整块 display 被静默剥掉,卡片退回「未提供回答」(本 bug 的原始形态)。 + */ +export const ASK_USER_QUESTION_DISPLAY_ANSWER_MAX_CHARS = 4_096; + +export const askUserQuestionToolResultDisplayPayloadSchema = z + .object({ + kind: z.literal("ask_user_question"), + // key = 问题原文,与 output.answers / formatModelContent 同一映射; + // 空对象表示用户未作答而 runtime 自动继续,与「没有 display」语义不同。 + answers: z.record(z.string().max(ASK_USER_QUESTION_DISPLAY_ANSWER_MAX_CHARS)), + }) + .strict(); + +export type AskUserQuestionToolResultDisplayPayload = z.infer< + typeof askUserQuestionToolResultDisplayPayloadSchema +>; + export const AskUserQuestionInputJsonSchema = withRequiredDefaultedMultiSelect( toToolJsonSchema(AskUserQuestionInputSchema), ); diff --git a/apps/kcode-cli/packages/contracts/src/tools/tool-result-metadata.ts b/apps/kcode-cli/packages/contracts/src/tools/tool-result-metadata.ts index 14ef258..190fb7d 100644 --- a/apps/kcode-cli/packages/contracts/src/tools/tool-result-metadata.ts +++ b/apps/kcode-cli/packages/contracts/src/tools/tool-result-metadata.ts @@ -6,6 +6,7 @@ import { CreateWorkflowCausalityGraphSchema, createWorkflowToolResultDisplayDiagnosticSchema, } from "./create-workflow.js"; +import { askUserQuestionToolResultDisplayPayloadSchema } from "./ask-user-question.js"; import { evalWorkflowSnippetToolResultDisplayPayloadSchema, getWorkflowRunToolResultDisplayPayloadSchema, @@ -248,6 +249,7 @@ export const toolResultDisplayPayloadSchema = z.discriminatedUnion("kind", [ savedWorkflowListToolResultDisplayPayloadSchema, listModelsToolResultDisplayPayloadSchema, resumeWorkflowRunToolResultDisplayPayloadSchema, + askUserQuestionToolResultDisplayPayloadSchema, ]); export type FileDiffToolResultDisplayPayload = z.infer< diff --git a/apps/kcode-cli/packages/core/src/tool/executor/ask-user-question-display.test.ts b/apps/kcode-cli/packages/core/src/tool/executor/ask-user-question-display.test.ts new file mode 100644 index 0000000..26c5b83 --- /dev/null +++ b/apps/kcode-cli/packages/core/src/tool/executor/ask-user-question-display.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + parseCompletedToolPartMetadata, + parseToolResultDisplayPayload, + type AskUserQuestionOutput, + type AskUserQuestionToolResultDisplayPayload, +} from "@kcode/contracts"; +import { createAskUserQuestionDisplay } from "./ask-user-question-display.js"; + +const FIRST_QUESTION = "用哪个方案?"; +const SECOND_QUESTION = "要开哪些开关?"; + +function question(questionText: string, multiSelect: boolean) { + return { + question: questionText, + header: "方案", + options: [ + { label: "A 方案", description: "先做 A" }, + { label: "B 方案", description: "先做 B" }, + ], + multiSelect, + }; +} + +function outputWith(answers: Record): AskUserQuestionOutput { + return { + questions: [question(FIRST_QUESTION, false), question(SECOND_QUESTION, true)], + answers, + }; +} + +function displayOf(output: unknown): AskUserQuestionToolResultDisplayPayload { + const display = createAskUserQuestionDisplay(output); + assert.ok(display, "结构化答案必须产出 display 载荷"); + assert.equal(display.kind, "ask_user_question"); + return display as AskUserQuestionToolResultDisplayPayload; +} + +test("多题答案按问题原文逐条进入 display;多选字符串不被构造侧改写", () => { + const answers = { [FIRST_QUESTION]: "A 方案", [SECOND_QUESTION]: "A 方案, B 方案" }; + assert.deepEqual(displayOf(outputWith(answers)).answers, answers); +}); + +test("answers 为空仍产出载荷(用户未作答、runtime 自动继续)", () => { + assert.deepEqual(displayOf(outputWith({})).answers, {}); +}); + +test("非 AskUserQuestion 形态的输出不产出 display", () => { + assert.equal(createAskUserQuestionDisplay(undefined), undefined); + assert.equal(createAskUserQuestionDisplay({ answers: {} }), undefined); + assert.equal(createAskUserQuestionDisplay("User has answered your questions"), undefined); +}); + +test("display 载荷通过落库 schema 与 tool part metadata 往返(冷恢复同源)", () => { + const display = displayOf(outputWith({ [FIRST_QUESTION]: "A 方案" })); + assert.deepEqual(parseToolResultDisplayPayload(display), display); + assert.deepEqual(parseCompletedToolPartMetadata({ schemaVersion: 1, display })?.display, display); +}); + +test("超长答案按 4 KiB 截断,截断结果仍能通过落库 schema", () => { + const display = displayOf(outputWith({ [FIRST_QUESTION]: "自定义输入".repeat(2_000) })); + const bounded = display.answers[FIRST_QUESTION]; + assert.ok(bounded); + assert.ok(Buffer.byteLength(bounded, "utf8") <= 4 * 1024); + assert.match(bounded, /\[truncated\]$/u); + assert.deepEqual(parseToolResultDisplayPayload(display), display); +}); diff --git a/apps/kcode-cli/packages/core/src/tool/executor/ask-user-question-display.ts b/apps/kcode-cli/packages/core/src/tool/executor/ask-user-question-display.ts new file mode 100644 index 0000000..e41199c --- /dev/null +++ b/apps/kcode-cli/packages/core/src/tool/executor/ask-user-question-display.ts @@ -0,0 +1,29 @@ +import { AskUserQuestionOutputSchema, type ToolResultDisplayPayload } from "@kcode/contracts"; +import { boundDisplayText } from "./display-text.js"; + +// display 不经过 tool result budget,必须在这里单独限长:答案是用户自由输入, +// 无界载荷会随实时事件、tool part metadata 和 replayable snapshot 一起膨胀。 +const MAX_ANSWER_DISPLAY_BYTES = 4 * 1024; + +/** + * AskUserQuestion 的结构化结果 → display 载荷。 + * + * Bug 修复:工具结果文本由 formatModelContent 拍成 `User has answered your questions: "Q"="A"`, + * 而展示侧被设计为不解析该文本,因此卡片回显用户答案只能靠这份结构化副本。载荷随 + * completedToolPartMetadata 落进 tool part 的 metadata.display,实时链路与冷恢复因此同源。 + * + * 空 answers 也必须产出载荷:它是「用户未作答、runtime 自动继续」的显式语义, + * 与「没有 display」(拒绝/取消/旧数据)在卡片上是两种文案。 + */ +export function createAskUserQuestionDisplay( + output: unknown, +): ToolResultDisplayPayload | undefined { + const parsed = AskUserQuestionOutputSchema.safeParse(output); + if (!parsed.success) return undefined; + + const answers: Record = {}; + for (const [question, answer] of Object.entries(parsed.data.answers)) { + answers[question] = boundDisplayText(answer, MAX_ANSWER_DISPLAY_BYTES).value; + } + return { kind: "ask_user_question", answers }; +} diff --git a/apps/kcode-cli/packages/core/src/tool/executor/result-display.ts b/apps/kcode-cli/packages/core/src/tool/executor/result-display.ts index 68d7e35..7bb585d 100644 --- a/apps/kcode-cli/packages/core/src/tool/executor/result-display.ts +++ b/apps/kcode-cli/packages/core/src/tool/executor/result-display.ts @@ -1,4 +1,5 @@ import { + ASK_USER_QUESTION_TOOL_NAME, RESPOND_TO_COORDINATOR_TOOL_NAME, RespondToCoordinatorOutputSchema, MCP_TOOL_DISPLAY_MAX_DESCRIPTION_CHARS, @@ -22,6 +23,7 @@ import { import { createBashResultDisplay } from "./bash-result-display.js"; import { countPatchLines } from "../diff.js"; import { boundDisplayText } from "./display-text.js"; +import { createAskUserQuestionDisplay } from "./ask-user-question-display.js"; import { createCreateWorkflowDisplay } from "./create-workflow-display.js"; import { createWorkflowObservationDisplay } from "./workflow-observation-display.js"; @@ -114,6 +116,11 @@ export function createToolResultDisplay( const nodeReplDisplay = createNodeReplDisplay(toolName, output); if (nodeReplDisplay) return nodeReplDisplay; + if (toolName === ASK_USER_QUESTION_TOOL_NAME) { + // 用户答案是结构化结果,不是可解析的模型文本;卡片回显只能靠这条 display 通道。 + return createAskUserQuestionDisplay(output); + } + const createWorkflow = createCreateWorkflowDisplay(toolName, output); if (createWorkflow) return createWorkflow; diff --git a/docs/ask-user-question-answer-display.md b/docs/ask-user-question-answer-display.md new file mode 100644 index 0000000..2acaccf --- /dev/null +++ b/docs/ask-user-question-answer-display.md @@ -0,0 +1,94 @@ +# AskUserQuestion 答案回显(v4 工具卡)实现规范 + +> 本文档是「AskUserQuestion 工具卡回显用户答案」的完整规范:产品规则、状态所有者、接口与验收场景;自包含,不依赖任何外部文档。 +> 源码事实以当前检出的 kcode 仓库为准;本文档与源码冲突时,先对齐本文档再改代码。 + +## 问题陈述 + +桌面端 AskUserQuestion 工具卡在用户提交答案后,每个问题下方恒显示「未提供回答」(`chat.askQuestion.noAnswerProvided`),而模型后续 thinking 里能看到真实选择。答案没有丢,是展示层读不到。 + +期望:单题单选回显所选选项文本;多题每题回显各自答案;多选按产品列表约定用「,」连接;自定义输入回显输入文本;用户未作答而 runtime 自动继续的场景显示「未回答,已自动继续」(`chat.askQuestion.autoContinued`)而不是「未提供回答」。 + +## 根因 + +1. **提交与执行侧正常**:客户端以「问题文本」为 key 提交 `answers`,`interaction-broker` 按问题文本重排后经 `modifiedInput` 注入工具输入,handler 正常拿到 `input.answers`。 +2. **序列化把结构化输出拍成文本**:AskUserQuestion 定义了 `formatModelContent`,工具结果的 `content` 因此是 `User has answered your questions: "Q"="A"` 这类纯文本;结构化 `answers` 只存在于 handler 的返回对象 `output` 里。 +3. **投影层只保留文本**:`buildToolOutput` 只产出 `{ text, display?, truncated? }`,而 AskUserQuestion 从不产出 `display`,`ToolCallRow` 也没有别的结构化 answers 通道;前端桥接取的正是文本。 +4. **输入侧没有兜底**:`row.input` 始终是提交前的原始 `questions`(工具入参与模型声明的入参一致),v4 投影不把 `modifiedInput.answers` 回填到行上,因此 `input.answers` 兜底分支永不成立。 +5. **前端被设计为不解析模型文本**:`readAskUserQuestionAnswers` 的四种载体(`output.answers` / 旧 `selected` 形态 / `input.answers` / `raw.*`)在 desktop v4 运行时一个都不满足,必然全部落到「未提供回答」。 + +## 解决方案 + +只走一条既有通道:**把结构化答案放进工具结果的 `display` 载荷**(`ToolResultDisplayPayload`,kind `ask_user_question`)。这条通道已被生产、持久化、协议校验、前端渲染四层共用,新增一个成员即可,不再造第二条答案通道。 + +选择它而不是回填 `row.input.answers` 的决定性理由:`row.input` 在冷恢复时来自 durable transcript 的 `part.state.input`,那是模型声明的入参(不含 `answers`);而 `display` 会随 `completedToolPartMetadata` 写进 tool part 的 `metadata.display`,冷恢复由同一份 metadata 重建,实时链路与 replayable/冷恢复链路因此天然一致。 + +## 数据流与所有者 + +单一所有者:**CLI 工具执行结果的结构化载荷**(`ToolResultDisplayPayload`)。其余各层都是它的派生投影,不允许任何一层维护第二份答案真相。 + +```text +handler output {questions, answers} ← 唯一答案真相(用户在 elicitation 提交后由 broker 注入) + │ + ├─ createAskUserQuestionDisplay(output) ← 唯一构造点(core/tool/executor) + ▼ +ToolExecutionResult.display + ├── 实时:ToolCallResult 事件 → buildToolOutput → row.output.display ──┐ + └── 持久化:completedToolPartMetadata → part.state.metadata.display │ + └── 冷恢复/重连:transcript-hydration 合成同一事件 ───┘ + ▼ + v4 协议校验 → ToolCallRow.output.display + ▼ + toolCallRowAdapter → raw.display → UI 卡片 +``` + +- **模型可见文本不变**:`output.text` 仍由 `formatModelContent` 决定;display 只是展示载荷,绝不把结构化答案塞进 `text`。 +- **两个镜像点必须同步**:`contracts`(zod v3,产出 + 落库校验)与 `packages/shared`(zod v4,v4 协议校验)。少一个成员,strict union 会把整块 display 静默剥掉,卡片退回「未提供回答」——这正是本 bug 的形态。 + +## 接口 + +### display 载荷(两侧逐字段一致) + +```ts +{ + kind: "ask_user_question", + answers: Record, // key = 问题原文,与 formatModelContent 的映射同一份 +} +``` + +- `answers` 为空对象 = 用户未作答、runtime 自动继续;**与「没有 display」语义不同**(后者是拒绝/取消/旧数据,卡片显示「未提供回答」)。 +- 单值上限 4 KiB(字节,构造侧用 `boundDisplayText` 截断并内联 `...[truncated]`);两侧 schema 的字符上限取 4096,是 4 KiB 字节的宽松超集,保证不会因协议校验把 display 整块拒掉。 +- 问题原文作为 key 不截断:它必须与 `row.input` 的 `questions[].question` 逐字相等才能命中查表。 + +### 读取(UI) + +`readAskUserQuestionAnswers` 增加 `display` 载体,优先级最高;其后仍是既有 `output` / `input.answers` / `raw.*` 分支,旧客户端与旧快照行为不变。取值函数 `getAskUserQuestionAnswerText` 仍是唯一渲染入口。 + +旧「单条答案」形态(`{type:"answered", selected}` / `{type:"answered_custom", text}`)只携带一个答案、不带问题标识,因此只在「这次请求就问了一个问题」的输入上生效;多题输入下不再把它记到第一题,避免显示一条错位答案。 + +### 多选分隔符 + +多选答案在**上游**已被合并成单个字符串:客户端提交时 `join(", ")`,broker 的 `normalizeAnswerValue` 对数组也用 `", "`。因此展示侧在 `multiple` 问题上把这层合并还原成产品列表约定「,」再渲染;这不是解析模型文本,只是把上游合并约定换成展示约定。用户自定义输入里的 `, ` 在同一问题上按列表分隔处理——代价仅是标点,绝不改写单选的自由文本。 + +## 验收场景 + +| # | 场景 | 期望 | 覆盖 | +| --- | ------------------------ | ---------------------- | ---------------------------------------- | +| 1 | 单题单选提交 | 回显所选选项文本 | producer + 投影 + UI 解析测试 | +| 2 | 多题提交 | 每题分别回显各自答案 | producer + UI 解析测试 | +| 3 | 多选提交 | 各选项以「,」连接 | UI 取值测试 | +| 4 | 自定义输入提交 | 回显输入文本 | producer + UI 解析测试 | +| 5 | 无答案自动继续 | 「未回答,已自动继续」 | producer(保留空对象)+ UI 解析返回 `{}` | +| 6 | 冷恢复 / replayable 重放 | 与实时一致 | 落库 schema 往返 + 投影测试 | +| 7 | 拒绝 / 取消 / 旧数据 | 仍「未提供回答」 | 无 display 时回落既有分支 | + +## 兼容与迁移 + +- 纯 additive:新增 display kind,不改任何既有 kind 的字段集(两侧 union 的成员字段表是冻结的)。 +- 无 display 的历史行与旧快照不受影响,回落现状。 +- 不发 snapshot 版本号变更:`ToolCallRow.output` 形状未变,只是多了一个合法的 display 成员。 + +## 未覆盖 / 剩余风险 + +- 若历史会话的 tool part 没有 `metadata.display`(本次修复之前落库的旧数据),冷恢复后仍显示「未提供回答」;只有新产生的会话才带得上该载荷。这是可接受的降级:旧数据本来就没有结构化答案。 +- 卡片不单独渲染 display 级截断提示;超长自定义输入由内联 `...[truncated]` 标记自述。 diff --git a/packages/shared/src/kcode-protocol-v4/toolDisplay.ts b/packages/shared/src/kcode-protocol-v4/toolDisplay.ts index 2f3ac48..8827bd3 100644 --- a/packages/shared/src/kcode-protocol-v4/toolDisplay.ts +++ b/packages/shared/src/kcode-protocol-v4/toolDisplay.ts @@ -16,6 +16,25 @@ import { toolCallResumeWorkflowRunDisplaySchema, } from "./workflow-observation-display.js"; +// 构造侧按 4 KiB 字节截断单条答案(core 的 createAskUserQuestionDisplay);这里是其宽松超集, +// 两侧任何一侧更严都会让 display 校验失败、答案整块消失。 +const ASK_USER_QUESTION_DISPLAY_ANSWER_MAX_CHARS = 4_096; + +/** + * AskUserQuestion 的结构化结果展示载荷:v4 卡片回显用户答案的唯一通道(desktop v4 没有别的 + * 结构化 answers 通道,模型可见文本是 formatModelContent 拍的 `"Q"="A"`,前端不解析它)。 + * 与 CLI contracts 的 askUserQuestionToolResultDisplayPayloadSchema 是同一份契约的两侧镜像—— + * 都是 strict union 成员,少加一处会让 toolOutput.display 被静默剥掉(本 bug 的原始形态)。 + * answers 以问题原文为 key;空对象表示用户未作答而 runtime 自动继续,与「没有 display」不同。 + */ +export const askUserQuestionDisplaySchema = z + .object({ + kind: z.literal("ask_user_question"), + answers: z.record(z.string(), z.string().max(ASK_USER_QUESTION_DISPLAY_ANSWER_MAX_CHARS)), + }) + .strict(); +export type AskUserQuestionDisplay = z.infer; + // toolCall 终态 output 的结构化展示模型(port 自 feat;CUA 工具靠 kind:"cua" 分支把 // errorCode/suggestedAction/media(screenshot) 等结构化内容带到 renderer)。consume-main 之前 // 缺这个 union + toolOutputSchema.display 字段——协议层 zod 校验会把 agent 下发的 display 整个 @@ -147,6 +166,7 @@ const toolResultDisplaySchema = z.discriminatedUnion("kind", [ toolCallSavedWorkflowListDisplaySchema, toolCallListModelsDisplaySchema, toolCallResumeWorkflowRunDisplaySchema, + askUserQuestionDisplaySchema, ]); export type ToolResultDisplay = z.infer; diff --git a/packages/ui/src/ToolCallBlocks/renderers/ask-question.tsx b/packages/ui/src/ToolCallBlocks/renderers/ask-question.tsx index 4c9a0c5..7a53c17 100644 --- a/packages/ui/src/ToolCallBlocks/renderers/ask-question.tsx +++ b/packages/ui/src/ToolCallBlocks/renderers/ask-question.tsx @@ -8,6 +8,7 @@ import { } from "@/lib/askUserQuestion.js"; import { useKCodeIntl } from "@/i18n/IntlProvider.js"; import { ToolLayout } from "@/ToolCallBlocks/ToolLayout.js"; +import { readToolResultDisplay } from "@/ToolCallBlocks/toolResultDisplay.js"; import type { ToolCallBlockRenderContext } from "@/ToolCallBlocks/shared.js"; const ASK_QUESTION_TOOL_ICON = ( @@ -20,7 +21,11 @@ export function AskQuestionToolCallBlock(context: ToolCallBlockRenderContext) { const { toolCall } = toolCallNode; const input = readAskUserQuestionInput(toolCall); const data = normalizeAskUserQuestionInput(input); - const answers = readAskUserQuestionAnswers(toolCall) ?? data.answers; + // 结构化答案只走 display 通道(desktop v4 唯一携带 answers 的载体,冷恢复同源); + // 其余载体继续兜住旧快照与旧客户端形态。 + const answers = + readAskUserQuestionAnswers({ ...toolCall, display: readToolResultDisplay(toolCall.raw) }) ?? + data.answers; const wasAutomaticallyContinued = !isRunning && toolCall.status !== "failed" && diff --git a/packages/ui/src/ToolCallBlocks/toolResultDisplay.ts b/packages/ui/src/ToolCallBlocks/toolResultDisplay.ts index 03ecfff..df46868 100644 --- a/packages/ui/src/ToolCallBlocks/toolResultDisplay.ts +++ b/packages/ui/src/ToolCallBlocks/toolResultDisplay.ts @@ -1,10 +1,12 @@ import { + askUserQuestionDisplaySchema, toolCallEvalWorkflowSnippetDisplaySchema, toolCallGetWorkflowRunDisplaySchema, toolCallListModelsDisplaySchema, toolCallListWorkflowRunsDisplaySchema, toolCallResumeWorkflowRunDisplaySchema, toolCallSavedWorkflowListDisplaySchema, + type AskUserQuestionDisplay, type ToolCallEvalWorkflowSnippetDisplay, type ToolCallGetWorkflowRunDisplay, type ToolCallListModelsDisplay, @@ -70,6 +72,7 @@ export type ToolResultDisplay = | TaskOutputToolResultDisplay | RespondToCoordinatorToolResultDisplay | CuaToolResultDisplay + | AskUserQuestionDisplay | ToolCallGetWorkflowRunDisplay | ToolCallListWorkflowRunsDisplay | ToolCallEvalWorkflowSnippetDisplay @@ -82,26 +85,27 @@ function isRecord(value: unknown): value is Record { } /** - * 工作流 display kind → 解析函数的查表(新增 kind 只改这一处)。每个条目用 shared 侧的 - * strict schema safeParse,输出类型即各自 schema 的推断类型——成员输出不是 ToolResultDisplay - * 的 union 成员时这里直接编译失败,而不是靠运行时兜住。 + * shared 侧 display kind → 解析函数的查表(新增工作流 display kind 只改这一处)。每个条目用 + * shared 的 strict schema safeParse,输出类型即各自 schema 的推断类型——成员输出不是 + * ToolResultDisplay 的 union 成员时这里直接编译失败,而不是靠运行时兜住。 */ -const WORKFLOW_DISPLAY_PARSERS_BY_KIND: Record< +const SHARED_DISPLAY_PARSERS_BY_KIND: Record< string, (value: Record) => ToolResultDisplay | undefined > = { - get_workflow_run: (value) => parseWorkflowDisplay(toolCallGetWorkflowRunDisplaySchema, value), - list_workflow_runs: (value) => parseWorkflowDisplay(toolCallListWorkflowRunsDisplaySchema, value), + get_workflow_run: (value) => parseDisplayWithSchema(toolCallGetWorkflowRunDisplaySchema, value), + list_workflow_runs: (value) => + parseDisplayWithSchema(toolCallListWorkflowRunsDisplaySchema, value), eval_workflow_snippet: (value) => - parseWorkflowDisplay(toolCallEvalWorkflowSnippetDisplaySchema, value), + parseDisplayWithSchema(toolCallEvalWorkflowSnippetDisplaySchema, value), saved_workflow_list: (value) => - parseWorkflowDisplay(toolCallSavedWorkflowListDisplaySchema, value), - list_models: (value) => parseWorkflowDisplay(toolCallListModelsDisplaySchema, value), + parseDisplayWithSchema(toolCallSavedWorkflowListDisplaySchema, value), + list_models: (value) => parseDisplayWithSchema(toolCallListModelsDisplaySchema, value), resume_workflow_run: (value) => - parseWorkflowDisplay(toolCallResumeWorkflowRunDisplaySchema, value), + parseDisplayWithSchema(toolCallResumeWorkflowRunDisplaySchema, value), }; -function parseWorkflowDisplay( +function parseDisplayWithSchema( schema: { safeParse: (data: unknown) => { success: true; data: T } | { success: false } }, value: Record, ): ToolResultDisplay | undefined { @@ -253,13 +257,19 @@ function parseDisplay(value: unknown): ToolResultDisplay | undefined { }; } + // AskUserQuestion 的结构化答案(卡片回显用户选择的唯一通道):与工作流 display 同款, + // 用 shared 的 strict schema 解析,避免手写第二套字段校验造成两侧漂移。 + if (value.kind === "ask_user_question") { + return parseDisplayWithSchema(askUserQuestionDisplaySchema, value); + } + // 工作流工具的 display kind(观察五件套 + ResumeWorkflowRun 恢复卡):按 kind 查表后用 // shared 的 strict schema 解析,保证 UI 消费侧与协议侧字段表永远同步——手写第二套结构 // 校验是漂移温床。 if (typeof value.kind === "string") { - const parseWorkflowDisplayByKind = WORKFLOW_DISPLAY_PARSERS_BY_KIND[value.kind]; - if (parseWorkflowDisplayByKind !== undefined) { - return parseWorkflowDisplayByKind(value); + const parseSharedDisplayByKind = SHARED_DISPLAY_PARSERS_BY_KIND[value.kind]; + if (parseSharedDisplayByKind !== undefined) { + return parseSharedDisplayByKind(value); } } diff --git a/packages/ui/src/lib/askUserQuestion.ts b/packages/ui/src/lib/askUserQuestion.ts index a7cb52f..0257527 100644 --- a/packages/ui/src/lib/askUserQuestion.ts +++ b/packages/ui/src/lib/askUserQuestion.ts @@ -175,7 +175,10 @@ function parseKCodeAskUserQuestionOutput( const data = normalizeAskUserQuestionInput(input); const question = data.questions[0]; - if (!question) { + // 该旧形态只携带一个答案、不带任何问题标识,因此只能归属到「这一次请求就问了一个问题」的输入。 + // 多题输入下把它记到第一题上属于凭空归属(用户可能回答的是别的题),宁可返回 undefined + // 让卡片走无答案文案,也不显示一条错位答案。 + if (!question || data.questions.length > 1) { return undefined; } @@ -279,11 +282,39 @@ export function readAskUserQuestionInput(value: { return value.input; } +const ASK_USER_QUESTION_DISPLAY_KIND = "ask_user_question"; + +// v4 卡片的单条答案在**上游**就已被合并成字符串(客户端提交与 broker 归一化都用 ", " 连接多选)。 +// 卡片按产品列表约定用「,」显示,所以这里把多选的合并分隔符换回展示分隔符;只处理 multiple, +// 单选(含自定义输入里字面出现的 ", ")原样显示。 +const ANSWER_MERGE_SEPARATOR = ", "; +const ANSWER_LIST_SEPARATOR = ","; + +/** + * v4 工具卡的结构化答案载荷(kind = ask_user_question)。 + * + * Bug 修复:desktop v4 运行时唯一携带结构化 answers 的通道就是它——模型可见文本由 CLI 的 + * formatModelContent 拍成 `User has answered your questions: "Q"="A"`,而前端被设计为不解析该 + * 文本。载荷由 CLI 产出、随 tool part metadata 持久化,冷恢复与实时链路同源。 + * 空对象是「用户未作答、runtime 自动继续」的显式语义,必须原样返回,不能当成缺失。 + */ +function readAskUserQuestionDisplayAnswers(display: unknown): KCodeUserQuestionAnswers | undefined { + if (!isPlainRecord(display) || display.kind !== ASK_USER_QUESTION_DISPLAY_KIND) { + return undefined; + } + return isPlainRecord(display.answers) ? display.answers : undefined; +} + export function readAskUserQuestionAnswers(value: { input?: unknown; output?: unknown; raw?: unknown; + display?: unknown; }): KCodeUserQuestionAnswers | undefined { + const displayAnswers = readAskUserQuestionDisplayAnswers(value.display); + if (displayAnswers) { + return displayAnswers; + } const nestedOutputAnswers = readNestedAskUserQuestionAnswers(value.output); if (nestedOutputAnswers) { return nestedOutputAnswers; @@ -329,6 +360,17 @@ export function readAskUserQuestionAnswers(value: { return undefined; } +function toDisplayAnswerText(question: AskUserQuestionItem, text: string) { + if (question.type !== "multiple") { + return text; + } + const parts = text + .split(ANSWER_MERGE_SEPARATOR) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + return parts.length > 1 ? parts.join(ANSWER_LIST_SEPARATOR) : text; +} + export function getAskUserQuestionAnswerText( question: AskUserQuestionItem, answers: KCodeUserQuestionAnswers | undefined, @@ -337,10 +379,10 @@ export function getAskUserQuestionAnswerText( const value = answers?.[question.question] ?? answers?.[question.id]; if (Array.isArray(value)) { const values = value.map((item) => String(item).trim()).filter((item) => item.length > 0); - return values.length > 0 ? values.join(",") : noAnswerText; + return values.length > 0 ? values.join(ANSWER_LIST_SEPARATOR) : noAnswerText; } if (typeof value === "string") { - return value.trim().length > 0 ? value : noAnswerText; + return value.trim().length > 0 ? toDisplayAnswerText(question, value) : noAnswerText; } if (value === undefined || value === null) { return noAnswerText; diff --git a/packages/ui/test/askUserQuestionDisplay.test.ts b/packages/ui/test/askUserQuestionDisplay.test.ts new file mode 100644 index 0000000..9ce9593 --- /dev/null +++ b/packages/ui/test/askUserQuestionDisplay.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + askUserQuestionDisplaySchema, + toolOutputSchema, +} from "../../shared/src/kcode-protocol-v4/toolDisplay.js"; +import { + getAskUserQuestionAnswerText, + normalizeAskUserQuestionInput, + readAskUserQuestionAnswers, +} from "../src/lib/askUserQuestion.js"; + +// desktop v4 卡片回显用户答案的验收面:结构化答案只在 output.display(kind = ask_user_question) +// 里;模型可见文本由 CLI 的 formatModelContent 拍成 `"Q"="A"`,前端被设计为不解析它。 +const QUESTION = "用哪个方案?"; +const NO_ANSWER = "未提供回答"; + +function questionOf(multiSelect: boolean) { + const data = normalizeAskUserQuestionInput({ + questions: [{ question: QUESTION, multiSelect, options: ["A 方案", "B 方案"] }], + }); + const question = data.questions[0]; + assert.ok(question); + return question; +} + +test("display 通道回显结构化答案,空对象是「未作答、自动继续」的显式语义", () => { + assert.deepEqual( + readAskUserQuestionAnswers({ + display: { kind: "ask_user_question", answers: { [QUESTION]: "A 方案" } }, + // 同时给出模型文本:它是纯展示文本,不得被当作答案来源。 + output: `User has answered your questions: "${QUESTION}"="A 方案".`, + }), + { [QUESTION]: "A 方案" }, + ); + assert.deepEqual( + readAskUserQuestionAnswers({ display: { kind: "ask_user_question", answers: {} } }), + {}, + ); +}); + +test("非 ask_user_question 的 display 不参与答案解析", () => { + assert.equal( + readAskUserQuestionAnswers({ + display: { kind: "cua", schemaVersion: 1, toolName: "request_access", status: "success" }, + }), + undefined, + ); + assert.equal(readAskUserQuestionAnswers({ display: { kind: "ask_user_question" } }), undefined); +}); + +test("多选答案按产品列表约定用「,」连接", () => { + const multi = questionOf(true); + assert.equal( + getAskUserQuestionAnswerText(multi, { [QUESTION]: "A 方案, B 方案" }, NO_ANSWER), + "A 方案,B 方案", + ); + // 上游合并分隔符出现在自由输入里时按列表分隔处理:代价仅是标点,不改写文本内容。 + assert.equal( + getAskUserQuestionAnswerText(multi, { [QUESTION]: "自定义, 带逗号" }, NO_ANSWER), + "自定义,带逗号", + ); +}); + +test("单选回显原样文本;缺答案回落到无答案文案", () => { + const single = questionOf(false); + assert.equal( + getAskUserQuestionAnswerText(single, { [QUESTION]: "自定义, 带逗号" }, NO_ANSWER), + "自定义, 带逗号", + ); + assert.equal(getAskUserQuestionAnswerText(single, {}, NO_ANSWER), NO_ANSWER); + assert.equal(getAskUserQuestionAnswerText(single, undefined, NO_ANSWER), NO_ANSWER); +}); + +test("v4 协议镜像接受结构化答案 display,并保持 strict", () => { + const display = { kind: "ask_user_question" as const, answers: { [QUESTION]: "A 方案" } }; + // 缺 union 成员时 toolOutput.display 会被 .catch(undefined) 静默剥掉,这就是原 bug 的形态。 + assert.deepEqual(toolOutputSchema.parse({ text: "模型可见文本", display }).display, display); + assert.equal(askUserQuestionDisplaySchema.safeParse({ ...display, extra: true }).success, false); +}); + +test("旧「单条答案」形态只归属单题输入,多题输入不凭空记到第一题", () => { + const single = { question: QUESTION, options: [{ label: "A 方案" }, { label: "B 方案" }] }; + assert.deepEqual( + readAskUserQuestionAnswers({ + input: single, + output: { type: "answered", selected: "A 方案" }, + }), + { [QUESTION]: "A 方案" }, + ); + assert.equal( + readAskUserQuestionAnswers({ + input: { + questions: [single, { question: "还要什么?", options: [{ label: "C" }, { label: "D" }] }], + }, + output: { type: "answered", selected: "A 方案" }, + }), + undefined, + ); +});