diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index f77968506e..9761794839 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -143,7 +143,7 @@ describe("CodexAppServerAgent", () => { }); }); - it("isolates subagent output, usage, compaction, and completion", async () => { + it("surfaces subagent activity while isolating its lifecycle state", async () => { const stub = makeStubRpc({ initialize: {}, "thread/start": { thread: { id: "thr_1" } }, @@ -191,7 +191,6 @@ describe("CodexAppServerAgent", () => { prompt: "Review the implementation", }, }); - const sessionUpdateCount = sessionUpdates.length; const extNotificationCount = extNotifications.length; stub.emit("item/agentMessage/delta", { @@ -215,6 +214,16 @@ describe("CodexAppServerAgent", () => { text: '{"source":"child"}', }, }); + stub.emit("item/started", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + item: { + type: "commandExecution", + id: "shared_command_id", + command: "echo child", + status: "inProgress", + }, + }); stub.emit("item/commandExecution/outputDelta", { threadId: "subagent_1", turnId: "subagent_turn_1", @@ -251,11 +260,9 @@ describe("CodexAppServerAgent", () => { expect({ extNotifications: extNotifications.length, promptSettled, - sessionUpdates: sessionUpdates.length, }).toEqual({ extNotifications: extNotificationCount, promptSettled: false, - sessionUpdates: sessionUpdateCount, }); stub.emit("item/agentMessage/delta", { @@ -292,10 +299,14 @@ describe("CodexAppServerAgent", () => { await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" }); const serializedUpdates = JSON.stringify(sessionUpdates); expect(serializedUpdates).toContain("spawn_agent"); + expect(serializedUpdates).toContain("subagent prose"); + expect(serializedUpdates).toContain("subagent reasoning"); + expect(serializedUpdates).toContain("child command output"); + expect(serializedUpdates).toContain( + "subagent:subagent_1:shared_command_id", + ); + expect(serializedUpdates).toContain('"parentToolCallId":"spawn_1"'); expect(serializedUpdates).toContain("parent response"); - expect(serializedUpdates).not.toContain("subagent prose"); - expect(serializedUpdates).not.toContain("subagent reasoning"); - expect(serializedUpdates).not.toContain("child command output"); expect(structuredOutputs).toEqual([{ source: "parent" }]); expect( extNotifications.filter( @@ -304,6 +315,142 @@ describe("CodexAppServerAgent", () => { ).toHaveLength(1); }); + it.each(["resumeAgent", "sendInput"])( + "attaches child activity to the current %s call", + async (collaborationTool) => { + let turnNumber = 0; + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + "turn/start": () => ({ + turn: { + id: `turn_${++turnNumber}`, + status: "inProgress", + }, + }), + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + + const firstPrompt = agent.prompt({ + sessionId: "thr_1", + prompt: [{ type: "text", text: "spawn" }], + } as unknown as PromptRequest); + stub.emit("item/started", { + threadId: "thr_1", + turnId: "turn_1", + item: { + type: "collabAgentToolCall", + id: "spawn_1", + tool: "spawnAgent", + receiverThreadIds: ["subagent_1"], + status: "inProgress", + }, + }); + stub.emit("turn/completed", { + threadId: "thr_1", + turn: { id: "turn_1", status: "completed" }, + }); + await firstPrompt; + + const secondPrompt = agent.prompt({ + sessionId: "thr_1", + prompt: [{ type: "text", text: "continue" }], + } as unknown as PromptRequest); + const currentCallId = `${collaborationTool}_1`; + stub.emit("item/started", { + threadId: "thr_1", + turnId: "turn_2", + item: { + type: "collabAgentToolCall", + id: currentCallId, + tool: collaborationTool, + receiverThreadIds: ["subagent_1"], + status: "inProgress", + }, + }); + stub.emit("item/agentMessage/delta", { + threadId: "subagent_1", + turnId: "subagent_turn_2", + itemId: "message_2", + delta: "continued work", + }); + + expect(JSON.stringify(sessionUpdates)).toContain( + `"parentToolCallId":"${currentCallId}"`, + ); + + stub.emit("turn/completed", { + threadId: "thr_1", + turn: { id: "turn_2", status: "completed" }, + }); + await secondPrompt; + }, + ); + + it("buffers child activity until its parent tool call arrives", async () => { + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + "turn/start": { turn: { id: "turn_1", status: "inProgress" } }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + const promptDone = agent.prompt({ + sessionId: "thr_1", + prompt: [{ type: "text", text: "delegate" }], + } as unknown as PromptRequest); + + stub.emit("item/agentMessage/delta", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + itemId: "message_1", + delta: "early child activity", + }); + expect(JSON.stringify(sessionUpdates)).not.toContain( + "early child activity", + ); + + stub.emit("item/started", { + threadId: "thr_1", + turnId: "turn_1", + item: { + type: "collabAgentToolCall", + id: "spawn_1", + tool: "spawnAgent", + receiverThreadIds: ["subagent_1"], + status: "inProgress", + }, + }); + + const serializedUpdates = JSON.stringify(sessionUpdates); + expect(serializedUpdates).toContain("early child activity"); + expect(serializedUpdates).toContain('"parentToolCallId":"spawn_1"'); + expect(serializedUpdates.indexOf("spawn_agent")).toBeLessThan( + serializedUpdates.indexOf("early child activity"), + ); + + stub.emit("turn/completed", { + threadId: "thr_1", + turn: { id: "turn_1", status: "completed" }, + }); + await promptDone; + }); + it.each([ { label: "reads an empty goal", @@ -2492,6 +2639,53 @@ describe("CodexAppServerAgent", () => { }); }); + it("restores subagent relationships from resumed thread history", async () => { + const stub = makeStubRpc({ + initialize: {}, + "thread/resume": { + thread: { + id: "t1", + turns: [ + { + items: [ + { + type: "collabAgentToolCall", + id: "spawn_1", + tool: "spawnAgent", + receiverThreadIds: ["subagent_1"], + status: "completed", + }, + ], + }, + ], + }, + }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.initialize(init); + await agent.resumeSession({ + sessionId: "t1", + cwd: "/r", + mcpServers: [], + } as unknown as Parameters[0]); + + stub.emit("item/agentMessage/delta", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + itemId: "message_1", + delta: "still working", + }); + + expect(JSON.stringify(sessionUpdates)).toContain( + '"parentToolCallId":"spawn_1"', + ); + }); + it("forwards additionalDirectories to thread/start as writable_roots", async () => { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } }); const { client } = makeFakeClient(); @@ -2783,7 +2977,13 @@ describe("CodexAppServerAgent", () => { } // permissionOutcome may be a value or a function (per-call, e.g. a pending promise). - function makePlanAgent(permissionOutcome: unknown) { + function makePlanAgent( + permissionOutcome: unknown, + options: { + rejectPlanToolUpdates?: boolean; + stallPlanToolUpdates?: boolean; + } = {}, + ) { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } }, "turn/start": { turn: { id: "turn_1" } }, @@ -2796,7 +2996,19 @@ describe("CodexAppServerAgent", () => { }> = []; const client = { sessionUpdate: async (n: unknown) => { - sessionUpdates.push(n as { update?: Record }); + const notification = n as { update?: Record }; + if ( + notification.update?.sessionUpdate === "tool_call" || + notification.update?.sessionUpdate === "tool_call_update" + ) { + if (options.rejectPlanToolUpdates) { + throw new Error("renderer disconnected"); + } + if (options.stallPlanToolUpdates) { + return new Promise(() => {}); + } + } + sessionUpdates.push(notification); }, requestPermission: async (params: { toolCall: Record; @@ -2924,6 +3136,57 @@ describe("CodexAppServerAgent", () => { expect(permissionRequests[0].options.map((o) => o.optionId)).toContain( "auto", ); + expect(sessionUpdates).toContainEqual({ + sessionId: "t", + update: { + sessionUpdate: "tool_call", + toolCallId: "p1:implement", + title: "Ready to code?", + kind: "switch_mode", + content: [ + { + type: "content", + content: { type: "text", text: "# The plan\n\n" }, + }, + ], + rawInput: { plan: "# The plan\n\n" }, + status: "in_progress", + }, + }); + expect(sessionUpdates).toContainEqual({ + sessionId: "t", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "p1:implement", + status: "in_progress", + content: [ + { + type: "content", + content: { type: "text", text: "# The plan\n\n1. do it" }, + }, + ], + rawInput: { plan: "# The plan\n\n1. do it" }, + }, + }); + expect(sessionUpdates).toContainEqual({ + sessionId: "t", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "p1:implement", + status: "completed", + }, + }); + expect( + sessionUpdates.some((notification) => { + if (notification.update?.sessionUpdate !== "agent_message_chunk") { + return false; + } + const content = notification.update.content as + | { type?: string; text?: string } + | undefined; + return content?.text?.includes("# The plan") === true; + }), + ).toBe(false); // Mode flipped to auto and the host was told. expect(sessionUpdates).toContainEqual( @@ -2949,8 +3212,66 @@ describe("CodexAppServerAgent", () => { }); }); + it("coalesces streamed plan snapshots in notification history", async () => { + const { agent, stub } = makePlanAgent({ + outcome: { outcome: "selected", optionId: "reject_with_feedback" }, + }); + const { done } = await startPlanTurn(agent, stub); + + stub.emit("item/plan/delta", { itemId: "p1", delta: "first" }); + stub.emit("item/plan/delta", { itemId: "p1", delta: " second" }); + stub.emit("item/plan/delta", { itemId: "p1", delta: " third" }); + stub.emit("turn/completed", { + turn: { id: "turn_1", status: "completed" }, + }); + await done; + + const session = ( + agent as unknown as { + session: { + notificationHistory: Array<{ update?: Record }>; + }; + } + ).session; + const streamedUpdates = session.notificationHistory.filter( + (notification) => + notification.update?.sessionUpdate === "tool_call_update" && + notification.update.status === "in_progress", + ); + expect(streamedUpdates).toHaveLength(1); + expect(streamedUpdates[0].update?.rawInput).toEqual({ + plan: "first second third", + }); + }); + + it("caps streamed plans before rendering or storing them", async () => { + const { agent, stub, permissionRequests, sessionUpdates } = makePlanAgent({ + outcome: { outcome: "selected", optionId: "reject_with_feedback" }, + }); + const { done } = await startPlanTurn(agent, stub); + + stub.emit("item/plan/delta", { itemId: "p1", delta: "a".repeat(75_000) }); + stub.emit("item/plan/delta", { itemId: "p1", delta: "b".repeat(75_000) }); + stub.emit("turn/completed", { + turn: { id: "turn_1", status: "completed" }, + }); + await done; + + const renderedPlans = sessionUpdates.flatMap((notification) => { + const rawInput = notification.update?.rawInput as + | { plan?: unknown } + | undefined; + return typeof rawInput?.plan === "string" ? [rawInput.plan] : []; + }); + const approvalPlan = permissionRequests[0].toolCall.rawInput as { + plan: string; + }; + expect(renderedPlans.every((plan) => plan.length <= 100_000)).toBe(true); + expect(approvalPlan.plan).toHaveLength(100_000); + }); + it("stays in plan mode when the handoff is rejected without feedback", async () => { - const { agent, stub, permissionRequests } = makePlanAgent({ + const { agent, stub, sessionUpdates, permissionRequests } = makePlanAgent({ outcome: { outcome: "selected", optionId: "reject_with_feedback" }, }); const { done } = await startPlanTurn(agent, stub); @@ -2964,12 +3285,64 @@ describe("CodexAppServerAgent", () => { expect((await done).stopReason).toBe("end_turn"); expect(permissionRequests).toHaveLength(1); + expect(sessionUpdates).toContainEqual({ + sessionId: "t", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "p1:implement", + status: "failed", + }, + }); // No implementation turn started; the picker stays on plan. expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength( 1, ); }); + it("settles the handoff when plan tool-call updates cannot be delivered", async () => { + const { agent, stub, permissionRequests } = makePlanAgent( + { + outcome: { outcome: "selected", optionId: "reject_with_feedback" }, + }, + { rejectPlanToolUpdates: true }, + ); + const { done } = await startPlanTurn(agent, stub); + + stub.emit("item/completed", { + item: { type: "plan", id: "p1", text: "# The plan" }, + }); + stub.emit("turn/completed", { + turn: { id: "turn_1", status: "completed" }, + }); + + expect((await done).stopReason).toBe("end_turn"); + expect(permissionRequests).toHaveLength(1); + expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength( + 1, + ); + }); + + it("does not block the handoff when plan tool-call updates never settle", async () => { + const { agent, stub, permissionRequests } = makePlanAgent( + { + outcome: { outcome: "selected", optionId: "reject_with_feedback" }, + }, + { stallPlanToolUpdates: true }, + ); + const { done } = await startPlanTurn(agent, stub); + + stub.emit("item/plan/delta", { itemId: "p1", delta: "# The plan" }); + stub.emit("turn/completed", { + turn: { id: "turn_1", status: "completed" }, + }); + + expect((await done).stopReason).toBe("end_turn"); + expect(permissionRequests).toHaveLength(1); + expect(stub.requests.filter((r) => r.method === "turn/start")).toHaveLength( + 1, + ); + }); + it("feeds handoff feedback into another plan turn", async () => { const { agent, stub, permissionRequests } = makePlanAgent({ outcome: { outcome: "selected", optionId: "reject_with_feedback" }, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 52fe7a8bec..91d1972c59 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -15,6 +15,7 @@ import type { RequestPermissionResponse, ResumeSessionRequest, ResumeSessionResponse, + SessionNotification, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, @@ -116,6 +117,8 @@ type GoalCommand = | { kind: "resume" } | { kind: "set"; objective: string }; +const MAX_PLAN_PROPOSAL_CHARS = 100_000; + type CodexSkill = { name?: string; description?: string; @@ -238,12 +241,19 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** Deployment environment; on "cloud" a non-danger sandbox would panic, so we skip the override. */ private environment?: "local" | "cloud"; private readonly commandOutputs = new Map(); + private readonly subagentParents = new Map(); + private readonly pendingSubagentNotifications = new Map< + string, + SessionNotification[] + >(); /** Extra writable roots for this session, folded into workspaceWrite sandbox turns. */ private additionalDirectories?: string[]; /** The session workspace stays writable when extra roots are applied per turn. */ private workspaceDirectory?: string; /** The in-flight turn's , streamed or completed (drives the implement handoff). */ private planProposal?: { itemId: string; text: string }; + /** Structured plan tool call already emitted while the proposal streams. */ + private streamedPlanToolCallId?: string; /** Idle signal deferred while the plan handoff keeps this prompt busy. */ private deferredTurnComplete?: { usage: PromptResponse["usage"] }; /** Settles the pending plan-approval race on cancel/close/preempting prompt. */ @@ -463,6 +473,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { ): Promise<{ threadId: string; thread: AppServerThread | undefined }> { this.cancelNextGoalTurn = false; this.nativeGoalTurnId = undefined; + this.subagentParents.clear(); + this.pendingSubagentNotifications.clear(); this.jsonSchema = params.meta?.jsonSchema ?? undefined; this.taskRunId = params.meta?.taskRunId; this.environment = params.meta?.environment; @@ -518,6 +530,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } this.threadId = threadId; this.sessionId = threadId; + this.restoreSubagentRelationships(thread); if (method === APP_SERVER_METHODS.THREAD_START && params.meta?.nativeGoal) { await this.restoreGoal(params.meta.nativeGoal); } @@ -882,6 +895,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.lastAgentMessage = ""; this.resetUsage(); this.planProposal = undefined; + this.streamedPlanToolCallId = undefined; // A new turn owns the idle boundary; its own completion emits the signal. this.deferredTurnComplete = undefined; const { completion, turn } = this.turns.begin(); @@ -939,13 +953,22 @@ export class CodexAppServerAgent extends BaseAcpAgent { // Re-check after the await: a cancel that raced the response wins, so a // late accept can never start implementation on a cancelled prompt. if (this.session.cancelled) { + if (outcome.kind === "implement") { + this.completePlanApprovalToolCall(outcome.toolCallId, "failed"); + } result = { ...result, stopReason: "cancelled" }; break; } // A picker change while approval was open owns the mode. Never let a // stale approval overwrite it with a broader implementation mode. - if (this.config.mode !== "plan") break; + if (this.config.mode !== "plan") { + if (outcome.kind === "implement") { + this.completePlanApprovalToolCall(outcome.toolCallId, "failed"); + } + break; + } if (outcome.kind === "implement") { + this.completePlanApprovalToolCall(outcome.toolCallId, "completed"); this.config.setOption("mode", outcome.mode); this.emitCurrentMode(outcome.mode); this.emitConfigOptions(); @@ -997,23 +1020,17 @@ export class CodexAppServerAgent extends BaseAcpAgent { itemId: string; text: string; }): Promise< - | { kind: "implement"; mode: "auto" | "full-access" } + | { + kind: "implement"; + mode: "auto" | "full-access"; + toolCallId: string; + } | { kind: "feedback"; feedback: string } | { kind: "stay" } > { const toolCallId = `${proposal.itemId}:implement`; - const toolCall = { - toolCallId, - title: "Ready to code?", - kind: "switch_mode", - content: [ - { - type: "content" as const, - content: { type: "text" as const, text: proposal.text }, - }, - ], - rawInput: { plan: proposal.text }, - }; + const toolCall = this.buildPlanApprovalToolCall(proposal); + this.emitPlanProposal(toolCall, proposal.text); const options = [ { optionId: "auto", @@ -1056,8 +1073,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { }); const settled = await Promise.race([permission, cancelled]); this.planHandoffCancel = undefined; - if (!settled) return { kind: "stay" }; + if (!settled) { + this.completePlanApprovalToolCall(toolCallId, "failed"); + return { kind: "stay" }; + } if (settled.failed) { + this.completePlanApprovalToolCall(toolCallId, "failed"); this.logger.warn("plan implementation prompt failed; staying in plan", { error: String(settled.err), }); @@ -1069,25 +1090,138 @@ export class CodexAppServerAgent extends BaseAcpAgent { } const response = settled.res; if (this.session.cancelled || response.outcome.outcome !== "selected") { + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } const optionId = response.outcome.optionId; - if (!offered.has(optionId)) return { kind: "stay" }; - if (optionId === "auto") return { kind: "implement", mode: "auto" }; + if (!offered.has(optionId)) { + this.completePlanApprovalToolCall(toolCallId, "failed"); + return { kind: "stay" }; + } + if (optionId === "auto") { + return { kind: "implement", mode: "auto", toolCallId }; + } // Double-gated: only ever offered under ALLOW_BYPASS, and re-checked here. if (optionId === "full-access" && ALLOW_BYPASS) { - return { kind: "implement", mode: "full-access" }; + return { kind: "implement", mode: "full-access", toolCallId }; } if (optionId === "reject_with_feedback") { const feedback = (response as { _meta?: { customInput?: unknown } })._meta ?.customInput; if (typeof feedback === "string" && feedback.trim()) { + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "feedback", feedback: feedback.trim() }; } } + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } + private buildPlanApprovalToolCall(proposal: { + itemId: string; + text: string; + }): { + toolCallId: string; + title: string; + kind: "switch_mode"; + content: Array<{ + type: "content"; + content: { type: "text"; text: string }; + }>; + rawInput: { plan: string }; + } { + return { + toolCallId: `${proposal.itemId}:implement`, + title: "Ready to code?", + kind: "switch_mode", + content: [ + { + type: "content", + content: { type: "text", text: proposal.text }, + }, + ], + rawInput: { plan: proposal.text }, + }; + } + + private emitPlanProposal( + toolCall: ReturnType, + text: string, + ): void { + if (this.streamedPlanToolCallId === toolCall.toolCallId) { + this.emitPlanApprovalToolCall({ + sessionUpdate: "tool_call_update", + toolCallId: toolCall.toolCallId, + status: "in_progress", + content: [{ type: "content", content: { type: "text", text } }], + rawInput: { plan: text }, + }); + return; + } + this.streamedPlanToolCallId = toolCall.toolCallId; + this.emitPlanApprovalToolCall({ + sessionUpdate: "tool_call", + ...toolCall, + status: "in_progress", + }); + } + + private completePlanApprovalToolCall( + toolCallId: string, + status: "completed" | "failed", + ): void { + this.emitPlanApprovalToolCall({ + sessionUpdate: "tool_call_update", + toolCallId, + status, + }); + } + + private emitPlanApprovalToolCall(update: Record): void { + const notification = { + sessionId: this.sessionId, + update, + } as unknown as Parameters[0]; + this.appendPlanApprovalNotification(notification); + void this.client.sessionUpdate(notification).catch((error) => { + this.logger.warn("Failed to emit plan approval tool call update", { + error: String(error), + sessionUpdate: update.sessionUpdate, + toolCallId: update.toolCallId, + }); + }); + } + + private appendPlanApprovalNotification( + notification: Parameters[0], + ): void { + const update = notification.update as Record; + if ( + update.sessionUpdate === "tool_call_update" && + update.status === "in_progress" && + typeof update.toolCallId === "string" + ) { + for ( + let index = this.session.notificationHistory.length - 1; + index >= 0; + index-- + ) { + const previous = this.session.notificationHistory[index] as unknown as { + update?: Record; + }; + if ( + previous.update?.sessionUpdate === "tool_call_update" && + previous.update.status === "in_progress" && + previous.update.toolCallId === update.toolCallId + ) { + this.session.notificationHistory[index] = notification; + return; + } + } + } + this.appendNotification(this.sessionId, notification); + } + /** Emit a plain agent message (user-facing status the model didn't produce). */ private broadcastAgentText(text: string): void { if (!this.sessionId) return; @@ -1162,6 +1296,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { async closeSession(): Promise { this.commandOutputs.clear(); + this.subagentParents.clear(); + this.pendingSubagentNotifications.clear(); this.nativeGoalTurnId = undefined; this.session.abortController.abort(); this.session.cancelled = true; @@ -1181,23 +1317,38 @@ export class CodexAppServerAgent extends BaseAcpAgent { const notificationThreadId = readNotificationThreadId(params); const isMainThread = !notificationThreadId || notificationThreadId === this.threadId; + const relatedSubagentThreadIds = this.captureSubagentRelationship( + method, + params, + notificationThreadId, + ); const mappedParams = isMainThread ? this.withBufferedCommandOutput(method, params) : params; if (this.sessionId && !this.session.cancelled) { - if (isMainThread) { - const notification = mapAppServerNotification( - this.sessionId, - method, - mappedParams, - ); - if (notification) { - void this.client - .sessionUpdate(notification) - .catch((err) => this.logger.warn("sessionUpdate failed", err)); - this.appendNotification(this.sessionId, notification); - } + const notification = mapAppServerNotification( + this.sessionId, + method, + mappedParams, + ); + const visibleNotification = isMainThread + ? notification + : this.mapSubagentNotification(notification, notificationThreadId); + if (visibleNotification) { + this.emitSessionNotification(visibleNotification); + } else if ( + notification && + notificationThreadId && + isSubagentActivityNotification(notification) + ) { + const pending = + this.pendingSubagentNotifications.get(notificationThreadId) ?? []; + pending.push(notification); + this.pendingSubagentNotifications.set(notificationThreadId, pending); + } + for (const threadId of relatedSubagentThreadIds) { + this.flushSubagentNotifications(threadId); } } @@ -1322,6 +1473,120 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } + private captureSubagentRelationship( + method: string, + params: unknown, + senderThreadId: string | undefined, + ): string[] { + if ( + method !== APP_SERVER_NOTIFICATIONS.ITEM_STARTED && + method !== APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED + ) { + return []; + } + const item = (params as { item?: AppServerItem })?.item; + return this.captureSubagentRelationshipItem(item, senderThreadId); + } + + private restoreSubagentRelationships( + thread: AppServerThread | undefined, + ): void { + for (const turn of thread?.turns ?? []) { + for (const item of turn.items ?? []) { + this.captureSubagentRelationshipItem(item, item.senderThreadId); + } + } + } + + private captureSubagentRelationshipItem( + item: AppServerItem | undefined, + senderThreadId: string | undefined, + ): string[] { + if ( + item?.type !== "collabAgentToolCall" || + (item.tool !== "spawnAgent" && + item.tool !== "resumeAgent" && + item.tool !== "sendInput") || + !item.id || + !item.receiverThreadIds?.length + ) { + return []; + } + const parentToolCallId = + senderThreadId && senderThreadId !== this.threadId + ? subagentToolCallId(senderThreadId, item.id) + : item.id; + for (const receiverThreadId of item.receiverThreadIds) { + this.subagentParents.set(receiverThreadId, parentToolCallId); + } + return item.receiverThreadIds; + } + + private emitSessionNotification(notification: SessionNotification): void { + if (!this.sessionId) return; + void this.client + .sessionUpdate(notification) + .catch((err) => this.logger.warn("sessionUpdate failed", err)); + this.appendNotification(this.sessionId, notification); + } + + private flushSubagentNotifications(threadId: string): void { + const pending = this.pendingSubagentNotifications.get(threadId); + if (!pending) return; + this.pendingSubagentNotifications.delete(threadId); + for (const notification of pending) { + const visibleNotification = this.mapSubagentNotification( + notification, + threadId, + ); + if (visibleNotification) { + this.emitSessionNotification(visibleNotification); + } + } + } + + private mapSubagentNotification( + notification: SessionNotification | null, + threadId: string | undefined, + ): SessionNotification | null { + if (!notification || !threadId) return null; + const parentToolCallId = this.subagentParents.get(threadId); + if (!parentToolCallId) return null; + if (!isSubagentActivityNotification(notification)) return null; + const update = notification.update; + const toolCallId = update.toolCallId + ? subagentToolCallId(threadId, update.toolCallId) + : undefined; + if (update.sessionUpdate === "tool_call_update") { + return { + ...notification, + update: { ...update, ...(toolCallId ? { toolCallId } : {}) }, + } as SessionNotification; + } + const existingPosthog = (update._meta?.posthog ?? {}) as Record< + string, + unknown + >; + return { + ...notification, + update: { + ...update, + ...(toolCallId ? { toolCallId } : {}), + _meta: { + ...update._meta, + posthog: { + toolName: + typeof existingPosthog.toolName === "string" + ? existingPosthog.toolName + : "subagent_activity", + ...existingPosthog, + parentToolCallId, + }, + }, + }, + } as SessionNotification; + } + private withBufferedCommandOutput(method: string, params: unknown): unknown { if (!params || typeof params !== "object") { return params; @@ -1381,7 +1646,16 @@ export class CodexAppServerAgent extends BaseAcpAgent { params as { item?: { type?: string; id?: string; text?: string } } )?.item; if (item?.type === "plan" && typeof item.text === "string" && item.text) { - this.planProposal = { itemId: item.id ?? "codex-plan", text: item.text }; + this.planProposal = { + itemId: item.id ?? "codex-plan", + text: item.text.slice(0, MAX_PLAN_PROPOSAL_CHARS), + }; + if (this.config.mode === "plan" && this.streamedPlanToolCallId) { + this.emitPlanProposal( + this.buildPlanApprovalToolCall(this.planProposal), + this.planProposal.text, + ); + } } } @@ -1398,7 +1672,18 @@ export class CodexAppServerAgent extends BaseAcpAgent { : (this.planProposal?.itemId ?? "codex-plan"); const previousText = this.planProposal?.itemId === proposalId ? this.planProposal.text : ""; - this.planProposal = { itemId: proposalId, text: previousText + delta }; + const remainingChars = MAX_PLAN_PROPOSAL_CHARS - previousText.length; + if (remainingChars <= 0) return; + this.planProposal = { + itemId: proposalId, + text: previousText + delta.slice(0, remainingChars), + }; + if (this.config.mode === "plan") { + this.emitPlanProposal( + this.buildPlanApprovalToolCall(this.planProposal), + this.planProposal.text, + ); + } } /** Compaction started: emit `_posthog/status` so the host sets `isCompacting` (gates steer/queue). */ @@ -1733,6 +2018,27 @@ function readNotificationThreadId(params: unknown): string | undefined { return typeof threadId === "string" ? threadId : undefined; } +function subagentToolCallId(threadId: string, toolCallId: string): string { + return `subagent:${threadId}:${toolCallId}`; +} + +function isSubagentActivityNotification( + notification: SessionNotification, +): notification is SessionNotification & { + update: SessionNotification["update"] & { + _meta?: Record; + toolCallId?: string; + }; +} { + const { sessionUpdate } = notification.update; + return ( + sessionUpdate === "agent_message_chunk" || + sessionUpdate === "agent_thought_chunk" || + sessionUpdate === "tool_call" || + sessionUpdate === "tool_call_update" + ); +} + /** The codex thread config override map: folds in MCP servers + makes extra workspace roots writable. Undefined when empty. */ function buildThreadConfig( mcpServers: ReturnType, diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index 11a5f22f90..a44aa3b927 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -42,20 +42,14 @@ describe("mapAppServerNotification", () => { }); }); - it("streams a plan delta as an ACP agent_message_chunk", () => { + it("keeps plan deltas out of the agent transcript", () => { const result = mapAppServerNotification( "s-1", APP_SERVER_NOTIFICATIONS.PLAN_DELTA, { itemId: "p1", delta: "## Plan\n" }, ); - expect(result).toEqual({ - sessionId: "s-1", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "## Plan\n" }, - }, - }); + expect(result).toBeNull(); }); it("returns null when the delta is missing or empty", () => { @@ -609,15 +603,25 @@ describe("mapHistoryItem", () => { ]); }); - it("replays a persisted plan item as an agent_message_chunk", () => { + it("replays a persisted plan item as a historical plan tool call", () => { expect( mapHistoryItem("s-1", { type: "plan", id: "p1", text: "# The plan" }), ).toEqual([ { sessionId: "s-1", update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "# The plan" }, + sessionUpdate: "tool_call", + toolCallId: "p1:implement", + title: "Plan", + kind: "switch_mode", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "# The plan" }, + }, + ], + rawInput: { plan: "# The plan", historical: true }, }, }, ]); diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index 28388f9da9..6f5dfc8d51 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -41,18 +41,9 @@ export function mapAppServerNotification( }, }; } - // Plan-mode proposal streaming as agent prose (codex strips it from agentMessage deltas). - case APP_SERVER_NOTIFICATIONS.PLAN_DELTA: { - const delta = readStringField(params, "delta"); - if (!delta) return null; - return { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: delta }, - }, - }; - } + // Plan deltas are buffered by the adapter for the structured approval UI. + case APP_SERVER_NOTIFICATIONS.PLAN_DELTA: + return null; case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: { // Context indicator: renderer reads `used`/`size`; detailed breakdown comes via `_posthog/usage_update`. const usage = readTokenUsage(params); @@ -276,19 +267,29 @@ export function mapHistoryItem( : []; case "reasoning": return []; - // Replay the proposed plan as agent prose so a reattached host still shows it. - case "plan": - return item.text - ? [ - { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", + case "plan": { + if (!item.text) return []; + const toolCallId = `${item.id ?? "codex-plan"}:implement`; + return [ + { + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Plan", + kind: "switch_mode", + status: "completed", + content: [ + { + type: "content", content: { type: "text", text: item.text }, }, - }, - ] - : []; + ], + rawInput: { plan: item.text, historical: true }, + }, + }, + ]; + } default: { const tool = describeTool(item); if (!tool || !item.id) return []; diff --git a/packages/agent/src/adapters/codex-app-server/protocol.ts b/packages/agent/src/adapters/codex-app-server/protocol.ts index 37c61a75e9..38e2119152 100644 --- a/packages/agent/src/adapters/codex-app-server/protocol.ts +++ b/packages/agent/src/adapters/codex-app-server/protocol.ts @@ -32,8 +32,8 @@ export const APP_SERVER_NOTIFICATIONS = { REASONING_TEXT_DELTA: "item/reasoning/textDelta", // Default reasoning stream for gpt-5 models; raw textDelta is off by default, so without this the host sees no reasoning. REASONING_SUMMARY_TEXT_DELTA: "item/reasoning/summaryTextDelta", - // Plan-mode stream. codex strips the plan from agentMessage deltas, - // so without this the host sees nothing while the plan is written. + // Plan-mode stream. The adapter buffers it for the structured + // plan approval UI because codex strips it from agentMessage deltas. PLAN_DELTA: "item/plan/delta", TURN_PLAN_UPDATED: "turn/plan/updated", TURN_COMPLETED: "turn/completed", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cffe7a2b5d..83534cfe3e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -265,6 +265,7 @@ export { readAgentToolName, readMcpToolDescriptor, readMcpToolName, + readParentToolCallId, } from "./tool-meta"; export { TypedEventEmitter } from "./typed-event-emitter"; export { isSafeExternalUrl } from "./url"; diff --git a/packages/shared/src/tool-meta.test.ts b/packages/shared/src/tool-meta.test.ts index 8e718d8616..77e0bdd02b 100644 --- a/packages/shared/src/tool-meta.test.ts +++ b/packages/shared/src/tool-meta.test.ts @@ -4,6 +4,7 @@ import { readAgentToolName, readMcpToolDescriptor, readMcpToolName, + readParentToolCallId, } from "./tool-meta"; describe("parseMcpToolName", () => { @@ -49,6 +50,43 @@ describe("readAgentToolName", () => { }); }); +describe("readParentToolCallId", () => { + it("prefers the posthog channel over the legacy claudeCode fallback", () => { + expect( + readParentToolCallId({ + posthog: { toolName: "Bash", parentToolCallId: "parent-1" }, + claudeCode: { parentToolCallId: "stale" }, + }), + ).toBe("parent-1"); + }); + + it("falls back to claudeCode when posthog is absent", () => { + expect( + readParentToolCallId({ + claudeCode: { parentToolCallId: "parent-2" }, + }), + ).toBe("parent-2"); + }); + + it("ignores malformed canonical metadata and uses a valid legacy fallback", () => { + expect( + readParentToolCallId({ + posthog: { toolName: "Bash", parentToolCallId: {} }, + claudeCode: { parentToolCallId: "parent-3" }, + }), + ).toBe("parent-3"); + }); + + it("returns undefined for empty or non-string parent ids", () => { + expect( + readParentToolCallId({ posthog: { parentToolCallId: "" } }), + ).toBeUndefined(); + expect( + readParentToolCallId({ claudeCode: { parentToolCallId: 123 } }), + ).toBeUndefined(); + }); +}); + describe("readMcpToolDescriptor / readMcpToolName", () => { it("uses the structured mcp descriptor when present (no name parsing)", () => { const meta = { diff --git a/packages/shared/src/tool-meta.ts b/packages/shared/src/tool-meta.ts index 8ef62cc324..afbfde8049 100644 --- a/packages/shared/src/tool-meta.ts +++ b/packages/shared/src/tool-meta.ts @@ -12,6 +12,8 @@ export interface PosthogToolMeta { toolName: string; /** Set only for MCP tool calls — the originating server + tool. */ mcp?: { server: string; tool: string }; + /** Parent subagent tool call for nested activity. */ + parentToolCallId?: string; } /** `_meta` fragment for adapters to spread onto a tool_call update. */ @@ -45,7 +47,7 @@ export function parseMcpToolName( interface ToolCallMeta { posthog?: PosthogToolMeta; /** Legacy Claude-adapter channel, read only as a fallback. */ - claudeCode?: { toolName?: string }; + claudeCode?: { toolName?: string; parentToolCallId?: string }; } function asToolCallMeta(meta: unknown): ToolCallMeta | undefined { @@ -58,6 +60,15 @@ export function readAgentToolName(meta: unknown): string | undefined { return m?.posthog?.toolName ?? m?.claudeCode?.toolName; } +/** Parent subagent tool call: neutral channel first, legacy fallback. */ +export function readParentToolCallId(meta: unknown): string | undefined { + const m = asToolCallMeta(meta); + const canonical = m?.posthog?.parentToolCallId; + if (typeof canonical === "string" && canonical.length > 0) return canonical; + const legacy = m?.claudeCode?.parentToolCallId; + return typeof legacy === "string" && legacy.length > 0 ? legacy : undefined; +} + /** * The MCP `{ server, tool }` descriptor for a tool call, or undefined for a * non-MCP call. Prefers the structured channel, else parses the legacy diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index d4386b921e..60cf5f5dd9 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -12,6 +12,7 @@ import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, + readParentToolCallId, type UserShellExecuteParams, } from "@posthog/shared"; import { @@ -173,7 +174,17 @@ function isThoughtItem( } export function markThoughtCompletion(items: ConversationItem[]) { + markThoughtCompletionInItems(items, new Set()); +} + +function markThoughtCompletionInItems( + items: ConversationItem[], + visited: Set, +) { + if (visited.has(items)) return; + visited.add(items); const seenContexts = new Set(); + const itemContexts = new Set(); for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; @@ -185,6 +196,13 @@ export function markThoughtCompletion(items: ConversationItem[]) { if (item.type === "session_update") { seenContexts.add(item.turnContext); + itemContexts.add(item.turnContext); + } + } + + for (const context of itemContexts) { + for (const children of context.childItems.values()) { + markThoughtCompletionInItems(children, visited); } } } @@ -747,10 +765,7 @@ function extractUserPrompt(params: unknown): { } function getParentToolCallId(update: SessionUpdate): string | undefined { - const meta = (update as Record)?._meta as - | { claudeCode?: { parentToolCallId?: string } } - | undefined; - return meta?.claudeCode?.parentToolCallId; + return readParentToolCallId((update as Record)._meta); } function pushChildItem(b: ItemBuilder, parentId: string, update: RenderItem) { diff --git a/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts b/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts index d793141e2c..ee98a728e7 100644 --- a/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts @@ -143,6 +143,22 @@ const childToolCallMsg = ( _meta: { claudeCode: { parentToolCallId } }, }); +const childThoughtChunk = ( + ts: number, + text: string, + parentToolCallId: string, +) => + updateMsg(ts, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + _meta: { + posthog: { + toolName: "subagent_activity", + parentToolCallId, + }, + }, + }); + // --- normalization (cycle-free, Map-resolved) ----------------------------- function normContext(ctx: TurnContext) { @@ -475,4 +491,58 @@ describe("createIncrementalConversationBuilder", () => { expect(row2.turnContext.childItems).not.toBe(row1.turnContext.childItems); expect(row2.turnContext.childItems.get("agent1")?.length).toBe(1); }); + + it("groups canonical PostHog child metadata under its subagent", () => { + const inc = createIncrementalConversationBuilder(); + const messages = [ + userPromptMsg(1, 1, "go"), + toolCallMsg(2, "agent1", { + _meta: { posthog: { toolName: "spawn_agent" } }, + }), + updateMsg(3, { + sessionUpdate: "tool_call", + toolCallId: "child1", + kind: "read", + status: "pending", + title: "child1", + _meta: { + posthog: { + toolName: "subagent_activity", + parentToolCallId: "agent1", + }, + }, + }), + ]; + + const result = inc.update(messages, true); + const row = result.items.find((item) => item.type === "session_update"); + if (row?.type !== "session_update") { + throw new Error("expected agent session_update row"); + } + expect(row.turnContext.childItems.get("agent1")?.length).toBe(1); + }); + + it("marks nested subagent thoughts complete when the turn finishes", () => { + const inc = createIncrementalConversationBuilder(); + const messages = [ + userPromptMsg(1, 1, "go"), + toolCallMsg(2, "agent1", { + _meta: { posthog: { toolName: "spawn_agent" } }, + }), + childThoughtChunk(3, "investigating", "agent1"), + promptResponseMsg(4, 1), + ]; + + const result = inc.update(messages, false); + const row = result.items.find((item) => item.type === "session_update"); + if (row?.type !== "session_update") { + throw new Error("expected agent session_update row"); + } + const thought = row.turnContext.childItems.get("agent1")?.[0]; + expect(thought).toMatchObject({ + type: "session_update", + thoughtComplete: true, + update: { sessionUpdate: "agent_thought_chunk" }, + }); + }); }); diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index f9b86585bb..ef705ba77c 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -70,20 +70,64 @@ describe("PlanApprovalView", () => { expect(screen.queryByText(PLAN_MARKER)).not.toBeInTheDocument(); }); - it("shows the rejected status with a working toggle when cancelled", async () => { + it("shows the not-approved status with a working toggle when cancelled", async () => { const user = userEvent.setup(); renderView({ toolCall: makeToolCall({ status: "pending" }), turnCancelled: true, }); - expect(screen.getByText(/\(plan rejected\)/i)).toBeInTheDocument(); + expect(screen.getByText(/\(plan not approved\)/i)).toBeInTheDocument(); const toggle = screen.getByRole("button", { name: /show plan/i }); await user.click(toggle); expect(screen.getByText(PLAN_MARKER)).toBeInTheDocument(); }); + it("shows the not-approved status when the plan tool call fails", () => { + renderView({ toolCall: makeToolCall({ status: "failed" }) }); + + expect(screen.getByText(/\(plan not approved\)/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /show plan/i }), + ).toBeInTheDocument(); + }); + + it("renders historical plans without claiming they were approved", () => { + renderView({ + toolCall: makeToolCall({ + status: "completed", + rawInput: { plan: PLAN_MARKER, historical: true }, + }), + }); + + expect(screen.getByText(/^plan$/i)).toBeInTheDocument(); + expect( + screen.queryByText(/plan approved — proceeding with implementation/i), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /show plan/i }), + ).toBeInTheDocument(); + }); + + it("uses updated content instead of stale raw input while streaming", () => { + renderView({ + toolCall: makeToolCall({ + status: "in_progress", + rawInput: { plan: "Initial plan" }, + content: [ + { + type: "content", + content: { type: "text", text: "Updated plan" }, + }, + ], + }), + }); + + expect(screen.getByText("Updated plan")).toBeInTheDocument(); + expect(screen.queryByText("Initial plan")).not.toBeInTheDocument(); + }); + it("omits the toggle when there is no plan text available", () => { renderView({ toolCall: makeToolCall({ diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx index aefd2f11dd..b70c281953 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -16,7 +16,7 @@ export function PlanApprovalView({ turnComplete, }: ToolViewProps) { const { content } = toolCall; - const { isComplete, wasCancelled } = useToolCallStatus( + const { isComplete, isFailed, wasCancelled } = useToolCallStatus( toolCall.status, turnCancelled, turnComplete, @@ -27,39 +27,44 @@ export function PlanApprovalView({ const hasModelSelector = modelOption?.type === "select" && flattenSelectOptions(modelOption.options).length > 0; + const rawInput = toolCall.rawInput as + | { historical?: boolean; plan?: string } + | undefined; + const isHistoricalPlan = rawInput?.historical === true; const planText = useMemo(() => { - const rawPlan = (toolCall.rawInput as { plan?: string } | undefined)?.plan; - if (rawPlan) return rawPlan; - - if (!content || content.length === 0) return null; - const textContent = content.find((c) => c.type === "content"); - if (textContent && "content" in textContent) { - const inner = textContent.content as - | { type?: string; text?: string } - | undefined; - if (inner?.type === "text" && inner.text) { - return inner.text; + if (content?.length) { + const textContent = content.find((c) => c.type === "content"); + if (textContent && "content" in textContent) { + const inner = textContent.content as + | { type?: string; text?: string } + | undefined; + if (inner?.type === "text" && inner.text) { + return inner.text; + } } } - return null; - }, [content, toolCall.rawInput]); + return rawInput?.plan ?? null; + }, [content, rawInput?.plan]); - const showResult = isComplete || wasCancelled; + const wasNotApproved = isFailed || wasCancelled; + const showResult = isHistoricalPlan || isComplete || wasNotApproved; const canTogglePlan = showResult && !!planText; const planContentId = `plan-content-${toolCall.toolCallId}`; if (!planText && !showResult) return null; - const statusContent = isComplete ? ( + const statusContent = isHistoricalPlan ? ( + Plan + ) : isComplete ? ( <> Plan approved — proceeding with implementation - ) : wasCancelled ? ( - (Plan rejected) + ) : wasNotApproved ? ( + (Plan not approved) ) : null; return (