Skip to content

Commit 4cd408d

Browse files
committed
feat: enhance AskUserQuestion handling and add answer tagging
1 parent c5d8956 commit 4cd408d

11 files changed

Lines changed: 152 additions & 18 deletions

File tree

‎packages/cli/src/tests/ask-user-question.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,20 @@ test("formatAskUserQuestionAnswers creates model-readable answer text", () => {
102102
"Which package manager?": "yarn",
103103
"Any notes?": "Use the existing lockfile",
104104
}),
105-
'User has answered your questions: "Which package manager?"="yarn", "Any notes?"="Use the existing lockfile". You can now continue with the user\'s answers in mind.'
105+
[
106+
"Questions 2/2 answered",
107+
" - `Which package manager?`",
108+
" answer: yarn",
109+
" - `Any notes?`",
110+
" answer: Use the existing lockfile",
111+
].join("\n")
112+
);
113+
});
114+
115+
test("formatAskUserQuestionAnswers normalizes multiline answers and escapes question backticks", () => {
116+
assert.equal(
117+
formatAskUserQuestionAnswers({ "Which `mode`?": "Use fast\nmode" }),
118+
["Questions 1/1 answered", " - `Which \\`mode\\`?`", " answer: Use fast mode"].join("\n")
106119
);
107120
});
108121

‎packages/cli/src/ui/core/ask-user-question.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,13 @@ export function findPendingAskUserQuestion(
4747
}
4848

4949
export function formatAskUserQuestionAnswers(answers: AskUserQuestionAnswers): string {
50-
const answersText = Object.entries(answers)
51-
.map(([question, answer]) => `"${escapeAnswerPart(question)}"="${escapeAnswerPart(answer)}"`)
52-
.join(", ");
53-
return `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`;
50+
const entries = Object.entries(answers);
51+
const lines = [`Questions ${entries.length}/${entries.length} answered`];
52+
for (const [question, answer] of entries) {
53+
lines.push(` - \`${formatQuestion(question)}\``);
54+
lines.push(` answer: ${formatAnswer(answer)}`);
55+
}
56+
return lines.join("\n");
5457
}
5558

5659
export function formatAskUserQuestionDecline(): string {
@@ -128,6 +131,10 @@ function normalizeOption(raw: unknown): AskUserQuestionOption | null {
128131
};
129132
}
130133

131-
function escapeAnswerPart(value: string): string {
132-
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim();
134+
function formatQuestion(value: string): string {
135+
return value.replace(/`/g, "\\`").replace(/\s+/g, " ").trim();
136+
}
137+
138+
function formatAnswer(value: string): string {
139+
return value.replace(/\s+/g, " ").trim();
133140
}

‎packages/cli/src/ui/views/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
406406
permissions: submission.permissions,
407407
alwaysAllows: submission.alwaysAllows,
408408
planMode: submission.planMode ?? planMode,
409+
isAnswers: submission.isAnswers,
409410
};
410411
const activeSessionId = sessionManager.getActiveSessionId();
411412
const permissionReply =
@@ -902,6 +903,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes
902903
void handlePrompt({
903904
text: formatAskUserQuestionAnswers(answers),
904905
imageUrls: [],
906+
isAnswers: true,
905907
});
906908
},
907909
[handlePrompt]

‎packages/cli/src/ui/views/PromptInput.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export type PromptSubmission = {
7373
permissions?: UserToolPermission[];
7474
alwaysAllows?: PermissionScope[];
7575
planMode?: boolean;
76+
isAnswers?: boolean;
7677
command?: "new" | "resume" | "fork" | "continue" | "undo" | "mcp" | "exit";
7778
};
7879

‎packages/core/src/common/openai-message-converter.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open
22
import { supportsMultimodal, type MultimodalMode } from "./model-capabilities";
33
import type { SessionMessage } from "../session";
44

