Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f492336
feat(web-shell): present structured shell execution results
Sep 20, 2026
a10f2c6
Merge branch 'main' into codex/web-shell-command-card
ytahdn Sep 20, 2026
c34536c
fix(shell): preserve cleanup notices and clarify result fallbacks
Sep 20, 2026
6c2ee93
fix(test): sync shell result source mappings
Sep 20, 2026
ac01af9
chore: merge origin/main into codex/web-shell-command-card
qwen-code-dev-bot Sep 20, 2026
69a6f7c
fix(web-shell): address shell command card review findings (#12311)
Sep 20, 2026
af4e121
test(core): isolate gitPush tests from host git config (#12311)
Sep 20, 2026
d23096e
fix(core): stage browser-use runtime on demand in core prebuild (#12311)
Sep 20, 2026
22f6db3
fix(web-shell): treat empty string shell display as authoritative (#1…
Sep 21, 2026
9b470cc
Merge branch 'main' into codex/web-shell-command-card
ytahdn Sep 21, 2026
aacd8e5
fix(core): preserve shell display and recording diagnostics
Sep 21, 2026
1c5a526
Merge commit 'refs/pull/12311/head' of github.com:QwenLM/qwen-code in…
Sep 21, 2026
eae2ca8
feat(web-shell): add session tool calls panel with prompt selection
Sep 22, 2026
d10c768
Merge remote-tracking branch 'origin/main' into feat/web-shell-turn-c…
Sep 22, 2026
0ad9565
fix(web-shell): address tool calls review findings
Sep 23, 2026
6f68391
fix(web-shell): keep prompt text out of persisted dock state
Sep 23, 2026
5aad27e
fix(web-shell): restore sender tool call identity and review fixes
Sep 23, 2026
88b9e25
docs(web-shell): record real daemon sender verification
Sep 23, 2026
8fce4a3
Merge origin/main into feat/web-shell-turn-calls-panel
Sep 23, 2026
20a5809
Merge origin/main trajectory overview updates
Sep 23, 2026
2e424ef
Merge origin/main conversation search updates
Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(web-shell): add session tool calls panel with prompt selection
  • Loading branch information
钉萁
钉萁 committed Sep 22, 2026
commit eae2ca876a668c3861b37526f22e4da2a7963023
231 changes: 231 additions & 0 deletions docs/design/web-shell/web-shell-turn-calls-panel.md

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions docs/design/web-shell/web-shell-turn-calls-panel.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Web Shell 工具调用面板

[English](web-shell-turn-calls-panel.md) | [简体中文](web-shell-turn-calls-panel.zh-CN.md)

状态:已实现;新的实时后端验证等待重启。

## 问题

工具调用分散在对话中。读者需要集中核对一轮实际做了什么,包括记录的结果状态与耗时。原面板只读取实时块:历史视口 ID 无法匹配,轮中追加消息提前截断一轮,用接收时间差也丢失了实际执行耗时。用户报告的会话中,一轮 57 次调用只显示了 13 次。

## 目标

- 从用户消息复制按钮旁打开该轮调用,包括运行中轮次和历史分页中的轮次。
- 展示国际化工具名,优先显示本次调用的明确描述。
- 展示已记录耗时,明确显示已完成、失败、取消、等待和运行状态。
- 就地展开原始参数与结果;数据有父子关系时保留嵌套。
- 右侧面板适配明暗主题和窄布局。

## 非目标

不修改无关 daemon 行为或无关客户端界面。共用 Web Shell 消息适配器同时修正通用工具包装的真实名称与参数,并按用户要求将中文编辑工具文案改为“编辑文件”。不推测 skill 归属,不增加业务页面入口。

## 设计

**入口与标识。** 公共属性 `showToolCalls` 为 `true` 时,在用户消息的复制与编辑操作之间加入调用入口。宿主接入默认 `false`,独立页面 `main.tsx` 显式设为 `true`。文档/导出模式保持不变。持久化记录尚未出现时,实时块 ID 标识运行中的轮次。历史视口从自身块中传递用户记录 UUID,不能拿页面局部 ID 去匹配另一份实时投影。右侧只保留一个随选择切换的页签。在现有按会话隔离的右侧面板状态中保存持久化用户记录 ID 或稳定的 prompt ID,刷新后恢复打开状态、选中页签和原轮次。实时页签在用户记录到达后补齐 ID;实时块被裁剪时,通过分页轮次索引将 prompt ID 解析为记录 ID。仅有投影局部 ID 时不保存或恢复,以免刷新后误匹配其他轮次。

**已记录数据。** 工作区限定的工具调用接口只接收会话与持久化轮次 ID,一次返回完整结果。服务端负责快照与受限的内部 transcript 分页。调用归属于启动时所在的导航 prompt;保留读取器的安全前缀,重放至下一个普通 prompt 边界,使可见定时或实时 prompt 之后到达的结果和计时仍能与原调用配对。对受限片段进行一次重放,再按调用归属筛选开始、更新及计时。客户端继续复用 `projectTrajectoryWindow` 与 `buildTrajectory` 配对工具计时和状态。轮中指令及通知不切分轮次,可见的定时 prompt 与左侧导航采用相同边界。部分重放、游标失败以及响应/页数超限均明确报错,不能伪装为空或完整数据。该路由属于持久化工作区作用域,读取器、游标编码、存储及信任脱敏都使用已解析的 runtime。冻结快照前,对已加载会话复用现有读取屏障,等待 transcript 写入完成;不能为读取调用而 attach 或 resume 会话。

**实时更新。** 选中的 prompt 运行或等待时,直接使用 Message 的 transcript blocks,不请求历史,也不轮询。结束后读取一次历史,等待期间保留实时行;另一 prompt 运行时选择旧 prompt 仍读取历史。按调用 ID 合并,保留完整终态结果,丢弃来自旧会话、旧工作区或旧 prompt 的响应。实时块被裁剪后仍保留已解析的记录标识。实时工具块保留事件已有的 prompt ID;用户锚点被移除时,按该稳定 ID 收集仍保留的调用,继续实时更新且不请求历史。历史重放不使用浏览器接收时钟。

**行布局。** 列表上方先展示 prompt Select,再展示调用总数,prompt 不提供“全部”选项;最右侧保留工具类型 Select,默认“全部工具”,按国际化类型筛选,MCP 归为一类。两个 Select 使用正常正文色,summary 左右 padding 为零,调用总数前不显示图标,工具类型 Select 不显示边框。筛选时保持总调用数量。每行上方显示元信息:工具名前复用 Message 区域的 `ToolSummaryIcon`,国际化工具名使用正常正文色,后面紧跟浅色耗时,右侧依次是明确状态与展开箭头。已完成使用绿色勾选图标和文字,运行中使用共享旋转图标,取消状态使用带斜线的圆形图标,图标和文字均使用警告色。下方描述最多一行,超出显示省略号,悬停可查看完整描述。`rawInput.description` 中非空字符串优先,否则复用 `getToolSummaryDescription`。转义控制字符,描述最多 2000 字符。`localizeToolDisplayName` 对没有翻译的自定义工具沿用既有回退。箭头与可见键盘焦点表示可展开原始参数和结果,每个字段最多 4000 字符。使用已有主题 token 和原生按钮语义。读、写、编辑文件的行在描述后复用消息区的文件预览入口,仅显示图标并保留悬停提示与无障碍标签,紧跟文件名称而非右对齐,路径被省略时按钮也不收缩,点击在右侧面板新建页签。沿用共享的工作区归属与文件可用性检查。智能体行使用已有 summary 重放投影,省略子调用事件,仅在结构化 task 摘要替代结果时去掉重复完整文本,保留普通失败诊断、标识、状态和耗时;点击后进入已有智能体详情页签,按需加载完整子会话。编辑/新建文件的结果复用消息区的 diff 提取与视图,没有 diff 记录时回退到原始结果。合法 JSON 参数和结果在展示截断前识别格式,使用消息区已有的 Markdown 渲染器,以格式化的 JSON 代码块展示,长 JSON 截断后仍保持代码块;其他结果保持纯文本。Diff 渲染优先。

**耗时与状态。** 优先使用 daemon 已记录耗时,包括零和不足一秒的值。失败或取消调用具有合法记录起点时,也保留测量得到的零耗时;旧记录中没有起点的非成功零值仍视为未知。不足一秒显示毫秒,其余复用已有格式化函数。运行中的实时调用复用每秒刷新的共享时钟;已完成且没有计时记录时,可回退到正的客户端观测区间。悬停耗时时使用共享 tooltip,仅展示带毫秒的开始与结束时间。这些值只来自真实的单次调用计时记录,不使用浏览器接收时间,因为历史重放可能在同一时刻交付整轮消息。只有终态调用同时具有真实开始时间与耗时时才显示起止 tooltip 和无障碍时间描述;运行中或历史时间不完整时保留耗时标签,不提供 tooltip。

调度器在成功、失败和取消时保留本次计时所用的开始时间,经 `ToolCallEvent.started_at` 写入 telemetry,由重放输出 `startedAt`,SDK 与 trajectory 原样保留。结束时间等于该开始时间加已记录耗时,与整批调用何时写日志无关。沿用现有耗时口径,包含校验、审批和调度等待,不改变工具执行或指标行为。旧记录保持兼容,不从整批日志时间反推调用起点。

ACP Session 执行路径也在实时工具元信息和 telemetry 中输出测量所用的开始时间与耗时。SDK 保留这些字段,实时时钟使用服务端开始时间,完成后固定为记录耗时。

无法获得时间时显示破折号;等待状态不显示计时。记录中的取消状态覆盖重放块中通用的失败状态。已完成表示工具报告完成,不保证业务操作成功。

**MCP 结果。** MCP 标签放在行状态之前,收起时同样可见,字体为 11px 常规字重。通过 `mcp_invocation` 预览或解析后以 `mcp__` 开头的工具名识别。通用 `tool_call` 包装从输入解析真实工具名和参数,重放计时匹配也使用该名称。

**Shell 分区。** 当前分支合入 PR #12311 的结构化 Shell 结果契约。在工具调用中,“参数”只显示 command 字符串;“结果”只显示版本 1 的 output(空输出同样具有优先权)。其余调用参数与执行元数据放入默认收起的“其他”。实时输出复用已有片段解析器;历史字符串保持文本形式,合法 JSON 则由共用 Markdown JSON 渲染器格式化。未知版本使用 text 回退,不解释其字段。沿用已有展示长度上限。此次接入仅通过单元测试、构建与类型检查验证,新的实时后端调用验证仍待完成。

**身份、错误与渲染。** 轮次索引立即从用户记录读取 `daemonPromptId`,结束记录仅作为旧会话的回退来源。因此 prompt 完成前,选择器即可合并持久化条目与实时条目。无法定位 prompt 与索引刷新失败分别显示可重试提示,位置在 listbox 外;二者都不能显示为无调用空态。成功加载的索引会复用,直到导航身份、快照变化或用户主动刷新。新加载条目优先于请求开始时的导航快照;后续到达的新导航快照仍可更新条目。响应信封包含 `v: 1`;重放不完整返回 `tool_calls_replay_incomplete`。扫描预算包含跨定时/实时边界补齐调用结果所需的后续记录,超限错误明确描述整个扫描,而非单页。

行使用工具调用 ID 贯通实时与持久化投影,保留展开状态。两条路径根据同一套已保留父调用链计算缩进。行渲染使用 memo,折叠时不序列化参数和结果,也不解析 Shell 详情。筛选类型消失时回到“全部工具”。文件描述使用已解析的工作区路径;状态图标作为可见文字旁的装饰,不重复播报。

ACP 审批通过后的通知带有工具名、已批准参数与测量起点,使运行中的行在完成前即可展示命令和描述。信息通知发送失败只记日志,不阻止工具执行;通知之后仍检查取消状态。测试覆盖通知拒绝、历史视口 UUID 传递、非空智能体结果转 summary、零耗时,以及成功、失败和取消时的调度器开始时间。

## 涉及文件

- `TurnCallsPanel`、样式及测试、`loadTurnCalls`:渲染、实时合并、历史加载与验证。
- `turnCallsContext`、`MessageItem`、`MessageTimestamp`、`App`、`ArtifactPanel`:入口与面板接线。
- `TranscriptViewport`、`useTranscriptViewport`:历史记录标识。
- `toolFormatting` 及测试:忽略空白或非字符串描述。
- `toolClassification`、`transcriptToMessages`:共用工具身份与参数投影,同时覆盖消息区。
- `i18n`:中英文文案。
- Core 调度器与 telemetry、ACP 重放、SDK 计时读取和 trajectory:贯通真实调用开始时间的记录与重放。

## 验证

覆盖工具名国际化、描述优先级与回退、已记录和实时耗时、明确状态、历史 ID、过期响应、轮中追加消息、跨页计时、下一轮隔离及重放错误。组件测试覆盖悬停时间、缺失时间与运行中状态;App 测试覆盖页签保存、刷新、会话隔离及延迟到达的持久化记录 ID。调度器、重放和 SDK 测试覆盖独立开始时间、延迟批量日志、旧记录及非法时间戳。测试还覆盖工具筛选及 JSON 与纯文本结果。用户报告的 14 次调用会话已在浏览器确认刷新恢复,以及 MCP 标签和 515ms 耗时;旧记录仍缺少真实开始时间。针对用户报告的真实会话,按持久化调用 ID 和耗时核对,包含最后一轮 57 次与前一轮 139 次调用。模拟 daemon 浏览器测试覆盖布局、键盘展开和实时时钟,不能证明真实历史正确。结果记录在 `.qwen/e2e-tests/web-shell-turn-calls-redesign.md`。

## 风险与限制

- summary 列表省略 Agent 子调用,点击后由现有详情页获取完整子会话。
- 一个 Shell 脚本即使调用多个 API,仍然只是一条调用。
- 缺少服务端计时的旧版实时调用回退到客户端观测耗时,重连后可能少计。
- 服务端内部重放最多加载 100 页,每页 250 条记录,保留的重放片段与响应分别限制为 32 MiB,超限明确报错;公开工具调用接口不提供分页。
- 没有计时记录的旧会话无法恢复精确耗时。

## 会话级 prompt 选择

右侧面板按会话归属,第一行提供按内容宽度显示的 prompt Select(过长省略),右侧提供刷新图标和文案;第二行显示调用总数及工具类型筛选。手动刷新更新 prompt 索引,只有历史 prompt 会重新读取一次调用。从消息操作打开时选中该 prompt,不提供“全部 prompt”选项。复用左侧导航的持久化索引及临时实时 prompt,包括摘要和稳定标识。工具类型筛选仍放在右侧。所选 prompt 保存在现有右侧面板状态中。

历史调用在选择时通过工作区限定的 `GET /workspaces/:workspace/session/:id/tool-calls?turnId=...` 接口读取一次。`turnId` 必填,对外不提供 cursor 或 limit,返回所选轮次的完整调用及已记录计时。内部复用现有 transcript 快照与重放读取,超过大小上限明确报错,不能静默截断列表。遵循已解析的工作区归属、归档协调、活动分支边界及既有脱敏规则。

选中的 prompt 运行中时直接渲染 Message transcript store,不请求历史调用,也不轮询。结束后发起一次历史读取,等待期间保留实时行。另一 prompt 正在运行时切换到旧 prompt,仍使用历史接口。切换选择或会话后丢弃过期响应。持久化索引映射尚未到达时,临时实时 prompt 仍可选择。轮次索引默认返回尾页,选择器和 prompt ID 恢复均在同一快照内向前补齐,确保长会话的早期 prompt 可见。

验收:默认选中入口所属 prompt,Select 在调用次数前,无“全部 prompt”,切换历史只加载所选轮次,实时更新不产生重复 HTTP 请求,结束不闪空,刷新恢复稳定的 prompt 选择。

## 页签顺序与入口文案

界面统一显示“工具调用”,包括已保存后恢复的页签;Message 入口和右侧面板页签使用共享扳手图标。打开或切换工具列表时,工具调用页签位于文件及智能体详情页签之前。刷新恢复应用相同顺序,修正旧持久化状态中相反的排列。
133 changes: 133 additions & 0 deletions packages/acp-bridge/src/transcript-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2394,6 +2394,39 @@ describe('ui_telemetry timing frames', () => {
]);
});

it('uses recorded tool starts instead of the later batch log timestamp', () => {
const machine = timingMachine();
const starts = [1_760_000_000_000, 1_760_000_010_000];
const frames = starts.flatMap((startedAt, index) =>
timings(
machine,
telemetry(`timed-${index}`, {
...TOOL_CALL_EVENT,
call_id: `timed-${index}`,
started_at: startedAt,
duration_ms: 4_000,
}),
),
);
expect(
frames.map((frame) => [frame?.['startedAt'], frame?.['durationMs']]),
).toEqual(starts.map((startedAt) => [startedAt, 4_000]));
const [legacy] = timings(machine, telemetry('legacy', TOOL_CALL_EVENT));
expect(legacy).not.toHaveProperty('startedAt');
});

it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, '1760000000000'])(
'omits an invalid recorded tool start: %s',
(started_at) => {
const [frame] = timings(
timingMachine(),
telemetry('invalid-start', { ...TOOL_CALL_EVENT, started_at }),
);
expect(frame).not.toHaveProperty('startedAt');
expect(frame).toMatchObject({ kind: 'tool', durationMs: 16 });
},
);

