mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): keep question answers visible as user-style messages (#11461)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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:',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user