5+
const ANSWERS_SYSTEM_MESSAGE =
6+
"User has answered your questions. You can now continue with the user's answers in mind.";
7+
58
export type OpenAIMessageConverterOptions = {
69
/** Optional callback to render the /init command prompt template. */
710
renderInitPrompt?: () => string;
@@ -40,6 +43,11 @@ export class OpenAIMessageConverter {
4043
}
4144

4245
openAIMessages.push(this.convertMessage(message, thinkingEnabled, model, multimodal));
46+
if (message.role === "user" && message.meta?.isAnswers) {
47+
openAIMessages.push(
48+
this.convertMessage(this.buildAnswersSystemMessage(message), thinkingEnabled, model, multimodal)
49+
);
50+
}
4351

4452
const toolCalls = this.getAssistantToolCalls(message);
4553
if (toolCalls.length === 0) {
@@ -148,6 +156,21 @@ export class OpenAIMessageConverter {
148156
return message.content ?? "";
149157
}
150158

159+
private buildAnswersSystemMessage(message: SessionMessage): SessionMessage {
160+
return {
161+
id: `${message.id}:answers`,
162+
sessionId: message.sessionId,
163+
role: "system",
164+
content: ANSWERS_SYSTEM_MESSAGE,
165+
contentParams: null,
166+
messageParams: null,
167+
compacted: false,
168+
visible: false,
169+
createTime: message.createTime,
170+
updateTime: message.updateTime,
171+
};
172+
}
173+
151174
private pairToolMessages(messages: SessionMessage[]): Map<string, number> {
152175
const pairings = new Map<string, number>();
153176
const usedToolMessageIndexes = new Set<number>();

‎packages/core/src/session.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ export type MessageMeta = {
294294
paramsMd?: string;
295295
resultMd?: string;
296296
asThinking?: boolean;
297+
isAnswers?: boolean;
297298
isSummary?: boolean;
298299
isModelChange?: boolean;
299300
skill?: SkillInfo;
@@ -331,6 +332,7 @@ export type UserPromptContent = {
331332
permissions?: UserToolPermission[];
332333
alwaysAllows?: PermissionScope[];
333334
planMode?: boolean;
335+
isAnswers?: boolean;
334336
};
335337

336338
type PersistedPromptImage = {
@@ -2487,7 +2489,10 @@ ${agentInstructions}
24872489
visible: true,
24882490
createTime: now,
24892491
updateTime: now,
2490-
meta: { userPrompt: this.cloneUserPromptForMeta(prompt) },
2492+
meta: {
2493+
userPrompt: this.cloneUserPromptForMeta(prompt),
2494+
isAnswers: prompt.isAnswers,
2495+
},
24912496
checkpointHash: this.getCurrentCheckpointHash(sessionId),
24922497
};
24932498
}
@@ -2917,6 +2922,7 @@ ${agentInstructions}
29172922
permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined,
29182923
alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined,
29192924
planMode: prompt.planMode,
2925+
isAnswers: prompt.isAnswers,
29202926
};
29212927
}
29222928

‎packages/core/src/tests/openai-message-converter.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,48 @@ test("OpenAIMessageConverter multimodal config overrides model-based filtering",
135135
]);
136136
});
137137

138+
test("OpenAIMessageConverter appends an answers system message after tagged user messages", () => {
139+
const c = converter();
140+
const messages: SessionMessage[] = [
141+
msg({
142+
id: "answers-1",
143+
role: "user",
144+
content: "Questions 1/1 answered",
145+
meta: { isAnswers: true },
146+
}),
147+
];
148+
149+
const result = c.buildMessages(messages, false, "test-model") as Array<{ role: string; content: string }>;
150+
151+
assert.deepEqual(result, [
152+
{ role: "user", content: "Questions 1/1 answered" },
153+
{
154+
role: "system",
155+
content: "User has answered your questions. You can now continue with the user's answers in mind.",
156+
},
157+
]);
158+
assert.equal(messages.length, 1);
159+
assert.equal((c as any).buildAnswersSystemMessage(messages[0]).visible, false);
160+
});
161+
162+
test("OpenAIMessageConverter does not append an answers system message for ordinary or compacted messages", () => {
163+
const c = converter();
164+
const messages: SessionMessage[] = [
165+
msg({ id: "ordinary", role: "user", content: "hello" }),
166+
msg({
167+
id: "old-answers",
168+
role: "user",
169+
content: "Questions 1/1 answered",
170+
compacted: true,
171+
meta: { isAnswers: true },
172+
}),
173+
];
174+
175+
const result = c.buildMessages(messages, false, "test-model") as Array<{ role: string; content: string }>;
176+
177+
assert.deepEqual(result, [{ role: "user", content: "hello" }]);
178+
});
179+
138180
test("OpenAIMessageConverter injects reasoning_content in thinking mode", () => {
139181
const c = converter();
140182
const messages: SessionMessage[] = [msg({ role: "assistant", content: "Final answer", messageParams: null })];

‎packages/core/src/tests/session.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,24 @@ test("SessionManager persists Plan Mode and appends prompts only on mode transit
622622
assert.equal(messages.filter((message) => message.content === PLAN_MODE_OFF_STATUS_MESSAGE).length, 1);
623623
});
624624

625+
test("SessionManager tags AskUserQuestion answer messages", async () => {
626+
const workspace = createTempDir("deepcode-answers-workspace-");
627+
const home = createTempDir("deepcode-answers-home-");
628+
setHomeDir(home);
629+
630+
const manager = createMockedClientSessionManager(workspace, [
631+
createChatResponse("continued", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
632+
]);
633+
const sessionId = await manager.createSession({
634+
text: "Questions 1/1 answered\n - `Continue?`\n answer: Yes",
635+
isAnswers: true,
636+
});
637+
const message = manager.listSessionMessages(sessionId).find((item) => item.role === "user");
638+
639+
assert.equal(message?.meta?.isAnswers, true);
640+
assert.equal(message?.meta?.userPrompt?.isAnswers, true);
641+
});
642+
625643
test("SessionManager excludes the former bundled plan skill and defaults legacy sessions to Default mode", async () => {
626644
const workspace = createTempDir("deepcode-plan-legacy-workspace-");
627645
const home = createTempDir("deepcode-plan-legacy-home-");

‎packages/vscode-ide-companion/resources/webview.html‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@
541541
}
542542

543543
function buildAskUserQuestionReply(questions, form) {
544-
const lines = ["Answer to AskUserQuestion:"];
544+
const lines = [`Questions ${questions.length}/${questions.length} answered`];
545545

546546
for (let index = 0; index < questions.length; index += 1) {
547547
const question = questions[index];
@@ -558,14 +558,12 @@
558558
};
559559
}
560560

561-
lines.push("");
562-
lines.push(`${index + 1}. ${question.question}`);
563-
if (selectedOptions.length > 0) {
564-
lines.push(`- Selected: ${selectedOptions.join(", ")}`);
565-
}
561+
const answers = [...selectedOptions];
566562
if (otherText) {
567-
lines.push(`- Other: ${otherText}`);
563+
answers.push(otherText);
568564
}
565+
lines.push(` - \`${String(question.question || "").replace(/`/g, "\\`")}\``);
566+
lines.push(` answer: ${answers.join(", ")}`);
569567
}
570568