it('omits bulky recorded fields the conversation already carries', () => {
const [requestTiming] = timings(
timingMachine(),
Expand Down Expand Up @@ -2833,6 +2866,44 @@ describe('ui_telemetry timing frames', () => {
expect(toolTiming).toMatchObject({ kind: 'tool', durationMs: 0 });
});

it.each(['error', 'cancelled'] as const)(
'keeps measured zero duration and status for %s with a recorded start',
(status) => {
const [toolTiming] = timings(
timingMachine(),
telemetry('measured-zero', {
...TOOL_CALL_EVENT,
duration_ms: 0,
started_at: 1_760_000_000_000,
status,
}),
);
expect(toolTiming).toMatchObject({
kind: 'tool',
durationMs: 0,
startedAt: 1_760_000_000_000,
toolStatus: status,
});
},
);

it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, '1760000000000'])(
'does not treat an invalid start as measured zero timing: %s',
(started_at) => {
expect(
timings(
timingMachine(),
telemetry('invalid-zero', {
...TOOL_CALL_EVENT,
duration_ms: 0,
started_at,
status: 'cancelled',
}),
),
).toEqual([]);
},
);

it('consumes duplicate recorded ids in allocation order', () => {
// Two calls recorded under one id: the first keeps it, the second is
// rewritten. Each telemetry record must claim its own allocation.
Expand Down Expand Up @@ -2895,6 +2966,68 @@ describe('ui_telemetry timing frames', () => {
expect(secondFrame[0]).toMatchObject({ callId: rewrittenCallId });
});

it('pairs reused bridge call ids with resolved tool names across page state', () => {
const first = timingMachine();
const target = 'mcp__yuque__yuque_whoami';
const starts = ['assistant-1', 'assistant-2'].map((uuid) =>
updates(
first,
record(uuid, 'assistant', {
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'bridge-dup',
name: 'tool_call',
args: { name: target, arguments: {} },
},
},
],
},
}),
),
);
const callIds = starts.map(
(items) => (items[0] as unknown as { toolCallId: string }).toolCallId,
);
expect(callIds[0]).not.toBe(callIds[1]);
const carried = JSON.parse(
JSON.stringify(first.snapshot()),
) as TranscriptReplayStateV1;
expect(carried.pendingToolCalls).toEqual([
expect.objectContaining({
toolName: 'tool_call',
resolvedToolName: target,
}),
expect.objectContaining({
toolName: 'tool_call',
resolvedToolName: target,
rawCallId: 'bridge-dup',
}),
]);
const next = createTranscriptReplayMachine({
includeTiming: true,
initialState: carried,
});
const frames = [515, 42].map(
(duration_ms, index) =>
timings(
next,
telemetry(`bridge-timing-${index}`, {
...TOOL_CALL_EVENT,
call_id: 'bridge-dup',
function_name: target,
duration_ms,
}),
)[0],
);
expect(frames).toMatchObject([
{ callId: callIds[0], toolName: target, durationMs: 515 },
{ callId: callIds[1], toolName: target, durationMs: 42 },
]);
});

it('does not re-claim a call already matched on an earlier page', () => {
const first = timingMachine();
updates(first, assistantWithToolCall('assistant-1', 'call_dup'));
Expand Down
Loading