Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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);
});
27 changes: 27 additions & 0 deletions apps/kcode-cli/packages/contracts/src/tools/ask-user-question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,33 @@ export const AskUserQuestionOutputSchema = z

export type AskUserQuestionOutput = z.infer<typeof AskUserQuestionOutputSchema>;

/**
* 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),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CreateWorkflowCausalityGraphSchema,
createWorkflowToolResultDisplayDiagnosticSchema,
} from "./create-workflow.js";
import { askUserQuestionToolResultDisplayPayloadSchema } from "./ask-user-question.js";
import {
evalWorkflowSnippetToolResultDisplayPayloadSchema,
getWorkflowRunToolResultDisplayPayloadSchema,
Expand Down Expand Up @@ -248,6 +249,7 @@ export const toolResultDisplayPayloadSchema = z.discriminatedUnion("kind", [
savedWorkflowListToolResultDisplayPayloadSchema,
listModelsToolResultDisplayPayloadSchema,
resumeWorkflowRunToolResultDisplayPayloadSchema,
askUserQuestionToolResultDisplayPayloadSchema,
]);

export type FileDiffToolResultDisplayPayload = z.infer<
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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);
});
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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 };
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ASK_USER_QUESTION_TOOL_NAME,
RESPOND_TO_COORDINATOR_TOOL_NAME,
RespondToCoordinatorOutputSchema,
MCP_TOOL_DISPLAY_MAX_DESCRIPTION_CHARS,
Expand All @@ -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";

Expand Down Expand Up @@ -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;

Expand Down
Loading