571569
return {
@@ -680,7 +678,7 @@
680678
}
681679
errorMessage.textContent = "";
682680
setAskUserQuestionFormState(form, false);
683-
sendUserPromptText(reply.text);
681+
sendUserPromptText(reply.text, undefined, [], { isAnswers: true });
684682
});
685683

686684
wrapper.appendChild(form);
@@ -1997,6 +1995,7 @@
19971995
const normalizedImages = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : [];
19981996
const permissions = Array.isArray(options.permissions) ? options.permissions : undefined;
19991997
const alwaysAllows = Array.isArray(options.alwaysAllows) ? options.alwaysAllows : undefined;
1998+
const isAnswers = options.isAnswers === true;
20001999
const hasPermissionPayload = Boolean(
20012000
(permissions && permissions.length > 0) || (alwaysAllows && alwaysAllows.length > 0)
20022001
);
@@ -2014,7 +2013,8 @@
20142013
skills: skills && skills.length > 0 ? skills : undefined,
20152014
images: normalizedImages.length > 0 ? normalizedImages : undefined,
20162015
permissions,
2017-
alwaysAllows
2016+
alwaysAllows,
2017+
isAnswers: isAnswers || undefined
20182018
});
20192019
return true;
20202020
}

‎packages/vscode-ide-companion/src/provider.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export async function handleWebviewMessage(message: unknown, deps: ProviderDeps)
8686
await handlePrompt(prompt, skills, images, sessionManager, postMessage, renderMarkdown, {
8787
permissions: permissions.length > 0 ? permissions : undefined,
8888
alwaysAllows: alwaysAllows.length > 0 ? alwaysAllows : undefined,
89+
isAnswers: msg.isAnswers === true ? true : undefined,
8990
});
9091
return true;
9192
}
@@ -238,7 +239,7 @@ async function handlePrompt(
238239
sessionManager: ProviderDeps["sessionManager"],
239240
postMessage: PostMessageFn,
240241
renderMarkdown: (text: string) => string,
241-
options: { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[] } = {}
242+
options: { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; isAnswers?: boolean } = {}
242243
): Promise<void> {
243244
const normalizedImages = imageUrls.filter(Boolean);
244245
const displayPrompt = prompt || (normalizedImages.length > 0 ? "粘贴的图像" : "");
@@ -260,6 +261,7 @@ async function handlePrompt(
260261
imageUrls: normalizedImages,
261262
permissions: options.permissions,
262263
alwaysAllows: options.alwaysAllows,
264+
isAnswers: options.isAnswers,
263265
});
264266
await sendSkillsList(sessionManager, postMessage);
265267

0 commit comments

Comments
 (0)