Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>/<n>`。它没有对应的 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
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/dev/ui-intent/regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] } },
Expand Down
107 changes: 107 additions & 0 deletions apps/desktop/src/entities/session/cot-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Extract<AgentRuntimeEvent, { type: "tool" }>> & { 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()) {
Expand Down
92 changes: 90 additions & 2 deletions apps/desktop/src/entities/session/cot-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
SessionRuntimeMessage,
SessionRuntimeMessagePart,
SessionRuntimeModel,
SessionRuntimeTool,
} from "./session-runtime-model";

export type CotPhase = "hidden" | "thinking" | "acting" | "answering" | "settled";
Expand Down Expand Up @@ -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<string, SessionRuntimeTool[]>,
): 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<string, SessionRuntimeTool[]>,
): ChatToolItem {
const tool = part.toolCallId ? model.tools.get(part.toolCallId) : undefined;
const executed = tool?.phase === "done";
Expand All @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -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<string, SessionRuntimeTool[]>();

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;

Expand All @@ -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 } : {}),
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/entities/session/session-runtime-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading