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..278edeab 100644 --- a/apps/desktop/src/entities/session/trajectory-model.test.ts +++ b/apps/desktop/src/entities/session/trajectory-model.test.ts @@ -68,6 +68,117 @@ 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("keeps an empty calls list when the record is partial", () => { + const turnsWith = (nestedCalls: unknown) => + 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 }, + }, + ]), + ]); + + expect(turnsWith({ calls: [], complete: false })[0].steps[0].nestedCalls).toEqual({ + calls: [], + complete: false, + }); + expect(turnsWith({ calls: [], complete: true })[0].steps[0]).not.toHaveProperty("nestedCalls"); + expect(turnsWith({ calls: [] })[0].steps[0]).not.toHaveProperty("nestedCalls"); + }); + + 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..e81de183 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,68 @@ 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 } : {}), + }); + } + + // An empty list still means something when the record is partial: Pi + // dropped every call, so the Inspector shows "Nested calls (0)" plus the + // partial-record note instead of nothing. + if (!calls.length && complete !== false) { + return undefined; + } + + return { calls, complete: complete === true }; +} + function formatValue(value: unknown) { if (value === undefined) { return ""; @@ -155,6 +233,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..c86585fb 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,128 @@ describe("ChatToolStep", () => { expect([...rows].every((row) => row.getAttribute("data-tool-count") === "1")).toBe(true); }); + it("counts every descendant, not only direct children", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-slot="chat-tool-nested-count"]')).toHaveTextContent( + "2 nested calls", + ); + }); + + it("counts failed descendants separately from top-level failures", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-slot="chat-tool-nested-failed"]')).toHaveTextContent( + "1 nested failed", + ); + expect(screen.queryByText("1 failed")).not.toBeInTheDocument(); + }); + + it("names the deepest running descendant in the live label", () => { + const { container } = render( + , + ); + + expect(container.querySelector('[data-slot="text-shimmer"]')).toHaveTextContent( + "Running codemode › bash…", + ); + }); + + 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 +413,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..4fff5660 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)}`; } @@ -146,6 +147,17 @@ type ChatToolStepOwnProps = { step: ChatToolStepItem; }; +/** Every Nested Tool Execution under `tool`, deepest level included, in start order. */ +function descendantsOf(tool: ChatToolItem): ChatToolItem[] { + const descendants: ChatToolItem[] = []; + + for (const child of tool.children ?? []) { + descendants.push(child, ...descendantsOf(child)); + } + + return descendants; +} + export type ChatToolStepProps = Omit, keyof ChatToolStepOwnProps | "children"> & ChatToolStepOwnProps; @@ -157,6 +169,11 @@ export function ChatToolStep({ }: ChatToolStepProps) { const { tools } = step; const failed = tools.filter((tool) => tool.state === "output-error").length; + const descendants = tools.flatMap(descendantsOf); + const nestedCount = descendants.length; + // Failed descendants get their own meta: a parent that caught its child's + // failure still succeeded, so `failed` stays a top-level count. + const nestedFailed = descendants.filter((tool) => tool.state === "output-error").length; 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 +189,24 @@ 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 the deepest one still + // running: descend from the active call, following the last running child. + let runningChild: ChatToolItem | undefined; + let cursor = active; + + while (cursor) { + const running = (cursor.children ?? []).filter( + (child) => child.state === "input-available", + ); + const next = running[running.length - 1]; + + if (!next) { + break; + } + + runningChild = next; + cursor = next; + } 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 +224,30 @@ 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} + {nestedFailed > 0 ? ( + + {nestedFailed === 1 ? "1 nested failed" : `${nestedFailed} nested failed`} + + ) : 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("renders an empty list with the partial-record note when every call was dropped", () => {
+    const { container } = render(
+       {}}
+        onTabChange={() => {}}
+      />,
+    );
+
+    const section = container.querySelector('[data-slot="nested-calls"]');
+    expect(section).toBeInTheDocument();
+    expect(section).toHaveTextContent("Nested calls (0)");
+    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}