From abb4ed441cc6e263d578e7dd10a123f6a0e512a0 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 30 Sep 2026 20:59:07 +0800 Subject: [PATCH 1/2] feat: show nested tool executions under their parent Pi 0.99 tools such as codemode start other tools through ctx.executeTool(). Those executions carry parentToolCallId and have no tool_call part, so the Chain of Thought never showed them. Carry the parentage through the event contract and runtime model, render nested executions inside the parent's detail (live and replayed), read Pi's bounded nestedCalls record into the Trajectory inspector, and title MCP tools as server/tool. --- CONTEXT.md | 4 + apps/desktop/src/dev/ui-intent/regions.ts | 1 + .../src/entities/session/cot-view.test.ts | 107 +++++++++++++++++ apps/desktop/src/entities/session/cot-view.ts | 92 ++++++++++++++- .../entities/session/session-runtime-model.ts | 9 ++ .../entities/session/trajectory-model.test.ts | 84 +++++++++++++ .../src/entities/session/trajectory-model.ts | 79 +++++++++++++ apps/desktop/src/pages/design-components.tsx | 110 ++++++++++++++++++ .../ui/chat/chat-chain-of-thought-rail.tsx | 3 +- .../shared/ui/chat/chat-tool-step.test.tsx | 81 +++++++++++++ .../src/shared/ui/chat/chat-tool-step.tsx | 20 +++- apps/desktop/src/shared/ui/chat/chat-tool.tsx | 40 ++++++- .../ui/pi-trajectory-inspector.test.tsx | 68 +++++++++++ .../src/shared/ui/pi-trajectory-inspector.tsx | 66 ++++++++++- .../src/shared/ui/pi-trajectory-ledger.tsx | 4 +- docs/design/chat.md | 2 +- docs/design/workspace.md | 2 +- docs/self-built-ui.md | 4 +- .../agent-runtime-event-normalizer.test.ts | 56 +++++++++ .../gateway/agent-runtime-event-normalizer.ts | 9 ++ packages/core/src/agent-runtime-event.ts | 4 + scripts/test-bundled-runtime.mjs | 6 + 22 files changed, 834 insertions(+), 17 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index bfa8477f..5aafbc4c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -216,6 +216,10 @@ _Avoid_: Tool execution, tool run, function call, tool use Pi Runtime 对一个 Tool Call 的真实执行,由协议的 tool 事件 start/update/end 界定,通过 toolCallId 关联回 Tool Call,有 announced/running/done 状态、耗时和 isError。它发生在所属 Assistant Message 结束之后、下一个 Turn 开始之前。 _Avoid_: Tool call, tool result message, tool step +**Nested Tool Execution**: +由另一个 Tool Execution 在执行过程中发起的 Tool Execution(例如 codemode 脚本经 `ctx.executeTool()` 调用 MCP 工具),通过 `parentToolCallId` 挂在发起它的 Tool Execution 下,toolCallId 形如 `<父 toolCallId>/`。它没有对应的 Tool Call:模型没有发出这个调用意图。实时由带 `parentToolCallId` 的 tool 事件界定,结果可见;Pi session 日志只在父调用的 toolResult 消息上留一份有上限的 `nestedCalls` 记录(名称、参数、状态、耗时、错误,不含结果)。 +_Avoid_: Nested tool call, sub-tool, child tool call + **Interim Output**: 非最后一个 Turn 里 partType 为 text 的 Message Part,即模型在继续调用工具前对用户说的过程性话语。协议上它和 Final Answer 是同一种 Part,只有在所属 Assistant Message 结束并确认含 Tool Call 之后才能判定;Live 阶段一律先按 Final Answer 推定呈现。它在 UI 中归属 Chain of Thought 的时间线,不是独立回答气泡。 _Avoid_: Partial answer, progress message, final answer, status diff --git a/apps/desktop/src/dev/ui-intent/regions.ts b/apps/desktop/src/dev/ui-intent/regions.ts index 26041492..8af9f741 100644 --- a/apps/desktop/src/dev/ui-intent/regions.ts +++ b/apps/desktop/src/dev/ui-intent/regions.ts @@ -109,6 +109,7 @@ export const uiRegions: UiRegion[] = [ { term: "Chain of Thought", match: { components: ["AssistantRunTrajectory", "ChatChainOfThought", "ChatChainOfThoughtSteps", "ChatChainOfThoughtStep", "ChatChainOfThoughtRail"] } }, { term: "Thinking", match: { components: ["ChatThoughtStep", "ChatThoughtMarkdown"] } }, { term: "Tool Call", match: { components: ["ChatTool", "ChatToolGroup", "ChatToolStep", "RailToolNode"] } }, + { term: "Nested Tool Execution", match: { components: ["NestedCallRow"], selectors: ['[data-slot="chat-tool-children"]'] } }, { term: "Assistant Message", match: { components: ["AssistantMessageContent", "ChatMessageAssistant"] } }, { term: "Execution Checkout", match: { components: ["GitBranchPicker", "CheckoutStrategyPicker"] } }, { term: "Empty Workspace State", match: { testIds: ["empty-workspace-state"] } }, diff --git a/apps/desktop/src/entities/session/cot-view.test.ts b/apps/desktop/src/entities/session/cot-view.test.ts index e9c70ab3..e7e00930 100644 --- a/apps/desktop/src/entities/session/cot-view.test.ts +++ b/apps/desktop/src/entities/session/cot-view.test.ts @@ -202,6 +202,113 @@ function replay( } describe("CoT view derivation", () => { + it("attaches Nested Tool Executions to their parent call and unwraps codemode's script args", () => { + const m1 = message(1); + const m2 = message(2); + const call = m1.part(0, "tool_call"); + const answer = m2.part(0, "text"); + + // Nested executions arrive as tool events with parentToolCallId and no + // tool_call part — the model never issued them. + const nested = ( + ms: number, + phase: "start" | "end", + event: Partial> & { toolCallId: string; name: string }, + ): Beat => ({ + ms, + event: { + type: "tool", + runId, + turnId: `${runId}:turn-1`, + phase, + surface: "trace", + origin: "sdk", + ...event, + } as AgentRuntimeEvent, + }); + + const { final, viewAt } = replay([ + runStart(0), + m1.start(100), + call.start(200), + call.end(300, '{"code":"return 1"}', "call-1"), + m1.end(400, [call.snapshot('{"code":"return 1"}', "call-1")]), + toolStart(500, "call-1", "codemode"), + nested(600, "start", { + toolCallId: "call-1/1", + parentToolCallId: "call-1", + name: "mcp__probe__echo", + args: { text: "hi" }, + }), + nested(700, "start", { + toolCallId: "call-1/2", + parentToolCallId: "call-1", + name: "bash", + args: { command: "exit 1" }, + }), + nested(800, "end", { + toolCallId: "call-1/1", + parentToolCallId: "call-1", + name: "mcp__probe__echo", + result: { content: [{ type: "text", text: "ECHO:hi" }] }, + isError: false, + }), + nested(900, "end", { + toolCallId: "call-1/2", + parentToolCallId: "call-1", + name: "bash", + result: { content: [{ type: "text", text: "boom" }] }, + isError: true, + }), + toolEnd(1000, "call-1", "codemode", { content: [{ type: "text", text: "1" }] }), + m2.start(1100), + answer.start(1200), + answer.end(1300, "Done."), + m2.end(1400, [answer.snapshot("Done.")]), + runEnd(1500), + ]); + + // While the parent runs, a started-but-unfinished nested execution is + // input-available — it has args but no result yet. + const liveStep = viewAt(700).steps.find((step) => step.kind === "tools"); + const liveChildren = + liveStep?.kind === "tools" ? liveStep.tools[0].children : undefined; + expect(liveChildren?.[0]).toMatchObject({ + toolCallId: "call-1/1", + toolName: "mcp__probe__echo", + state: "input-available", + argsText: '{"text":"hi"}', + }); + + const steps = final.steps; + const toolsSteps = steps.filter((step) => step.kind === "tools"); + expect(toolsSteps).toHaveLength(1); + const toolsStep = toolsSteps[0] as Extract<(typeof steps)[number], { kind: "tools" }>; + expect(toolsStep.tools).toHaveLength(1); + + const [codemode] = toolsStep.tools; + // The detail pane shows the script, not the escaped JSON envelope. + expect(codemode.argsText).toBe("return 1"); + expect(codemode.children?.map((child) => child.toolCallId)).toEqual([ + "call-1/1", + "call-1/2", + ]); + expect(codemode.children?.[0]).toMatchObject({ + toolName: "mcp__probe__echo", + state: "output-available", + argsText: '{"text":"hi"}', + output: JSON.stringify({ content: [{ type: "text", text: "ECHO:hi" }] }), + durationMs: 200, + }); + expect(codemode.children?.[1]).toMatchObject({ + toolName: "bash", + state: "output-error", + argsText: '{"command":"exit 1"}', + output: JSON.stringify({ content: [{ type: "text", text: "boom" }] }), + durationMs: 200, + }); + }); + it("preserves a failed run outcome for the settled header", () => { let model = createSessionRuntimeModel(); for (const [index, beat] of [runStart(0), runEnd(1400, "failed")].entries()) { diff --git a/apps/desktop/src/entities/session/cot-view.ts b/apps/desktop/src/entities/session/cot-view.ts index 876bde3d..c9a888c8 100644 --- a/apps/desktop/src/entities/session/cot-view.ts +++ b/apps/desktop/src/entities/session/cot-view.ts @@ -9,6 +9,7 @@ import type { SessionRuntimeMessage, SessionRuntimeMessagePart, SessionRuntimeModel, + SessionRuntimeTool, } from "./session-runtime-model"; export type CotPhase = "hidden" | "thinking" | "acting" | "answering" | "settled"; @@ -72,9 +73,76 @@ function serializeToolDetail(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value); } +// The codemode tool's only argument is a `code` envelope; the detail pane is +// more useful showing the script itself than escaped JSON. Partial JSON while +// the call streams in stays raw. +function codemodeArgsText(argsText: string, toolName: string | undefined): string { + if (toolName !== "codemode") { + return argsText; + } + + try { + const parsed: unknown = JSON.parse(argsText); + if ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { code?: unknown }).code === "string" + ) { + return (parsed as { code: string }).code; + } + } catch { + // fall through: mid-stream partial JSON + } + + return argsText; +} + +/** + * Executions that `toolCallId` started itself (Pi ctx.executeTool(), e.g. a + * codemode script calling an MCP tool). They carry no tool_call part, so they + * never become steps of their own — the parent's item owns them. Reads the + * per-derivation `childrenByParent` index, whose lists are already in start + * order (model.tools insertion order). + */ +function nestedItems( + parentId: string, + childrenByParent: ReadonlyMap, +): ChatToolItem[] { + const items: ChatToolItem[] = []; + + for (const tool of childrenByParent.get(parentId) ?? []) { + const executed = tool.phase === "done"; + const startedMs = parseTime(tool.startedAt); + const endedMs = executed ? parseTime(tool.updatedAt) : undefined; + const diffStat = + executed && !tool.isError ? toolDiffStatFromResult(tool.result) : undefined; + const children = nestedItems(tool.toolCallId, childrenByParent); + + items.push({ + state: executed + ? tool.isError + ? "output-error" + : "output-available" + : "input-available", + toolCallId: tool.toolCallId, + ...(tool.name ? { toolName: tool.name } : {}), + ...(tool.args !== undefined ? { argsText: JSON.stringify(tool.args) } : {}), + ...(tool.result !== undefined ? { output: serializeToolDetail(tool.result) } : {}), + ...(diffStat ? { diffStat } : {}), + ...(startedMs !== undefined && endedMs !== undefined + ? { durationMs: endedMs - startedMs } + : {}), + ...(children.length ? { children } : {}), + }); + } + + return items; +} + function toolItem( part: SessionRuntimeMessagePart, model: SessionRuntimeModel, + childrenByParent: ReadonlyMap, ): ChatToolItem { const tool = part.toolCallId ? model.tools.get(part.toolCallId) : undefined; const executed = tool?.phase === "done"; @@ -93,17 +161,20 @@ function toolItem( : "input-streaming"; const diffStat = executed && !tool?.isError ? toolDiffStatFromResult(tool?.result) : undefined; + const argsText = part.body ? codemodeArgsText(part.body, toolName) : undefined; + const children = part.toolCallId ? nestedItems(part.toolCallId, childrenByParent) : []; return { state, ...(part.toolCallId ? { toolCallId: part.toolCallId } : {}), ...(toolName ? { toolName } : {}), - ...(part.body ? { argsText: part.body } : {}), + ...(argsText ? { argsText } : {}), ...(diffStat ? { diffStat } : {}), ...(tool?.result !== undefined ? { output: serializeToolDetail(tool.result) } : {}), ...(startedMs !== undefined && endedMs !== undefined ? { durationMs: endedMs - startedMs } : {}), + ...(children.length ? { children } : {}), }; } @@ -189,6 +260,23 @@ export function deriveCotView( const ticking = phase === "thinking" || phase === "acting"; const steps: CotStep[] = []; + // Parent index for Nested Tool Executions, built once per derivation rather + // than rescanning model.tools inside every tool item's recursive lookup. + // Nested events share their parent's runId, so the filter also keeps an old + // Run's executions out of the current Run's steps. + const childrenByParent = new Map(); + + for (const tool of model.tools.values()) { + if (tool.parentToolCallId === undefined || tool.runId !== runId) { + continue; + } + + const siblings = childrenByParent.get(tool.parentToolCallId) ?? []; + + siblings.push(tool); + childrenByParent.set(tool.parentToolCallId, siblings); + } + for (const message of messages) { const isCurrent = message === current; @@ -202,7 +290,7 @@ export function deriveCotView( steps.push({ kind: "tools", id: `${message.messageId}-tools-${slotIndex}`, - tools: slot.parts.map((part) => toolItem(part, model)), + tools: slot.parts.map((part) => toolItem(part, model, childrenByParent)), live, // Pi executes in order, so the active call is the first unexecuted one. ...(live && pending[0].toolCallId ? { activeToolCallId: pending[0].toolCallId } : {}), diff --git a/apps/desktop/src/entities/session/session-runtime-model.ts b/apps/desktop/src/entities/session/session-runtime-model.ts index ea390ce9..230dccc6 100644 --- a/apps/desktop/src/entities/session/session-runtime-model.ts +++ b/apps/desktop/src/entities/session/session-runtime-model.ts @@ -73,6 +73,10 @@ export type SessionRuntimeTool = { toolCallId: string; runId: string; turnId: string; + // Set on a Nested Tool Execution: the toolCallId of the execution that + // started it. Such executions have no tool_call part, so the Chain of + // Thought reaches them only through this parentage. + parentToolCallId?: string; // "announced": only seen as a message tool_call part; "running": execution // started; "done": execution ended. phase: "announced" | "running" | "done"; @@ -450,6 +454,11 @@ export function applyAgentRuntimeEvent( turnId: event.turnId, phase: event.phase === "end" ? "done" : "running", name: event.name || existing?.name, + ...(event.parentToolCallId !== undefined + ? { parentToolCallId: event.parentToolCallId } + : existing?.parentToolCallId !== undefined + ? { parentToolCallId: existing.parentToolCallId } + : {}), ...(existing?.argsText ? { argsText: existing.argsText } : {}), // Validated args from tool execution overwrite message-stream args. args: event.args !== undefined ? event.args : existing?.args, diff --git a/apps/desktop/src/entities/session/trajectory-model.test.ts b/apps/desktop/src/entities/session/trajectory-model.test.ts index eca0c00d..d807b40d 100644 --- a/apps/desktop/src/entities/session/trajectory-model.test.ts +++ b/apps/desktop/src/entities/session/trajectory-model.test.ts @@ -68,6 +68,90 @@ describe("buildTrajectoryTurns", () => { expect(step.toolCallId).toBe("call_1"); }); + it("reads the parent's toolResult nestedCalls record onto the tool step", () => { + const [turn] = buildTrajectoryTurns([ + assistantTurn([ + { + partType: "toolCall", + name: "codemode", + payload: { id: "call_1", arguments: { code: "return 1" } }, + }, + { + partType: "toolResult", + name: "codemode", + text: "1", + isError: false, + payload: { + toolCallId: "call_1", + nestedCalls: { + complete: false, + calls: [ + { + id: "call_1/1", + name: "mcp__probe__echo", + arguments: { text: "hi" }, + status: "ok", + durationMs: 42, + }, + { + id: "call_1/2", + name: "bash", + argumentsBytes: 512, + status: "error", + error: "exit 1", + }, + // Malformed entries are dropped, not carried. + { name: "mcp__probe__echo", status: "ok" }, + { id: "call_1/4", name: "bash", status: "exploded" }, + ], + }, + }, + }, + ]), + ]); + + expect(turn.steps[0].nestedCalls).toEqual({ + complete: false, + calls: [ + { + id: "call_1/1", + name: "mcp__probe__echo", + argsText: JSON.stringify({ text: "hi" }, null, 2), + status: "ok", + durationMs: 42, + }, + { + id: "call_1/2", + name: "bash", + argumentsBytes: 512, + status: "error", + error: "exit 1", + }, + ], + }); + }); + + it("leaves nestedCalls absent when the toolResult carries none", () => { + const [turn] = buildTrajectoryTurns([ + assistantTurn([ + { + partType: "toolCall", + name: "bash", + payload: { id: "call_1", arguments: {} }, + }, + { + partType: "toolResult", + name: "bash", + text: "ok", + isError: false, + payload: { toolCallId: "call_1" }, + }, + ]), + ]); + + expect(turn.steps[0]).not.toHaveProperty("nestedCalls"); + }); + it("keeps an unmatched toolCall in the running state", () => { const [turn] = buildTrajectoryTurns([ assistantTurn([ diff --git a/apps/desktop/src/entities/session/trajectory-model.ts b/apps/desktop/src/entities/session/trajectory-model.ts index 3096946c..f2b7786f 100644 --- a/apps/desktop/src/entities/session/trajectory-model.ts +++ b/apps/desktop/src/entities/session/trajectory-model.ts @@ -7,6 +7,20 @@ import { toolTargetFromArgs } from "@/shared/ui/chat/chat-tool"; export type TrajectoryRole = "user" | "assistant" | "toolResult" | "annotation" | "unknown"; +/** + * One Nested Tool Execution as Pi's session log records it on the parent's + * toolResult message: no results, arguments possibly replaced by a byte count. + */ +export type TrajectoryNestedCall = { + id: string; + name: string; + argsText?: string; + argumentsBytes?: number; + status: "ok" | "error" | "unfinished"; + durationMs?: number; + error?: string; +}; + export type TrajectoryStep = { id: string; turnIndex: number; @@ -22,6 +36,8 @@ export type TrajectoryStep = { isError?: boolean; isRunning?: boolean; durationMs?: number; + /** Executions this call started itself; `complete` is false when Pi dropped calls. */ + nestedCalls?: { calls: TrajectoryNestedCall[]; complete: boolean }; /** Native toolCallId on a tool step; used to join SubagentRecords. */ toolCallId?: string; }; @@ -72,6 +88,65 @@ function payloadString(part: SessionContentPart, key: string) { return typeof value === "string" ? value : undefined; } +const NESTED_CALL_STATUSES = new Set([ + "ok", + "error", + "unfinished", +]); + +// Pi records a parent's Nested Tool Executions on the toolResult message; +// entries are capped and may be malformed, so keep only well-formed calls. +function nestedCallsFromPayload(part: SessionContentPart): TrajectoryStep["nestedCalls"] { + const value = payloadRecord(part)?.nestedCalls; + if (!value || typeof value !== "object") { + return undefined; + } + + const { calls: rawCalls, complete } = value as { calls?: unknown; complete?: unknown }; + if (!Array.isArray(rawCalls)) { + return undefined; + } + + const calls: TrajectoryNestedCall[] = []; + + for (const entry of rawCalls) { + if (!entry || typeof entry !== "object") { + continue; + } + + const call = entry as Record; + + if ( + typeof call.id !== "string" || + typeof call.name !== "string" || + typeof call.status !== "string" || + !NESTED_CALL_STATUSES.has(call.status as TrajectoryNestedCall["status"]) + ) { + continue; + } + + calls.push({ + id: call.id, + name: call.name, + ...(call.arguments !== undefined + ? { argsText: JSON.stringify(call.arguments, null, 2) } + : {}), + ...(typeof call.argumentsBytes === "number" + ? { argumentsBytes: call.argumentsBytes } + : {}), + status: call.status as TrajectoryNestedCall["status"], + ...(typeof call.durationMs === "number" ? { durationMs: call.durationMs } : {}), + ...(typeof call.error === "string" ? { error: call.error } : {}), + }); + } + + if (!calls.length) { + return undefined; + } + + return { calls, complete: complete === true }; +} + function formatValue(value: unknown) { if (value === undefined) { return ""; @@ -155,6 +230,10 @@ export function buildTrajectoryTurns(turns: SessionTurn[]): TrajectoryTurn[] { step.output = part.text ?? formatValue(part.payload); step.isError = part.isError; step.durationMs = part.durationMs; + const nestedCalls = nestedCallsFromPayload(part); + if (nestedCalls) { + step.nestedCalls = nestedCalls; + } continue; } diff --git a/apps/desktop/src/pages/design-components.tsx b/apps/desktop/src/pages/design-components.tsx index bd2497e9..365fdd9d 100644 --- a/apps/desktop/src/pages/design-components.tsx +++ b/apps/desktop/src/pages/design-components.tsx @@ -1109,6 +1109,52 @@ function PiTrajectoryInspectorGallery() { /> + +
+ {}} + onTabChange={() => {}} + /> +
+
@@ -2371,6 +2475,12 @@ function ChatToolStepGallery() { + + + + + +
); diff --git a/apps/desktop/src/shared/ui/chat/chat-chain-of-thought-rail.tsx b/apps/desktop/src/shared/ui/chat/chat-chain-of-thought-rail.tsx index 4df16e9c..55b2cbc1 100644 --- a/apps/desktop/src/shared/ui/chat/chat-chain-of-thought-rail.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-chain-of-thought-rail.tsx @@ -3,6 +3,7 @@ import type { ComponentProps } from "react"; import { TextShimmer } from "@/shared/ui/chat/text-shimmer"; import { formatToolDuration, + toolDisplayName, toolTargetFromArgs, type ChatToolItem, } from "@/shared/ui/chat/chat-tool"; @@ -61,7 +62,7 @@ function RailToolNode({ tool }: { tool: ChatToolItem }) { const row = ( <> - {tool.toolName ?? "tool"} + {toolDisplayName(tool.toolName) ?? "tool"} {target ? ( {target} ) : null} diff --git a/apps/desktop/src/shared/ui/chat/chat-tool-step.test.tsx b/apps/desktop/src/shared/ui/chat/chat-tool-step.test.tsx index 8a242923..321dfc9f 100644 --- a/apps/desktop/src/shared/ui/chat/chat-tool-step.test.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-tool-step.test.tsx @@ -72,6 +72,10 @@ describe("summarizeTools", () => { expect(summarizedCommand.endsWith("…")).toBe(true); }); + it("reads MCP names as server/tool, like Pi's TUI", () => { + expect(summarizeTools([tool({ toolName: "mcp__probe__echo" })])).toBe("Used probe/echo"); + }); + it("counts a burst by what its calls did, not by how many they were", () => { expect( summarizeTools([ @@ -256,6 +260,52 @@ describe("ChatToolStep", () => { expect([...rows].every((row) => row.getAttribute("data-tool-count") === "1")).toBe(true); }); + it("counts a call's nested executions in the settled meta", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-slot="chat-tool-nested-count"]')).toHaveTextContent( + "2 nested calls", + ); + }); + + it("names the running nested execution after its parent while live", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-slot="text-shimmer"]')).toHaveTextContent( + "Running codemode › probe/echo…", + ); + }); + // One call is the one-element burst, but the step row already says what // the call did: a second header inside the panel only costs a second click. it("opens a single call's args and output with one click", async () => { @@ -287,4 +337,35 @@ describe("ChatToolStep", () => { expect(container.querySelector('[data-slot="chat-tool-group"]')).not.toBeInTheDocument(); expect(screen.getAllByRole("button")).toHaveLength(1); }); + + it("lists a call's nested executions inside the expanded detail", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await user.click(screen.getByRole("button", { name: /codemode/ })); + + const children = container.querySelector('[data-slot="chat-tool-children"]'); + expect(children).toBeInTheDocument(); + expect(children).toHaveTextContent("probe/echo"); + expect(children).toHaveTextContent("bash"); + }); }); diff --git a/apps/desktop/src/shared/ui/chat/chat-tool-step.tsx b/apps/desktop/src/shared/ui/chat/chat-tool-step.tsx index 7f6e8651..1c89ada8 100644 --- a/apps/desktop/src/shared/ui/chat/chat-tool-step.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-tool-step.tsx @@ -6,6 +6,7 @@ import { ChatToolGroup, formatToolDuration, hasToolDetail, + toolDisplayName, toolTargetFromArgs, type ChatToolItem, } from "@/shared/ui/chat/chat-tool"; @@ -117,7 +118,7 @@ export function summarizeTools(tools: ChatToolItem[]) { } return verb === FALLBACK - ? `Used ${tool.toolName ?? "a tool"}` + ? `Used ${toolDisplayName(tool.toolName) ?? "a tool"}` : `${verb.past} ${pluralize(1, verb.noun)}`; } @@ -157,6 +158,7 @@ export function ChatToolStep({ }: ChatToolStepProps) { const { tools } = step; const failed = tools.filter((tool) => tool.state === "output-error").length; + const nestedCount = tools.reduce((sum, tool) => sum + (tool.children?.length ?? 0), 0); const totalMs = tools.reduce((sum, tool) => sum + (tool.durationMs ?? 0), 0); // Line stats page in with the settled summary only; mid-burst a finished // call already has its result but the row is still paging call names. @@ -172,6 +174,11 @@ export function ChatToolStep({ ); const active = tools.find((tool) => tool.toolCallId === step.activeToolCallId) ?? tools[tools.length - 1]; + // While a Nested Tool Execution runs, the label names it after its parent. + const runningChildren = active?.children?.filter( + (child) => child.state === "input-available", + ); + const runningChild = runningChildren?.[runningChildren.length - 1]; const kind = step.live ? toolKindFromName(active?.toolName) : toolKindFromTools(tools); // One pager for the row's whole life: call to call, and then to the summary, // all turn at the same pace, so finishing the burst is a page turn too. @@ -189,13 +196,22 @@ export function ChatToolStep({ - {active?.toolName ? `Running ${active.toolName}…` : "Running…"} + {active?.toolName + ? runningChild?.toolName + ? `Running ${toolDisplayName(active.toolName)} › ${toolDisplayName(runningChild.toolName)}…` + : `Running ${toolDisplayName(active.toolName)}…` + : "Running…"} ) : ( {summarizeTools(tools)} + {nestedCount > 0 ? ( + + {nestedCount === 1 ? "1 nested call" : `${nestedCount} nested calls`} + + ) : null} {diffStat ? ( +{diffStat.additions}{" "} diff --git a/apps/desktop/src/shared/ui/chat/chat-tool.tsx b/apps/desktop/src/shared/ui/chat/chat-tool.tsx index efb01051..14316021 100644 --- a/apps/desktop/src/shared/ui/chat/chat-tool.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-tool.tsx @@ -4,6 +4,7 @@ import { type ChatToolCallStatus, } from "@astryxdesign/core"; import type { ComponentProps } from "react"; +import { ChatToolKindIcon, toolKindFromName } from "@/shared/ui/chat/chat-tool-kind"; /** * Lifecycle of a tool invocation as rendered in the trace. Mirrors the state @@ -17,6 +18,8 @@ export type ToolPartState = export type ChatToolItem = { argsText?: string; + /** Nested Tool Executions this call started, in start order. */ + children?: ChatToolItem[]; /** Per-edit line counts; rendered as Astryx additions/deletions stats. */ diffStat?: { additions: number; deletions: number }; durationMs?: number; @@ -26,6 +29,24 @@ export type ChatToolItem = { toolName?: string; }; +/** + * Display name for a tool call: `mcp____` reads as + * `/`, matching how Pi's TUI titles MCP calls. Anything else is + * returned unchanged. Data layers keep the raw name; this is render-only. + */ +export function toolDisplayName(name: string | undefined): string | undefined { + if (!name?.startsWith("mcp__")) { + return name; + } + + const rest = name.slice("mcp__".length); + const separator = rest.indexOf("__"); + + return separator === -1 + ? rest + : `${rest.slice(0, separator)}/${rest.slice(separator + 2)}`; +} + const statusMap: Record = { "input-streaming": "running", "input-available": "running", @@ -87,13 +108,15 @@ export function formatToolDuration(durationMs: number | undefined): string | und } export function hasToolDetail(tool: ChatToolItem) { - return tool.argsText != null || tool.output != null; + return tool.argsText != null || tool.output != null || (tool.children?.length ?? 0) > 0; } /** * The args and output panes of one call. Rendered inside the Astryx row when * it expands, and directly by a single-call ChatToolStep, whose own row * already names the call — a second header there would only cost a click. + * Nested Tool Executions sit between args and result, in the same per-call + * row shape a multi-call ChatToolStep uses. */ export type ChatToolDetailProps = Omit, "children"> & { tool: ChatToolItem; @@ -107,6 +130,16 @@ export function ChatToolDetail({ tool, className, ...rest }: ChatToolDetailProps {tool.argsText} ) : null} + {tool.children?.length ? ( +
    + {tool.children.map((child, index) => ( +
  1. + + +
  2. + ))} +
+ ) : null} {tool.output !== undefined ? (
           {tool.output}
@@ -120,7 +153,7 @@ function toAstryxCall(tool: ChatToolItem, index: number): ChatToolCallItem {
   const resultDetail = hasToolDetail(tool) ?  : undefined;
 
   return {
-    name: tool.toolName ?? "tool",
+    name: toolDisplayName(tool.toolName) ?? "tool",
     status: statusMap[tool.state],
     target: toolTargetFromArgs(tool.argsText),
     ...(tool.diffStat
@@ -175,6 +208,7 @@ export type ChatToolProps = Omit, keyof ChatToolItem | "ch
 
 export function ChatTool({
   argsText,
+  children,
   diffStat,
   durationMs,
   output,
@@ -195,7 +229,7 @@ export function ChatTool({
        {
+  const codemodeStep: TrajectoryStep = {
+    id: "codemode",
+    turnIndex: 0,
+    stepIndex: 0,
+    kind: "tool",
+    name: "codemode",
+    toolCallId: "call-1",
+    nestedCalls: {
+      complete: false,
+      calls: [
+        {
+          id: "call-1/1",
+          name: "mcp__probe__echo",
+          argsText: '{\n  "text": "hi"\n}',
+          status: "ok",
+          durationMs: 42,
+        },
+        {
+          id: "call-1/2",
+          name: "bash",
+          argumentsBytes: 512,
+          status: "error",
+          error: "exit 1",
+        },
+      ],
+    },
+  };
+
+  it("lists the recorded calls with their status, args and errors", () => {
+    const { container } = render(
+       {}}
+        onTabChange={() => {}}
+      />,
+    );
+
+    const section = container.querySelector('[data-slot="nested-calls"]');
+    expect(section).toBeInTheDocument();
+    expect(section).toHaveTextContent("Nested calls (2)");
+    expect(section).toHaveTextContent("probe/echo");
+    expect(section).toHaveTextContent("42ms");
+    expect(section).toHaveTextContent('"text": "hi"');
+    expect(section).toHaveTextContent("Arguments omitted (512 bytes)");
+    expect(section).toHaveTextContent("exit 1");
+    expect(section).toHaveTextContent(
+      "Pi kept a partial record: some calls were dropped, had arguments omitted, or had not finished.",
+    );
+  });
+
+  it("omits the section when the step recorded no nested calls", () => {
+    const { container } = render(
+       {}}
+        onTabChange={() => {}}
+      />,
+    );
+
+    expect(container.querySelector('[data-slot="nested-calls"]')).not.toBeInTheDocument();
+  });
+});
+
 describe("PiTrajectoryInspector child controls", () => {
   it("hides send/stop when the record did not advertise them", () => {
     renderInspector({
diff --git a/apps/desktop/src/shared/ui/pi-trajectory-inspector.tsx b/apps/desktop/src/shared/ui/pi-trajectory-inspector.tsx
index 8f7727d5..d7d48d01 100644
--- a/apps/desktop/src/shared/ui/pi-trajectory-inspector.tsx
+++ b/apps/desktop/src/shared/ui/pi-trajectory-inspector.tsx
@@ -1,9 +1,13 @@
 import type { ComponentProps } from "react";
 import { useState } from "react";
-import { formatToolDuration } from "@/shared/ui/chat/chat-tool";
+import { formatToolDuration, toolDisplayName } from "@/shared/ui/chat/chat-tool";
 import type { RuntimeToolSchema } from "@pace/core";
 import { isSettledSubagentState, type SubagentRecord } from "@pace/core";
-import type { TrajectoryStep, TrajectoryTurn } from "@/entities/session/trajectory-model";
+import type {
+  TrajectoryNestedCall,
+  TrajectoryStep,
+  TrajectoryTurn,
+} from "@/entities/session/trajectory-model";
 import { TrajectoryStepBadge, trajectoryStepStatus, trajectoryStepType } from "@/shared/ui/pi-trajectory-ledger";
 
 /**
@@ -52,6 +56,44 @@ function formatTime(value?: string) {
   return new Intl.DateTimeFormat(undefined, { timeStyle: "medium" }).format(date);
 }
 
+// Same glyph/color conventions as trajectoryStepStatus in the ledger.
+const NESTED_CALL_STATUS: Record<
+  TrajectoryNestedCall["status"],
+  { glyph: string; className: string }
+> = {
+  ok: { glyph: "✓", className: "text-success" },
+  error: { glyph: "✕", className: "text-danger" },
+  unfinished: { glyph: "…", className: "text-muted" },
+};
+
+function NestedCallRow({ call }: { call: TrajectoryNestedCall }) {
+  const status = NESTED_CALL_STATUS[call.status];
+
+  return (
+    
  • +
    + + + {toolDisplayName(call.name)} + + + {formatToolDuration(call.durationMs)} + +
    + {call.argsText !== undefined ? ( + + ) : call.argumentsBytes !== undefined ? ( +

    + Arguments omitted ({call.argumentsBytes} bytes) +

    + ) : null} + {call.error ?

    {call.error}

    : null} +
  • + ); +} + function Field({ label, children }: { label: string; children?: React.ReactNode }) { return (
    @@ -175,7 +217,7 @@ export function PiTrajectoryInspector({

    - {step.name ?? step.kind} + {toolDisplayName(step.name) ?? step.kind}