feat(web-shell): keep question answers visible as user-style messages (#11461)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-09 12:05:57 +00:00
1 parent bae90d7afa
commit 2c5250daa6
34 files changed
+1574 -92

No files matched your search

@@ -0,0 +1,76 @@
# Always-visible question answers
[English](2026-09-09-web-shell-question-message.md) | [简体中文](2026-09-09-web-shell-question-message.zh-CN.md)
## Problem and scope
Completed AskUserQuestion results currently join tool summaries and disappear
inside the completed turn's “Processed” fold. Keep these records visible like
user messages, with a right-aligned bubble containing each question above its
answer. Exclude AskUserQuestion from the Processed tool count. Preserve model-authored question text, including prefixes. Pending question controls keep their interaction; their toggle gains explicit Expand/Collapse text and a down/up action arrow. Nested subagent tools keep their behavior.
## Design
MessageList isolates completed question tools from mixed groups, excludes them
from compact merging, and keeps them outside completed-turn folding. Preserve
the tool_group role, chronological order, and tool call identity so answers do
not create new user turns. MessageItem renders a question-answer bubble using
UserMessage's existing layout classes without applying user-input parsers.
The producer returns a structured display result:
`{ type: 'ask_user_question_answers', text, answers: [{ question, answer }] }`.
The existing model-facing text stays unchanged. PostToolUse hooks receive the
original string in `tool_response.returnDisplay`; PostToolBatch hooks receive
the readable text in `tool_calls[].tool_response.result_display`. Shared hook
normalization copies each question response without mutating the structured
UI/history result. History and recording use the existing 32,000-character
compactor for each display text, question, and answer field, preserving the
question/answer structure and truncation markers. Short fields and the original
model-facing result remain unchanged.
CLI renderers display `text`;
Web Shell validates the discriminator and string fields, then renders the answer
array directly. Partial submissions include only the answers actually supplied.
There is no prefix matching, regex parsing, or timestamp-based migration.
Only structured results use the right-aligned question/answer bubble. Historical
string results, including single questions, remain in the left-side ToolGroup
with its existing expansion control. This change excludes both formats from the
Processed turn fold and tool count. Empty results render no bubble or timestamp.
ACP and persisted records already carry structured result displays. The SDK
projects the new result into a bounded, allowlisted `question_answers` preview;
document exports sanitize and preserve those fields. Web Shell reconstructs the
same display shape from safe previews, keeping live, replay, and exported views
consistent. Unknown or malformed results use the existing tool presentation.
Multiline answers and literal code remain unchanged within these preview limits.
Live tool preparation can create an empty argument placeholder before the
permission request supplies full questions. Associate permission question data
with tool calls after projecting all blocks, and fill missing question arguments
rather than treating an empty object as complete. This keeps immediate submission
consistent with history replay without changing stored messages.
## Files and validation
Update the core result type and producer, CLI text fallbacks, SDK previews,
export validation, and their focused tests.
Change MessageList, MessageItem, toolFormatting, and add the result component
with scoped styles. Update collocated transcript and grouping tests. Cover
compact and normal views, mixed tool groups, completed-turn folding, multiple
questions, multiline answers, and historical replay. Run browser validation,
focused unit tests, build, typecheck, bundle, and full-diff self-review.
## Acceptance criteria and risks
New structured question answers remain visible before and after expanding or
collapsing Processed, in the original answer order. Questions precede their
answers with no inner fold. Legacy records remain on the left. Live completion
and reload use the same question/answer layout; oversized saved previews retain
explicit truncation markers. Long text wraps inside the bubble. Marker-shaped answer lines
cannot create extra answers because display boundaries come from the array.
Unknown fields must not survive safe projection or export. No historical data
is rewritten; the result discriminator determines presentation.
## Open questions
None.
@@ -0,0 +1,59 @@
# 始终可见的问答记录
[English](2026-09-09-web-shell-question-message.md) | [简体中文](2026-09-09-web-shell-question-message.zh-CN.md)
## 问题与范围
已完成的 AskUserQuestion 结果目前会合并到工具摘要,并隐藏在整轮对话的
“已处理”折叠区域。问答记录应像用户消息一样始终可见,在右侧气泡内先显示
问题,再换行显示答案。“已处理”的工具次数排除 AskUserQuestion。保留模型生成的问题原文,包括前缀。待回答控件的交互不变;折叠按钮增加明确的“展开/收起”文案,分别配合向下/向上的操作箭头。子代理内部工具保持现有行为。
## 设计
MessageList 从混合工具组中分离已完成的问答工具,阻止其参与紧凑摘要合并,
并将其保留在整轮折叠之外。保留 tool_group 角色、时间顺序和工具调用标识,
避免将答案误判为新的用户轮次。MessageItem 使用 UserMessage 的现有布局类
显示问答气泡,不调用用户输入解析器。
生产端返回结构化显示结果:
`{ type: 'ask_user_question_answers', text, answers: [{ question, answer }] }`。
给模型的原有文本保持不变。PostToolUse hook 的 `tool_response.returnDisplay`
继续为原字符串;PostToolBatch hook 的 `tool_calls[].tool_response.result_display`
也继续使用可读文本。共享 hook 转换复制问答响应,不修改界面及历史使用的结构化结果。
历史显示和录制对每个显示文本、问题和答案字段复用现有 32,000 字符压缩上限,
保留问答结构及截断标记。短字段和原始模型内容不变。
CLI 渲染器显示 `text`;Web Shell 校验类型标识及字符串
字段后,直接渲染问答数组。部分提交只包含实际提供的答案。不匹配前缀、不使用正则
拆分,也不按时间迁移展示。
仅结构化结果使用右侧问答气泡。历史字符串结果(包括单题)继续使用左侧 ToolGroup
及其原有展开控件。本次改动将两种格式都排除在“已处理”整轮折叠和工具计数之外。
空结果不渲染气泡或时间戳。
ACP 和持久化记录已支持传递结构化显示结果。SDK 将新结果投影为有长度限制、
仅包含白名单字段的 `question_answers` 预览;文档导出安全处理并保留这些字段。
Web Shell 从安全预览恢复相同显示结构,使实时、回放和导出一致。未知或格式错误
的结果使用现有工具展示。在上述预览限额内,多行答案和代码原文保持不变。
实时工具准备事件可能先创建参数为空的占位记录,完整问题随后才由提问请求提供。
因此在所有消息块转换完成后,再将提问参数关联到工具调用;判断问题参数是否缺失,
不把空对象当作完整参数。这让刚提交后的展示与历史回放一致,无需修改存储消息。
## 文件与验证
更新核心结果类型及生产逻辑、CLI 文本回退、SDK 预览、导出校验及定向测试。
修改 MessageList、MessageItem、toolFormatting,新增结果组件及作用域样式。
更新同目录的对话和分组测试,覆盖紧凑及普通视图、混合工具组、整轮折叠、
多问题、多行答案和历史回放。执行浏览器验证、定向单元测试、构建、类型检查、
打包和完整差异自查。
## 验收标准与风险
展开或折叠“已处理”前后,新结构化问答始终可见,并按原始答案顺序逐项显示。
问题在答案上方,无内部折叠。旧记录保留在左侧。实时完成与刷新后使用相同的问答布局;
超长的已存预览保留明确截断标记。长文本在气泡内换行。问答边界来自数组,答案中的标题形状文本不能生成额外答案。
未知字段不能进入安全预览或导出。不重写历史数据;由结果的类型标识决定展示方式。
## 待定问题
无。
@@ -1912,6 +1912,24 @@ describe('<ToolMessage />', () => {
expect(output).toContain('- Step 2: Do another thing');
});
it('renders structured question answers as their display text', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
forceShowResult
resultDisplay={{
type: 'ask_user_question_answers',
text: 'Selected Staging',
answers: [{ question: 'Deploy where?', answer: 'Staging' }],
}}
/>,
StreamingState.Idle,
);
expect(lastFrame()).toContain('MockMarkdown:Selected Staging');
expect(lastFrame()).not.toContain('ask_user_question_answers');
});
it('renders MCP App fallback text instead of stringifying HTML', () => {
const html = `<main>PROBE_MCP_APP_HTML${'x'.repeat(200)}</main>`;
const { lastFrame } = renderWithContext(
@@ -322,6 +322,17 @@ const useResultDisplayRenderer = (
};
}
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'ask_user_question_answers' &&
'text' in resultDisplay &&
typeof resultDisplay.text === 'string'
) {
return { type: 'string', data: resultDisplay.text };
}
// Default to string — safeguard against non-string objects
return {
type: 'string',
@@ -123,6 +123,34 @@ async function waitFor(assertion: () => void): Promise<void> {
}
describe('reduceDaemonEventToTuiUpdates', () => {
it('renders structured question answers as sanitized display text', () => {
const updates = reduceDaemonEventToTuiUpdates({
id: 1,
v: 1,
type: 'session_update',
data: {
sessionId: 'session-1',
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'ask-1',
status: 'completed',
rawOutput: {
type: 'ask_user_question_answers',
text: '\x1b[31mSelected Staging\x1b[0m',
answers: [{ question: 'Deploy where?', answer: 'Staging' }],
},
},
},
});
expect(updates).toMatchObject([
{
type: 'tool_group_update',
item: { tools: [{ resultDisplay: 'Selected Staging' }] },
},
]);
});
it('preserves a sanitized vision bridge notice as structured output', () => {
const updates = reduceDaemonEventToTuiUpdates({
id: 1,
@@ -337,6 +337,13 @@ function formatToolResultDisplay(
) {
return sanitizeDisplayText(value['fallbackText']);
}
if (
isRecord(value) &&
value['type'] === 'ask_user_question_answers' &&
typeof value['text'] === 'string'
) {
return sanitizeDisplayText(value['text']);
}
if (isRecord(value) && value['type'] === 'findings_list') {
// Discriminator-first rejection: a findings_list record that fails the
// full shape check falls back to the text rendering below no matter
@@ -740,6 +740,16 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
).toBe('◌ [2] working');
});
it('renders structured question answers as their display text', () => {
expect(
renderResultDisplay({
type: 'ask_user_question_answers',
text: 'Deploy where?\nStaging',
answers: [{ question: 'Deploy where?', answer: 'Staging' }],
}),
).toBe('Deploy where?\nStaging');
});
it('renders mcp_app with its fallbackText only', () => {
expect(
renderResultDisplay({
@@ -290,6 +290,12 @@ export function renderResultDisplay(display: unknown): string {
.map((line) => line.map((t) => t.text ?? '').join(''))
.join('\n');
}
if (
o['type'] === 'ask_user_question_answers' &&
typeof o['text'] === 'string'
) {
return o['text'];
}
// Structured displays ink's classifyDisplay handles individually.
if (o['type'] === 'plan_summary') {
const message = typeof o['message'] === 'string' ? o['message'] : '';
@@ -716,6 +716,28 @@
"toolResultPreview": {
"oneOf": [
{ "$ref": "#/$defs/todoPreview" },
{
"type": "object",
"additionalProperties": false,
"required": ["kind", "text", "answers"],
"properties": {
"kind": { "const": "question_answers" },
"text": { "type": "string", "maxLength": 409600 },
"answers": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["question", "answer"],
"properties": {
"question": { "type": "string", "maxLength": 409600 },
"answer": { "type": "string", "maxLength": 409600 }
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
@@ -1215,6 +1215,77 @@ describe('ExportTranscriptDocumentV1', () => {
);
});
it('exports structured question answers through the safe preview allowlist', () => {
const document = createExportTranscriptDocumentV1(
[
record('tool-start', null, {
type: 'assistant',
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'ask-1',
name: 'ask_user_question',
args: { questions: [] },
},
},
],
},
}),
record('tool-result', 'tool-start', {
type: 'tool_result',
message: {
role: 'user',
parts: [
{
functionResponse: {
id: 'ask-1',
name: 'ask_user_question',
response: { output: 'Question A: first\n**B**: embedded' },
},
},
],
},
toolCallResult: {
callId: 'ask-1',
resultDisplay: {
type: 'ask_user_question_answers',
text: 'Question A: first\n**B**: embedded',
answers: [
{
question: 'Read /Users/alice/example.ts?',
answer: 'first\n**B**: embedded',
secret: CANARY,
},
],
secret: CANARY,
},
},
}),
],
sessionData,
EXPORT_OPTIONS,
);
const tool = document.blocks.find((block) => block.kind === 'tool');
expect(tool?.resultPreview).toEqual({
kind: 'question_answers',
text: 'Question A: first\n**B**: embedded',
answers: [
{
question: 'Read [home]/example.ts?',
answer: 'first\n**B**: embedded',
},
],
});
expect(JSON.stringify(document)).not.toContain(CANARY);
expect(() => assertExportTranscriptDocumentV1(document)).not.toThrow();
const invalid = structuredClone(document);
const invalidTool = invalid.blocks.find((block) => block.kind === 'tool');
Object.assign(invalidTool!.resultPreview!, { secret: CANARY });
expect(() => assertExportTranscriptDocumentV1(invalid)).toThrow();
});
it('degrades a completed tool when its safe result preview is unavailable', () => {
const document = createExportTranscriptDocumentV1(
[
@@ -125,6 +125,7 @@ type ExportToolPreviewV1 =
| ToolPreviewPick<'generic', 'summary'>;
type ExportToolResultPreviewV1 =
| ExportTodoListPreviewV1
| Extract<DaemonToolResultPreview, { kind: 'question_answers' }>
| { kind: 'text'; text: string }
| { kind: 'generic'; summary: string };
@@ -795,6 +796,16 @@ function sanitizeResultPreview(
if (preview.kind === 'text') {
return { kind: 'text', text: budget.text(preview.text) };
}
if (preview.kind === 'question_answers') {
return {
kind: 'question_answers',
text: budget.plainText(preview.text),
answers: budget.array(preview.answers).map(({ question, answer }) => ({
question: budget.plainText(question),
answer: budget.plainText(answer),
})),
};
}
if (!preview.summary?.trim()) return undefined;
const summary = budget.text(preview.summary);
return summary.trim()
@@ -1710,7 +1721,10 @@ function isMarkdownExportText(
path: readonly string[],
): boolean {
if (path.at(-2) === 'resultPreview') {
return key === 'text' || key === 'summary';
return (
parent?.['kind'] !== 'question_answers' &&
(key === 'text' || key === 'summary')
);
}
return (
key === 'text' &&
@@ -1768,6 +1782,7 @@ const VISIBLE_EXPORT_TEXT_FIELDS = new Set([
'command',
'cwd',
'question',
'answer',
'path',
'oldText',
'newText',
@@ -1428,6 +1428,52 @@ describe('HookEventHandler', () => {
});
describe('firePostToolBatchEvent', () => {
it('preserves question text for batch hooks without changing other results', async () => {
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(
createMockExecutionPlan([
{
type: HookType.Command,
command: 'echo test',
source: HooksConfigSource.Project,
},
]),
);
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
createMockAggregatedResult(true),
);
const display = Object.freeze({
type: 'ask_user_question_answers',
text: 'Original answer text',
answers: [{ question: 'Continue?', answer: 'Yes' }],
});
const response = Object.freeze({
result_display: display,
response_parts: [],
});
const calls = ['ask_user_question', 'other_tool'].map((tool_name) =>
Object.freeze({
tool_name,
tool_input: {},
tool_use_id: tool_name,
status: 'success' as const,
tool_response: response,
}),
);
await hookEventHandler.firePostToolBatchEvent(calls);
expect(
vi.mocked(mockHookRunner.executeHooksParallel).mock.calls[0][2],
).toMatchObject({
tool_calls: [
{
tool_response: { result_display: display.text, response_parts: [] },
},
{ tool_response: { result_display: display, response_parts: [] } },
],
});
expect(calls[0].tool_response.result_display).toBe(display);
});
it('should execute hooks for PostToolBatch without matcher context', async () => {
const mockPlan = createMockExecutionPlan([]);
const mockAggregated = createMockAggregatedResult(true);
@@ -2041,6 +2087,56 @@ describe('HookEventHandler', () => {
});
describe('firePostToolUseEvent', () => {
it.each(['ask_user_question', 'other_tool'])(
'preserves the hook display contract for %s without mutating results',
async (toolName) => {
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(
createMockExecutionPlan([
{
type: HookType.Command,
command: 'echo test',
source: HooksConfigSource.Project,
},
]),
);
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
createMockAggregatedResult(true),
);
const text =
'User has provided the following answers:\n\n**A**: first\n**B**: embedded';
const display = Object.freeze({
type: 'ask_user_question_answers',
text,
answers: [
{ question: 'Question A?', answer: 'first\n**B**: embedded' },
],
});
const response = Object.freeze({
llmContent: text,
returnDisplay: display,
});
await hookEventHandler.firePostToolUseEvent(
toolName,
{},
response,
'toolu_question',
PermissionMode.Default,
);
expect(
vi.mocked(mockHookRunner.executeHooksParallel).mock.calls[0][2],
).toMatchObject({
tool_response: {
llmContent: text,
returnDisplay: toolName === 'ask_user_question' ? text : display,
},
});
expect(response.returnDisplay).toBe(display);
},
);
it('should execute hooks for PostToolUse event', async () => {
const mockPlan = createMockExecutionPlan([]);
const mockAggregated = createMockAggregatedResult(true);
+39 -2
View File
@@ -62,9 +62,31 @@ import { createDebugLogger } from '../utils/debugLogger.js';
import { logHookCall } from '../telemetry/loggers.js';
import { HookCallEvent } from '../telemetry/types.js';
import type { CronJob } from '../services/cronScheduler.js';
import { ToolNames } from '../tools/tool-names.js';
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
function normalizeQuestionHookResponse(
toolName: string,
response: Record<string, unknown>,
displayKey: 'returnDisplay' | 'result_display',
): Record<string, unknown> {
const display = response[displayKey];
if (
toolName === ToolNames.ASK_USER_QUESTION &&
display !== null &&
typeof display === 'object' &&
'type' in display &&
display.type === 'ask_user_question_answers' &&
'text' in display &&
typeof display.text === 'string'
) {
// Preserve the existing hook contract without changing the stored UI result.
return { ...response, [displayKey]: display.text };
}
return response;
}
/**
* Hook event bus that coordinates hook execution across the system
*/
@@ -409,7 +431,11 @@ export class HookEventHandler {
permission_mode: permissionMode,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
tool_response: normalizeQuestionHookResponse(
toolName,
toolResponse,
'returnDisplay',
),
tool_use_id: toolUseId,
...(tool_call_id && { tool_call_id }),
};
@@ -499,7 +525,18 @@ export class HookEventHandler {
const input: PostToolBatchInput = {
...this.createBaseInput(HookEventName.PostToolBatch),
permission_mode: permissionMode,
tool_calls: toolCalls,
tool_calls: toolCalls.map((call) =>
call.tool_response
? {
...call,
tool_response: normalizeQuestionHookResponse(
call.tool_name,
call.tool_response,
'result_display',
),
}
: call,
),
};
return this.executeHooks(
@@ -341,6 +341,37 @@ describe('AskUserQuestionTool', () => {
});
describe('execute', () => {
it('distinguishes partial answers containing another question header', async () => {
const invocation = tool.build({
questions: ['A', 'B'].map((header) => ({
header,
question: `Question ${header}?`,
options: [
{ label: 'Yes', description: 'Continue' },
{ label: 'No', description: 'Stop' },
],
})),
});
const signal = new AbortController().signal;
const confirmation = await invocation.getConfirmationDetails(signal);
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: { '0': 'first\n**B**: embedded', invalid: 'ignored' },
});
const result = await invocation.execute(signal);
expect(result.returnDisplay).toEqual({
type: 'ask_user_question_answers',
text: result.llmContent,
answers: [
{ question: 'Question A?', answer: 'first\n**B**: embedded' },
],
});
expect(result.llmContent).toBe(
'User has provided the following answers:\n\n**A**: first\n**B**: embedded',
);
});
it('should return error in non-interactive mode', async () => {
(mockConfig.isInteractive as Mock).mockReturnValue(false);
@@ -433,9 +464,14 @@ describe('AskUserQuestionTool', () => {
expect(result.llmContent).toContain('Framework**: React');
expect(result.llmContent).toContain('Language**: TypeScript');
expect(result.returnDisplay).toContain(
'has provided the following answers:',
);
expect(result.returnDisplay).toEqual({
type: 'ask_user_question_answers',
text: result.llmContent,
answers: [
{ question: 'Pick a framework?', answer: 'React' },
{ question: 'Pick a language?', answer: 'TypeScript' },
],
});
});
it('should ignore answers with malformed question indexes', async () => {
+9 -4
View File
@@ -271,6 +271,7 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation<
}
// Format the answers for LLM consumption
const answers: Array<{ question: string; answer: string }> = [];
const answersContent = Object.entries(this.userAnswers)
.flatMap(([key, value]) => {
const questionIndex = parseAnswerQuestionIndex(
@@ -279,6 +280,7 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation<
);
if (questionIndex === undefined) return [];
const question = this.params.questions[questionIndex]!;
answers.push({ question: question.question, answer: value });
return `**${question.header || `Question ${questionIndex + 1}`}**: ${value}`;
})
.join('\n');
@@ -287,12 +289,15 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation<
answersContent.length > 0
? answersContent
: 'No valid answers were provided.';
const llmMessage = `User has provided the following answers:\n\n${messageBody}`;
const displayMessage = `User has provided the following answers:\n\n${messageBody}`;
const message = `User has provided the following answers:\n\n${messageBody}`;
return {
llmContent: llmMessage,
returnDisplay: displayMessage,
llmContent: message,
returnDisplay: {
type: 'ask_user_question_answers',
text: message,
answers,
},
};
} catch (error) {
const errorMessage =
+7
View File
@@ -794,8 +794,15 @@ export function isTerminalImageDisplay(
);
}
export interface AskUserQuestionResultDisplay {
type: 'ask_user_question_answers';
text: string;
answers: Array<{ question: string; answer: string }>;
}
export type ToolResultDisplay =
| string
| AskUserQuestionResultDisplay
| FileDiff
| TodoResultDisplay
| PlanResultDisplay
@@ -16,6 +16,7 @@ import type {
TaskListResultDisplay,
TeamResultDisplay,
TodoResultDisplay,
ToolResultDisplay,
} from '../tools/tools.js';
import {
compactStringForHistory,
@@ -46,6 +47,82 @@ function hasUnpairedSurrogate(value: string): boolean {
}
describe('toolResultDisplayCompaction', () => {
it.each([
{ answers: [] },
{ text: 42, answers: [] },
{ text: null, answers: [] },
{ text: 'fallback' },
{ text: 'fallback', answers: null },
{ text: 'fallback', answers: {} },
{ text: 'fallback', answers: [null] },
{ text: 'fallback', answers: ['answer'] },
{ text: 'fallback', answers: [{ answer: 'Yes' }] },
{ text: 'fallback', answers: [{ question: 'Question?' }] },
{ text: 'fallback', answers: [{ question: 42, answer: 'Yes' }] },
{ text: 'fallback', answers: [{ question: 'Question?', answer: 42 }] },
])('preserves malformed question display unchanged: %j', (fields) => {
const display = {
type: 'ask_user_question_answers',
...fields,
} as unknown as ToolResultDisplay;
expect(compactToolResultDisplayForHistory(display)).toBe(display);
expect(compactToolResultDisplayForRecording(display)).toBe(display);
});
it('preserves valid question displays with no answers', () => {
const display = {
type: 'ask_user_question_answers' as const,
text: 'No valid answers were provided.',
answers: [],
};
expect(compactToolResultDisplayForHistory(display)).toEqual(display);
expect(compactToolResultDisplayForRecording(display)).toEqual(display);
});
it.each([
['history', compactToolResultDisplayForHistory],
['recording', compactToolResultDisplayForRecording],
] as const)(
'bounds question display fields for %s without changing answers at source',
(purpose, compact) => {
const long = `start-${'😀'.repeat(20_000)}-end`;
const display = {
type: 'ask_user_question_answers' as const,
text: long,
answers: [
{ question: long, answer: long },
{ question: 'Short question?', answer: 'Yes\n**Header**: literal' },
],
};
const original = structuredClone(display);
const result = compact(display);
expect(result.type).toBe(display.type);
expect(result.answers).toHaveLength(2);
expect(result.answers[1]).toEqual(display.answers[1]);
for (const value of [
result.text,
result.answers[0].question,
result.answers[0].answer,
]) {
expect(value.length).toBeLessThanOrEqual(
MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS,
);
expect(value).toContain('start-');
expect(value).toContain('-end');
expect(value).toContain(
purpose === 'history'
? 'CLI history display'
: 'saved session preview',
);
expect(hasUnpairedSurrogate(value)).toBe(false);
}
expect(display).toEqual(original);
},
);
it('keeps short strings unchanged', () => {
const value = 'short output';
@@ -629,6 +629,36 @@ function compactToolResultDisplay<T extends ToolResultDisplay | undefined>(
return resultDisplay;
}
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'ask_user_question_answers'
) {
if (
typeof resultDisplay.text !== 'string' ||
!Array.isArray(resultDisplay.answers) ||
!resultDisplay.answers.every(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
typeof entry.question === 'string' &&
typeof entry.answer === 'string',
)
) {
return resultDisplay;
}
return {
...resultDisplay,
text: compactString(resultDisplay.text, purpose),
answers: resultDisplay.answers.map(({ question, answer }) => ({
question: compactString(question, purpose),
answer: compactString(answer, purpose),
})),
} as T;
}
if (isFileDiffDisplay(resultDisplay)) {
return compactFileDiff(resultDisplay, purpose) as T;
}
@@ -23,7 +23,7 @@ import {
} from './utils.js';
const MAX_TOOL_PREVIEW_DEPTH = 8;
const MAX_TODO_PREVIEW_ENTRIES = 1_000;
const MAX_TOOL_PREVIEW_ENTRIES = 1_000;
const MAX_TOOL_RESULT_PREVIEW_LENGTH = 100_000;
const MAX_TODO_ID_LENGTH = 512;
@@ -124,6 +124,37 @@ export function createDaemonToolResultPreview(
content?: unknown,
opts: { toolName?: string; toolKind?: string } = {},
): DaemonToolResultPreview | undefined {
if (
isRecord(output) &&
output['type'] === 'ask_user_question_answers' &&
typeof output['text'] === 'string'
) {
const text = output['text'];
const fallback = createDaemonToolResultTextPreview(text);
const entries = output['answers'];
if (!Array.isArray(entries) || entries.length > MAX_TOOL_PREVIEW_ENTRIES) {
return fallback;
}
let remaining = MAX_TOOL_RESULT_PREVIEW_LENGTH - text.length;
const answers: Array<{ question: string; answer: string }> = [];
for (const entry of entries) {
if (
!isRecord(entry) ||
typeof entry['question'] !== 'string' ||
typeof entry['answer'] !== 'string'
) {
return fallback;
}
const question = entry['question'];
const answer = entry['answer'];
remaining -= question.length + answer.length;
if (remaining < 0) return fallback;
answers.push({ question, answer });
}
return remaining >= 0
? { kind: 'question_answers', text, answers }
: fallback;
}
const todoList = detectTodoList(output, opts, false);
if (todoList) return todoList;
@@ -156,7 +187,7 @@ function detectTodoList(
return undefined;
}
const retainedEntries = entries.slice(0, MAX_TODO_PREVIEW_ENTRIES);
const retainedEntries = entries.slice(0, MAX_TOOL_PREVIEW_ENTRIES);
const authoredIds = new Set<string>();
for (const entry of retainedEntries) {
if (!isRecord(entry)) continue;
@@ -172,9 +203,9 @@ function detectTodoList(
const normalized: DaemonTranscriptTodoItem[] = [];
const usedIds = new Set<string>();
let truncated = entries.length > MAX_TODO_PREVIEW_ENTRIES;
let truncated = entries.length > MAX_TOOL_PREVIEW_ENTRIES;
let remainingText = MAX_TOOL_RESULT_PREVIEW_LENGTH;
let remainingDependencyInputs = MAX_TODO_PREVIEW_ENTRIES;
let remainingDependencyInputs = MAX_TOOL_PREVIEW_ENTRIES;
retainedEntries.forEach((entry, index) => {
if (!isRecord(entry)) {
truncated = true;
@@ -901,6 +901,11 @@ export type DaemonToolPreview =
export type DaemonToolResultPreview =
| DaemonTodoListPreview
| {
kind: 'question_answers';
text: string;
answers: Array<{ question: string; answer: string }>;
}
| {
kind: 'text';
text: string;
@@ -5375,6 +5375,39 @@ describe('daemon UI tool preview taxonomy (PR-C)', () => {
).toBeUndefined();
});
it('preserves bounded question answer pairs without unknown raw fields', () => {
const output = {
type: 'ask_user_question_answers',
text: 'Question A: first\n**B**: embedded',
answers: [
{
question: 'Question A?',
answer: 'first\n**B**: embedded',
secret: 'do not retain',
},
],
secret: 'do not retain',
};
expect(createDaemonToolResultPreview(output)).toEqual({
kind: 'question_answers',
text: output.text,
answers: [{ question: 'Question A?', answer: 'first\n**B**: embedded' }],
});
for (const answers of [
[{ question: 'Question A?', answer: 42 }],
[{ question: 'Question A?', answer: 'x'.repeat(100_000) }],
Array.from({ length: 1_001 }, () => ({ question: '', answer: '' })),
]) {
expect(createDaemonToolResultPreview({ ...output, answers })).toEqual({
kind: 'text',
text: output.text,
});
}
expect(
createDaemonToolResultPreview({ ...output, text: 'x'.repeat(100_001) }),
).toBeUndefined();
});
it('does not retain a stale result preview when a later result is unsafe to preview', () => {
let state = reduceDaemonTranscriptEvents(
createDaemonTranscriptState({ now: 1 }),
@@ -3100,51 +3100,23 @@ describe('transcriptBlocksToDaemonMessages', () => {
});
});
it('uses AskUserQuestion permission title for the completed tool block', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-ask-1',
kind: 'permission',
requestId: 'req-ask-1',
sessionId: 'sess-1',
title: 'Ask user 4 questions',
options: [{ optionId: 'proceed_once', label: 'Submit', raw: {} }],
toolCall: {
toolCallId: 'ask-call-1',
kind: 'think',
status: 'pending',
it.each([false, true])(
'uses AskUserQuestion permission details regardless of block order (tool first=%s)',
(toolFirst) => {
const blocks: DaemonTranscriptBlock[] = [
{
id: 'perm-ask-1',
kind: 'permission',
requestId: 'req-ask-1',
sessionId: 'sess-1',
title: 'Ask user 4 questions',
rawInput: {
questions: [
{
header: '姓名',
question: '请输入学生的姓名:',
options: [{ label: '张三', description: '示例姓名' }],
},
],
},
},
preview: { kind: 'generic' as const },
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
resolved: 'selected:proceed_once',
},
toolBlock('ask-tool-1', 'ask-call-1', 'completed', 3, {
toolName: 'ask_user_question',
title: 'ask_user_question',
rawOutput: 'User has provided the following answers:\n\n**姓名**: 张三',
}),
]);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [
{
callId: 'ask-call-1',
options: [{ optionId: 'proceed_once', label: 'Submit', raw: {} }],
toolCall: {
toolCallId: 'ask-call-1',
kind: 'think',
status: 'pending',
title: 'Ask user 4 questions',
args: {
rawInput: {
questions: [
{
header: '姓名',
@@ -3153,13 +3125,49 @@ describe('transcriptBlocksToDaemonMessages', () => {
},
],
},
rawOutput:
'User has provided the following answers:\n\n**姓名**: 张三',
},
],
},
]);
});
preview: { kind: 'generic' as const },
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
resolved: 'selected:proceed_once',
},
toolBlock('ask-tool-1', 'ask-call-1', 'completed', 3, {
toolName: 'ask_user_question',
title: 'ask_user_question',
rawInput: {},
rawOutput:
'User has provided the following answers:\n\n**姓名**: 张三',
}),
];
const messages = transcriptBlocksToDaemonMessages(
toolFirst ? blocks.reverse() : blocks,
);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [
{
callId: 'ask-call-1',
title: 'Ask user 4 questions',
args: {
questions: [
{
header: '姓名',
question: '请输入学生的姓名:',
options: [{ label: '张三', description: '示例姓名' }],
},
],
},
rawOutput:
'User has provided the following answers:\n\n**姓名**: 张三',
},
],
},
]);
},
);
it('uses text content as raw output when a tool has no raw output', () => {
const messages = transcriptBlocksToDaemonMessages([
@@ -713,13 +713,6 @@ export function transcriptBlocksToDaemonMessages(
if (toolBlock.serverTimestamp !== undefined) {
serverStartTimes.set(toolCall.callId, toolBlock.serverTimestamp);
}
const permissionInfo = permissionToolInfoByCallId.get(toolCall.callId);
if (permissionInfo?.title) {
toolCall.title = permissionInfo.title;
}
if (!toolCall.args && permissionInfo?.args) {
toolCall.args = permissionInfo.args;
}
const parentSubAgent = toolCall.parentToolCallId
? toolsByCallId.get(toolCall.parentToolCallId)
: undefined;
@@ -1037,6 +1030,11 @@ export function transcriptBlocksToDaemonMessages(
}
for (const tool of toolsByCallId.values()) {
const permissionInfo = permissionToolInfoByCallId.get(tool.callId);
if (permissionInfo?.title) tool.title = permissionInfo.title;
if (!Array.isArray(tool.args?.questions) && permissionInfo?.args) {
tool.args = permissionInfo.args;
}
if (
isSubAgentToolCall(tool) &&
isActiveToolStatus(tool.status) &&
@@ -1493,6 +1491,13 @@ function daemonToolResultPreviewToOutput(
preview: DaemonToolTranscriptBlock['resultPreview'],
): unknown {
if (!preview) return undefined;
if (preview.kind === 'question_answers') {
return {
type: 'ask_user_question_answers',
text: preview.text,
answers: preview.answers,
};
}
if (preview.kind === 'text') return preview.text;
if (preview.kind === 'generic') return preview.summary;
return {
@@ -11,6 +11,12 @@ import { useI18n } from '../i18n';
import { ErrorBoundary } from './ErrorBoundary';
import { MessageTimestamp } from './MessageTimestamp';
import { UserMessage } from './messages/UserMessage';
import { QuestionAnswerMessage } from './messages/QuestionAnswerMessage';
import {
extractText,
getQuestionAnswerResult,
isCompletedAskUserQuestion,
} from './messages/toolFormatting';
import {
AssistantMessage,
ThinkingMessage,
@@ -80,7 +86,17 @@ export const MessageItem = memo(function MessageItem({
[onBranchSession, branchRecordId],
);
const compactMode = useContext(CompactModeContext);
const questionTool =
message.role === 'tool_group' &&
message.tools.length === 1 &&
isCompletedAskUserQuestion(message.tools[0])
? message.tools[0]
: undefined;
const questionAnswer = questionTool
? getQuestionAnswerResult(questionTool)
: null;
const isUserStyled =
!!questionAnswer ||
message.role === 'user' ||
(message.role === 'system' &&
message.source === 'mid_turn_message_injected');
@@ -125,6 +141,24 @@ export const MessageItem = memo(function MessageItem({
/>
);
case 'tool_group':
if (
questionTool &&
!questionAnswer &&
!extractText(questionTool)?.trim()
) {
return null;
}
if (questionAnswer && questionTool) {
if (!questionAnswer.answers.length && !questionAnswer.text.trim())
return null;
return (
<QuestionAnswerMessage
tool={questionTool}
result={questionAnswer}
isLocateFlashing={isLocateFlashing}
/>
);
}
return (
<ToolGroup
tools={message.tools}
@@ -260,7 +294,9 @@ export const MessageItem = memo(function MessageItem({
timestamp={message.timestamp}
chatMode={isUserStyled}
toolGroupSpacing={message.role === 'tool_group' && compactMode}
copyText={isUserStyled ? message.content : undefined}
copyText={
isUserStyled && 'content' in message ? message.content : undefined
}
copyTitle={t('common.copy')}
>
{selectableSafeBody}
@@ -984,7 +984,7 @@ describe('MessageList — compact mode', () => {
).toBeNull();
});
it.each(['TodoWrite', 'AskUserQuestion'])(
it.each(['TodoWrite'])(
'folds %s groups into the summary across hidden thinking',
(toolName) => {
const container = mount(
@@ -1011,7 +1011,6 @@ describe('MessageList — compact mode', () => {
it.each([
['TodoWrite', standaloneToolMsg('special', 'TodoWrite')],
['AskUserQuestion', standaloneToolMsg('special', 'AskUserQuestion')],
['agent', agentMsg('special')],
])(
'merges a leading %s group with later thinking and tools',
@@ -1037,6 +1036,49 @@ describe('MessageList — compact mode', () => {
});
describe('MessageList — turn collapse (DOM)', () => {
it.each([false, true])(
'keeps completed questions outside a collapsed mixed tool group (compact=%s)',
(compactMode) => {
const question = standaloneToolMsg('ask', 'AskUserQuestion').tools[0];
const c = mount(
[
userMsg('u1'),
thinkingMsg('thought'),
{
...toolMsg('mixed'),
tools: [
toolMsg('before').tools[0],
question,
toolMsg('after').tools[0],
],
},
asstMsg('a1'),
],
undefined,
{ compactMode },
);
expect(c.textContent).toContain('2 tool calls');
expect(c.textContent).not.toContain('3 tool calls');
const assertAnswerVisible = () => {
expect(c.querySelectorAll('[data-tool-ids="call-ask"]')).toHaveLength(
1,
);
expect(has(c, 'u1')).toBe(true);
expect(has(c, 'a1')).toBe(true);
};
expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false');
assertAnswerVisible();
expect(c.querySelector('[data-tool-ids*="call-before"]')).toBeNull();
expect(c.querySelector('[data-tool-ids*="call-after"]')).toBeNull();
click(toggleRow(c, 'u1'));
assertAnswerVisible();
expect(c.querySelector('[data-tool-ids*="call-before"]')).not.toBeNull();
expect(c.querySelector('[data-tool-ids*="call-after"]')).not.toBeNull();
click(toggleRow(c, 'u1'));
assertAnswerVisible();
},
);
it('gives prompt and collapse siblings distinct row identities for a shared source', () => {
const c = mount(
[
@@ -64,6 +64,8 @@ import { useSharedNow } from '../hooks/useSharedNow';
import { useChatNavigationVisible } from '../hooks/useChatNavigationVisible';
import {
isActiveToolStatus,
isCompletedAskUserQuestion,
isAskUserQuestionToolName,
toolContainsCallId,
} from './messages/toolFormatting';
import { getMcpAppDisplay } from './messages/McpApp';
@@ -310,7 +312,11 @@ function isForceExpandGroup(
return false;
}
function splitMcpAppToolGroups(messages: Message[]): Message[] {
function isStandaloneTool(tool: ACPToolCall): boolean {
return isCompletedAskUserQuestion(tool) || !!getMcpAppDisplay(tool.rawOutput);
}
function splitStandaloneToolGroups(messages: Message[]): Message[] {
const result: Message[] = [];
let changed = false;
@@ -318,7 +324,7 @@ function splitMcpAppToolGroups(messages: Message[]): Message[] {
if (
message.role !== 'tool_group' ||
message.tools.length < 2 ||
!message.tools.some((tool) => getMcpAppDisplay(tool.rawOutput))
!message.tools.some(isStandaloneTool)
) {
result.push(message);
continue;
@@ -340,7 +346,7 @@ function splitMcpAppToolGroups(messages: Message[]): Message[] {
};
for (const tool of message.tools) {
if (getMcpAppDisplay(tool.rawOutput)) {
if (isStandaloneTool(tool)) {
pushSegment(segment);
segment = [];
pushSegment([tool]);
@@ -364,7 +370,7 @@ function mergeCompactToolGroups(
const isMergedToolGroup = (m: Message): boolean =>
m.role === 'tool_group' &&
!isForceExpandGroup(m, pendingApproval) &&
!m.tools.some((tool) => getMcpAppDisplay(tool.rawOutput));
!m.tools.some(isStandaloneTool);
while (i < messages.length) {
const msg = messages[i];
@@ -740,6 +746,7 @@ function isHideableStep(item: DisplayItem, isFinalAnswer: boolean): boolean {
if (item.type === 'turn_collapse') return false;
switch (item.message.role) {
case 'tool_group':
return !item.message.tools.some(isCompletedAskUserQuestion);
case 'plan':
return true;
case 'assistant':
@@ -1367,7 +1374,11 @@ function itemToolCallCount(item: DisplayItem): number {
if (item.type === 'parallel_agents') return item.agents.length;
if (item.type === 'turn_outputs') return 0;
if (item.type === 'turn_collapse') return 0;
return item.message.role === 'tool_group' ? item.message.tools.length : 0;
return item.message.role === 'tool_group'
? item.message.tools.filter(
(tool) => !isAskUserQuestionToolName(tool.toolName),
).length
: 0;
}
/**
@@ -2935,14 +2946,14 @@ export const MessageList = memo(
} else if (tail?.role === 'thinking') {
value = compactMode
? updateCompactStreamingThinkingTail(cached.value, tail)
: splitMcpAppToolGroups(messages);
: splitStandaloneToolGroups(messages);
}
}
if (!value) {
const standaloneMcpApps = splitMcpAppToolGroups(messages);
const standaloneTools = splitStandaloneToolGroups(messages);
value = compactMode
? mergeCompactToolGroups(standaloneMcpApps, pendingApproval)
: standaloneMcpApps;
? mergeCompactToolGroups(standaloneTools, pendingApproval)
: standaloneTools;
}
mergedMessagesCache.current = {
sourceMessages: messages,
@@ -342,17 +342,213 @@ describe('WebShellTranscript DOM integration', () => {
<WebShellTranscript blocks={blocks} collapseCompletedTurns={false} />,
);
const summary = container.querySelector('button')!;
expect(container.querySelector('[class*="chatBubble"]')).toBeNull();
const summary = container.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
)!;
expect(summary.textContent).toContain('Asked 1 question');
expect(summary.getAttribute('aria-expanded')).toBe('false');
act(() => summary.click());
expect(container.textContent).toContain('Ask user 1 question');
expect(container.textContent).toContain('User answer: Staging');
expect(container.querySelector('button[type="submit"]')).toBeNull();
});
it.each(['readonly', 'document'] as const)(
'keeps question and multiline answers visible in %s mode',
(renderMode) => {
const questions = [
{ header: 'Choice', question: 'Which target?', options: [], raw: {} },
{ header: 'Choice', question: 'Which code?', options: [], raw: {} },
];
const answer = '```ts\nconst value = 1;\nconsole.log(value);\n```';
const text = `User has provided the following answers:\n\n**Choice**: Staging\n**Choice**: ${answer}`;
const answers = [
{ question: 'Which target?', answer: 'Staging' },
{ question: 'Which code?', answer },
];
const { container } = render(
<WebShellTranscript
renderMode={renderMode}
blocks={[
block({ id: 'u1', kind: 'user', text: 'Configure the project' }),
block(
{
id: 'read',
kind: 'tool',
toolCallId: 'read-1',
title: 'read_file',
toolName: 'read_file',
status: 'completed',
preview: { kind: 'file_read', path: 'package.json' },
},
2,
),
block(
{
id: 'ask',
kind: 'tool',
toolCallId: 'ask-1',
title: 'ask_user_question',
toolName: 'ask_user_question',
status: 'completed',
preview: { kind: 'ask_user_question', questions },
rawInput: { questions },
rawOutput: { type: 'ask_user_question_answers', text, answers },
resultPreview: { kind: 'question_answers', text, answers },
},
3,
),
block({ id: 'a1', kind: 'assistant', text: 'Configured.' }, 4),
]}
/>,
);
const bubble = container.querySelector(
'[data-transcript-tool-call-id="ask-1"]',
)!;
expect(bubble).not.toBeNull();
expect(
[...bubble.querySelectorAll('dt')].map((el) => el.textContent),
).toEqual(['Which target?', 'Which code?']);
expect(
[...bubble.querySelectorAll('dd')].map((el) => el.textContent),
).toEqual(['Staging', answer]);
expect(bubble.querySelector('button')).toBeNull();
if (renderMode === 'readonly') {
expect(
container.querySelector('[data-transcript-tool-call-id="read-1"]'),
).toBeNull();
}
},
);
it.each([undefined, '', ' \n'])(
'omits empty question bubbles: %s',
(text) => {
const { container } = render(
<WebShellTranscript
blocks={[
block({
id: 'ask',
kind: 'tool',
toolCallId: 'ask-1',
toolName: 'ask_user_question',
title: 'ask_user_question',
status: 'completed',
preview: { kind: 'generic' },
rawOutput: text,
}),
]}
/>,
);
expect(
container.querySelector('[data-transcript-tool-call-id="ask-1"]'),
).toBeNull();
},
);
it.each(['readonly', 'document'] as const)(
'uses structured partial answers without parsing text in %s mode',
(renderMode) => {
const text = 'Display wording can change independently.';
const answer = 'first\n**B**: embedded';
const answers = [{ question: 'Question A?', answer }];
const { container } = render(
<WebShellTranscript
renderMode={renderMode}
blocks={[
block({
id: 'ask',
kind: 'tool',
toolCallId: 'ask-1',
title: 'ask_user_question',
toolName: 'ask_user_question',
status: 'completed',
preview: { kind: 'generic' },
rawInput: { questions: [] },
rawOutput: { type: 'ask_user_question_answers', text, answers },
resultPreview: { kind: 'question_answers', text, answers },
}),
]}
/>,
);
expect(
[...container.querySelectorAll('dt')].map((el) => el.textContent),
).toEqual(['Question A?']);
expect(
[...container.querySelectorAll('dd')].map((el) => el.textContent),
).toEqual([answer]);
expect(container.textContent).not.toContain(text);
},
);
it.each([
'User declined to answer the questions.',
'User has provided the following answers:\n\nNo valid answers were provided.',
'User has provided the following answers:\n\n**Target**: answer\n**Example**: literal answer text',
'User has provided answers to all questions:\n\n**Target**: answer',
])('keeps unstructured results in the left tool display: %s', (text) => {
const { container } = render(
<WebShellTranscript
blocks={[
block({
id: 'ask',
kind: 'tool',
toolCallId: 'ask-1',
title: 'ask_user_question',
toolName: 'ask_user_question',
status: 'completed',
preview: { kind: 'generic' },
rawInput: {
questions: [{ header: 'Target', question: 'Which target?' }],
},
rawOutput: text,
}),
]}
/>,
);
expect(container.querySelector('[class*="chatBubble"]')).toBeNull();
expect(container.querySelector('dl')).toBeNull();
const summary = container.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
)!;
expect(summary.getAttribute('aria-expanded')).toBe('false');
act(() => summary.click());
expect(container.textContent).toContain(text.split('\n')[0]);
});
it.each([
{
type: 'ask_user_question_answers',
text: 'Invalid answer data',
answers: [{ question: 'A?', answer: 1 }],
},
{ type: 'ask_user_question_answers', text: 'No answers', answers: [] },
])('handles empty or invalid structured answers: $text', (result) => {
const { container } = render(
<WebShellTranscript
blocks={[
block({
id: 'ask',
kind: 'tool',
toolCallId: 'ask-1',
title: 'ask_user_question',
toolName: 'ask_user_question',
status: 'completed',
preview: { kind: 'generic' },
rawOutput: result,
}),
]}
/>,
);
expect(container.querySelector('dl')).toBeNull();
if (result.answers.length) {
expect(container.querySelector('[class*="chatBubble"]')).toBeNull();
} else {
expect(
container.querySelector('[class*="chatBubble"]')?.textContent,
).toBe(result.text);
}
});
it('keeps MessageList turn expansion as a local viewing interaction', () => {
const { container } = render(
<WebShellTranscript
@@ -70,15 +70,17 @@
.collapseButton {
display: flex;
width: 20px;
height: 20px;
gap: 4px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--muted-foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
transition: color 0.15s;
}
@@ -94,7 +96,7 @@
}
.collapseIconCollapsed {
transform: rotate(-90deg);
transform: rotate(180deg);
}
.collapsed {
@@ -1089,6 +1089,8 @@ describe('AskUserQuestion multiple questions', () => {
'[aria-label="Collapse"]',
)!;
expect(collapse.textContent).toBe('Collapse');
expect(collapse.getAttribute('aria-expanded')).toBe('true');
act(() => collapse.click());
expect(panel.getAttribute('aria-labelledby')).toBeNull();
@@ -1096,6 +1098,8 @@ describe('AskUserQuestion multiple questions', () => {
const expand = container!.querySelector<HTMLButtonElement>(
'[aria-label="Expand"]',
)!;
expect(expand.textContent).toBe('Expand');
expect(expand.getAttribute('aria-expanded')).toBe('false');
act(() => expand.click());
expect(container!.textContent).toContain('Enter submit');
});
@@ -712,14 +712,16 @@ export function AskUserQuestion({
aria-label={collapsed ? t('common.expand') : t('common.collapse')}
title={collapsed ? t('common.expand') : t('common.collapse')}
>
{collapsed ? t('common.expand') : t('common.collapse')}
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className={`${styles.collapseIcon} ${
collapsed ? styles.collapseIconCollapsed : ''
}`}
>
<path
d="M4 6l4 4 4-4"
d="M4 10l4-4 4 4"
fill="none"
stroke="currentColor"
strokeLinecap="round"
@@ -0,0 +1,21 @@
.bubble {
min-width: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.answers {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
}
.question {
margin-bottom: 4px;
color: var(--muted-foreground);
}
.answer {
margin: 0;
}
@@ -0,0 +1,44 @@
import type { ACPToolCall } from '../../adapters/types';
import type { getQuestionAnswerResult } from './toolFormatting';
import flashStyles from '../MessageLocateFlash.module.css';
import messageStyles from './UserMessage.module.css';
import styles from './QuestionAnswerMessage.module.css';
export function QuestionAnswerMessage({
tool,
result,
isLocateFlashing = false,
}: {
tool: ACPToolCall;
result: NonNullable<ReturnType<typeof getQuestionAnswerResult>>;
isLocateFlashing?: boolean;
}) {
const { text, answers } = result;
return (
<div
className={messageStyles.chatMessageRow}
data-transcript-tool-call-id={tool.callId}
>
<div className={messageStyles.chatMessageColumn}>
<div
className={`${messageStyles.chatBubble} ${styles.bubble} ${
isLocateFlashing ? flashStyles.flash : ''
}`}
>
{answers.length > 0 ? (
<dl className={styles.answers}>
{answers.map(({ question, answer }, index) => (
<div key={index}>
<dt className={styles.question}>{question}</dt>
<dd className={styles.answer}>{answer}</dd>
</div>
))}
</dl>
) : (
text
)}
</div>
</div>
</div>
);
}
@@ -142,6 +142,41 @@ export function isAskUserQuestionToolName(toolName: string): boolean {
return normalized === 'ask_user_question' || normalized === 'askuserquestion';
}
export function isCompletedAskUserQuestion(tool: ACPToolCall): boolean {
return (
tool.status === 'completed' && isAskUserQuestionToolName(tool.toolName)
);
}
export function getQuestionAnswerResult(tool: ACPToolCall): {
text: string;
answers: Array<{ question: string; answer: string }>;
} | null {
const output = tool.rawOutput;
if (
!output ||
typeof output !== 'object' ||
!('type' in output) ||
output.type !== 'ask_user_question_answers' ||
!('text' in output) ||
typeof output.text !== 'string' ||
!('answers' in output) ||
!Array.isArray(output.answers) ||
!output.answers.every(
(entry: unknown): entry is { question: string; answer: string } =>
!!entry &&
typeof entry === 'object' &&
'question' in entry &&
typeof entry.question === 'string' &&
'answer' in entry &&
typeof entry.answer === 'string',
)
) {
return null;
}
return { text: output.text, answers: output.answers };
}
export function truncateText(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max) + '...';
@@ -0,0 +1,396 @@
import { expect, test } from '@playwright/test';
import {
createTranscriptToolCallResultUpdate,
createTranscriptToolCallStartUpdate,
} from '@qwen-code/acp-bridge/transcriptReplay';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
toolCallEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
test('completed questions stay visible outside Processed after reload', async ({
page,
}, testInfo) => {
const answer = 'const greeting = "hello";\nconsole.log(greeting);';
const resultText = `User has provided the following answers:\n\n**Destination**: Staging\n**Example**: ${answer}`;
const legacyText =
'User has provided the following answers:\n\n**Legacy**: Legacy answer';
const partialAnswer = 'first\n**B**: embedded';
const scenario = createWebShellDaemonScenario({
events: [
userTextEvent('Please confirm the destination and example code.', {
id: 1,
}),
toolCallEvent(
'before',
'grep_search',
{ pattern: 'before-question' },
{ id: 2 },
),
{
id: 3,
v: 1,
type: 'session_update',
data: {
update: createTranscriptToolCallStartUpdate({
toolName: 'ask_user_question',
callId: 'ask',
status: 'in_progress',
metadata: {
title: 'Ask user 2 questions',
kind: 'think',
locations: [],
},
args: {
questions: [
{
header: 'Destination',
question: 'Where should this deploy?',
options: [{ label: 'Staging' }, { label: 'Production' }],
},
{
header: 'Example',
question: 'What code should we use?',
options: [{ label: 'Example' }, { label: 'Other' }],
},
],
},
}),
},
},
{
id: 4,
v: 1,
type: 'session_update',
data: {
update: createTranscriptToolCallResultUpdate({
toolName: 'ask_user_question',
callId: 'ask',
success: true,
resultDisplay: {
type: 'ask_user_question_answers',
text: resultText,
answers: [
{ question: 'Where should this deploy?', answer: 'Staging' },
{ question: 'What code should we use?', answer },
],
},
message: [
{
functionResponse: {
name: 'ask_user_question',
response: { output: resultText },
},
},
],
}),
},
},
{
id: 5,
v: 1,
type: 'session_update',
data: {
update: {
...createTranscriptToolCallStartUpdate({
toolName: 'ask_user_question',
callId: 'legacy-ask',
status: 'completed',
metadata: {
title: 'Ask user 1 question',
kind: 'think',
locations: [],
},
args: {
questions: [
{
header: 'Legacy',
question: 'Legacy question?',
options: [{ label: 'Legacy answer' }, { label: 'Other' }],
},
],
},
}),
rawOutput: legacyText,
},
},
},
toolCallEvent(
'partial-ask',
'ask_user_question',
{
questions: [
{ header: 'A', question: 'Question A?' },
{ header: 'B', question: 'Question B?' },
],
},
{
id: 6,
rawOutput: {
type: 'ask_user_question_answers',
text: `User has provided the following answers:\n\n**A**: ${partialAnswer}`,
answers: [{ question: 'Question A?', answer: partialAnswer }],
},
},
),
toolCallEvent(
'after',
'grep_search',
{ pattern: 'after-question' },
{ id: 7 },
),
assistantTextEvent('Confirmed. I will use those answers.', { id: 8 }),
turnCompleteEvent('prompt-question', { id: 9 }),
],
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
for (const reloaded of [false, true]) {
if (reloaded) await page.reload();
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount: scenario.events.length,
}),
);
await expect(page.getByText('Loading...')).toHaveCount(0);
const list = page.locator('[data-web-shell-message-list]');
const question = list.getByText('Where should this deploy?', {
exact: true,
});
await expect(question).toBeVisible();
await expect(
list.getByText('What code should we use?', { exact: true }),
).toBeVisible();
await expect(list.getByText('Staging', { exact: true })).toBeVisible();
const questionRow = list.locator('[data-transcript-tool-call-id="ask"]');
const codeAnswer = questionRow.locator('dd').nth(1);
await expect(codeAnswer).toHaveText(answer);
await expect(codeAnswer).toHaveCSS('white-space', 'pre-wrap');
const questionBox = await question.boundingBox();
const answerBox = await questionRow.locator('dd').first().boundingBox();
expect(answerBox!.y).toBeGreaterThanOrEqual(
questionBox!.y + questionBox!.height,
);
const userBubble = await list
.locator('[data-web-shell-user-bubble]')
.boundingBox();
const answerBubble = await questionRow
.locator('dl')
.locator('..')
.boundingBox();
expect(
Math.abs(
userBubble!.x +
userBubble!.width -
answerBubble!.x -
answerBubble!.width,
),
).toBeLessThanOrEqual(1);
await expect(
list.getByRole('button', { name: 'Expand steps', exact: true }),
).toBeVisible();
await expect(list).not.toContainText(
'User has provided the following answers:',
);
await expect(
list.getByRole('button', { name: 'Expand steps', exact: true }),
).toContainText('2 tool calls');
const partialRow = list.locator(
'[data-transcript-tool-call-id="partial-ask"]',
);
await expect(partialRow.locator('dt')).toHaveText(['Question A?']);
await expect(partialRow.locator('dd')).toHaveText([partialAnswer]);
await expect(partialRow).not.toContainText('Question B?');
const legacySummary = list
.getByRole('button')
.filter({ hasText: 'Asked 1 question' });
await expect(legacySummary).toHaveAttribute('aria-expanded', 'false');
const legacyBox = await legacySummary.boundingBox();
expect(legacyBox!.x).toBeLessThan(answerBubble!.x);
await legacySummary.click();
const legacyRow = list.locator(
'[data-transcript-tool-call-id="legacy-ask"]',
);
await expect(legacyRow).toContainText(
'User has provided the following answers:',
);
await expect(legacyRow).toContainText('Legacy answer');
await expect(legacyRow.locator('dt')).toHaveCount(0);
await legacySummary.click();
await expect(legacySummary).toHaveAttribute('aria-expanded', 'false');
await expect(list).not.toContainText('before-question');
await expect(list).not.toContainText('after-question');
await list
.getByRole('button', { name: 'Expand steps', exact: true })
.click();
await expect(question).toBeVisible();
await expect(question).toHaveCount(1);
await list
.getByRole('button', { name: 'Collapse steps', exact: true })
.click();
await expect(question).toBeVisible();
await expect(question).toHaveCount(1);
}
const liveQuestion = {
sessionUpdate: 'tool_call',
toolCallId: 'live-ask',
toolName: 'ask_user_question',
title: 'Ask user 1 question',
kind: 'think',
status: 'in_progress',
rawInput: {
questions: [
{
header: 'Confirm',
question: 'Should I continue?',
options: [{ label: 'Yes' }, { label: 'No' }],
},
],
},
};
await daemon.sendEvent(userTextEvent('One more confirmation.', { id: 10 }));
await daemon.sendEvent({
id: 11,
v: 1,
type: 'session_update',
data: { update: liveQuestion },
});
const liveRow = page.locator('[data-transcript-tool-call-id="live-ask"]');
await expect(liveRow.locator('dt')).toHaveCount(0);
await daemon.sendEvent({
id: 12,
v: 1,
type: 'session_update',
data: {
update: {
...liveQuestion,
sessionUpdate: 'tool_call_update',
status: 'completed',
rawOutput: {
type: 'ask_user_question_answers',
text: 'User has provided the following answers:\n\n**Confirm**: Yes',
answers: [{ question: 'Should I continue?', answer: 'Yes' }],
},
},
},
});
await expect(
liveRow.getByText('Should I continue?', { exact: true }),
).toBeVisible();
await expect(liveRow.getByText('Yes', { exact: true })).toBeVisible();
await daemon.sendEvent(turnCompleteEvent('prompt-live-question', { id: 13 }));
await expect(
liveRow.getByText('Should I continue?', { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('question-message.png'),
fullPage: true,
});
await daemon.sendEvent({
id: 14,
v: 1,
type: 'session_update',
data: {
update: createTranscriptToolCallStartUpdate({
toolName: 'ask_user_question',
callId: 'pending-question',
args: {},
status: 'pending',
metadata: {
title: 'Ask user 1 question',
kind: 'think',
locations: [],
},
extra: { phase: 'preparing' },
}),
},
});
await daemon.sendEvent({
id: 15,
v: 1,
type: 'permission_request',
data: {
requestId: 'pending-question',
sessionId: scenario.sessionId,
toolCall: {
toolCallId: 'pending-question',
title: 'Ask user 1 question',
kind: 'think',
rawInput: liveQuestion.rawInput,
_meta: { toolName: 'ask_user_question' },
},
options: [{ optionId: 'submit', label: 'Submit', kind: 'allow_once' }],
},
});
const panel = page.locator('[data-web-shell-ask-panel]');
const collapse = panel.getByRole('button', { name: 'Collapse', exact: true });
await expect(collapse).toHaveText('Collapse');
await expect(collapse).toHaveAttribute('aria-expanded', 'true');
await collapse.click();
const expand = panel.getByRole('button', { name: 'Expand', exact: true });
await expect(expand).toHaveText('Expand');
await expect(expand).toHaveAttribute('aria-expanded', 'false');
await expand.click();
await expect(
panel.getByText('Should I continue?', { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('question-toggle.png'),
fullPage: true,
});
await daemon.sendEvent({
id: 16,
v: 1,
type: 'permission_resolved',
data: {
requestId: 'pending-question',
outcome: { outcome: 'selected', optionId: 'submit' },
},
});
const pendingResult =
'User has provided the following answers:\n\n**Confirm**: Yes';
await daemon.sendEvent({
id: 17,
v: 1,
type: 'session_update',
data: {
update: createTranscriptToolCallResultUpdate({
toolName: 'ask_user_question',
callId: 'pending-question',
success: true,
resultDisplay: {
type: 'ask_user_question_answers',
text: pendingResult,
answers: [{ question: 'Should I continue?', answer: 'Yes' }],
},
message: [
{
functionResponse: {
name: 'ask_user_question',
response: { output: pendingResult },
},
},
],
}),
},
});
const submittedRow = page.locator(
'[data-transcript-tool-call-id="pending-question"]',
);
await expect(submittedRow.locator('dt')).toHaveText('Should I continue?');
await expect(submittedRow.locator('dd')).toHaveText('Yes');
await expect(submittedRow).not.toContainText(
'User has provided the following answers:',
);
});