From 17b0264fde2246887d1deddf2511c3da7889195b Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Thu, 16 Jul 2026 14:37:24 -0400 Subject: [PATCH 1/6] fix(agent): show Codex subagent activity Generated-By: PostHog Code Task-Id: d8f51f0f-0f93-47ef-9c93-4b70efc02672 --- .../codex-app-server-agent.test.ts | 25 ++-- .../codex-app-server-agent.ts | 115 ++++++++++++++++-- packages/shared/src/index.ts | 1 + packages/shared/src/tool-meta.test.ts | 20 +++ packages/shared/src/tool-meta.ts | 10 +- .../components/buildConversationItems.ts | 6 +- .../incrementalConversationItems.test.ts | 30 +++++ 7 files changed, 183 insertions(+), 24 deletions(-) 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 23355aee71..34dec2522c 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( 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 8b1ce61b89..7a4f2e48c6 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, @@ -236,6 +237,7 @@ 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(); /** 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. */ @@ -460,6 +462,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { ): Promise<{ threadId: string; thread: AppServerThread | undefined }> { this.cancelNextGoalTurn = false; this.nativeGoalTurnId = undefined; + this.subagentParents.clear(); this.jsonSchema = params.meta?.jsonSchema ?? undefined; this.taskRunId = params.meta?.taskRunId; this.environment = params.meta?.environment; @@ -1159,6 +1162,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { async closeSession(): Promise { this.commandOutputs.clear(); + this.subagentParents.clear(); this.nativeGoalTurnId = undefined; this.session.abortController.abort(); this.session.cancelled = true; @@ -1178,23 +1182,25 @@ export class CodexAppServerAgent extends BaseAcpAgent { const notificationThreadId = readNotificationThreadId(params); const isMainThread = !notificationThreadId || notificationThreadId === this.threadId; + 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) { + void this.client + .sessionUpdate(visibleNotification) + .catch((err) => this.logger.warn("sessionUpdate failed", err)); + this.appendNotification(this.sessionId, visibleNotification); } } @@ -1307,6 +1313,87 @@ export class CodexAppServerAgent extends BaseAcpAgent { } } + private captureSubagentRelationship( + method: string, + params: unknown, + senderThreadId: string | undefined, + ): void { + if ( + method !== APP_SERVER_NOTIFICATIONS.ITEM_STARTED && + method !== APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED + ) { + return; + } + const item = (params as { item?: AppServerItem })?.item; + if ( + item?.type !== "collabAgentToolCall" || + item.tool !== "spawnAgent" || + !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); + } + } + + 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; + const update = notification.update as SessionNotification["update"] & { + _meta?: Record; + toolCallId?: string; + }; + if ( + update.sessionUpdate !== "agent_message_chunk" && + update.sessionUpdate !== "agent_thought_chunk" && + update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update" + ) { + return null; + } + 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; @@ -1718,6 +1805,10 @@ function readNotificationThreadId(params: unknown): string | undefined { return typeof threadId === "string" ? threadId : undefined; } +function subagentToolCallId(threadId: string, toolCallId: string): string { + return `subagent:${threadId}:${toolCallId}`; +} + /** 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/shared/src/index.ts b/packages/shared/src/index.ts index fe66950795..37a4640262 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -275,6 +275,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..cf3fbd61ef 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,25 @@ 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"); + }); +}); + 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..ef0d7bfef8 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,12 @@ 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); + return m?.posthog?.parentToolCallId ?? m?.claudeCode?.parentToolCallId; +} + /** * 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..5747935a04 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 { @@ -747,10 +748,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..d90366e7ec 100644 --- a/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts @@ -475,4 +475,34 @@ 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); + }); }); From 7b8a5838973c1d99a25bc20beac95ccfa2889b36 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Thu, 16 Jul 2026 14:52:50 -0400 Subject: [PATCH 2/6] fix(agent): preserve resumed subagent activity Generated-By: PostHog Code Task-Id: 38f87de0-3672-400f-af61-378f74c17f2b --- .../codex-app-server-agent.test.ts | 127 ++++++++++++++++++ .../codex-app-server-agent.ts | 22 ++- packages/shared/src/tool-meta.test.ts | 18 +++ packages/shared/src/tool-meta.ts | 5 +- .../components/buildConversationItems.ts | 17 +++ .../incrementalConversationItems.test.ts | 40 ++++++ 6 files changed, 227 insertions(+), 2 deletions(-) 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 34dec2522c..9b69db94d8 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 @@ -315,6 +315,86 @@ 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.each([ { label: "reads an empty goal", @@ -2477,6 +2557,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(); 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 7a4f2e48c6..4ce2ee0c30 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 @@ -518,6 +518,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); } @@ -1325,9 +1326,28 @@ export class CodexAppServerAgent extends BaseAcpAgent { return; } const item = (params as { item?: AppServerItem })?.item; + 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, + ): void { if ( item?.type !== "collabAgentToolCall" || - item.tool !== "spawnAgent" || + (item.tool !== "spawnAgent" && + item.tool !== "resumeAgent" && + item.tool !== "sendInput") || !item.id || !item.receiverThreadIds?.length ) { diff --git a/packages/shared/src/tool-meta.test.ts b/packages/shared/src/tool-meta.test.ts index cf3fbd61ef..77e0bdd02b 100644 --- a/packages/shared/src/tool-meta.test.ts +++ b/packages/shared/src/tool-meta.test.ts @@ -67,6 +67,24 @@ describe("readParentToolCallId", () => { }), ).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", () => { diff --git a/packages/shared/src/tool-meta.ts b/packages/shared/src/tool-meta.ts index ef0d7bfef8..afbfde8049 100644 --- a/packages/shared/src/tool-meta.ts +++ b/packages/shared/src/tool-meta.ts @@ -63,7 +63,10 @@ export function readAgentToolName(meta: unknown): string | undefined { /** Parent subagent tool call: neutral channel first, legacy fallback. */ export function readParentToolCallId(meta: unknown): string | undefined { const m = asToolCallMeta(meta); - return m?.posthog?.parentToolCallId ?? m?.claudeCode?.parentToolCallId; + 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; } /** diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 5747935a04..60cf5f5dd9 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -174,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]; @@ -186,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); } } } diff --git a/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts b/packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts index d90366e7ec..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) { @@ -505,4 +521,28 @@ describe("createIncrementalConversationBuilder", () => { } 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" }, + }); + }); }); From a1b390c24da71f49019813f195c684198a991b1d Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Thu, 16 Jul 2026 15:22:11 -0400 Subject: [PATCH 3/6] fix(agent): buffer early Codex subagent activity Preserve child-thread messages, reasoning, and tool activity that arrive before the parent collaboration call is observed. Generated-By: PostHog Code Task-Id: acb27526-5140-4f34-9024-1df8d5c241a8 --- .../codex-app-server-agent.test.ts | 56 +++++++++++ .../codex-app-server-agent.ts | 94 ++++++++++++++----- 2 files changed, 128 insertions(+), 22 deletions(-) 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 9b69db94d8..597a324d45 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 @@ -395,6 +395,62 @@ describe("CodexAppServerAgent", () => { }, ); + 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", 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 4ce2ee0c30..9670c5d698 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 @@ -238,6 +238,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { 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. */ @@ -463,6 +467,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { 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; @@ -1164,6 +1169,7 @@ 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; @@ -1183,7 +1189,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { const notificationThreadId = readNotificationThreadId(params); const isMainThread = !notificationThreadId || notificationThreadId === this.threadId; - this.captureSubagentRelationship(method, params, notificationThreadId); + const relatedSubagentThreadIds = this.captureSubagentRelationship( + method, + params, + notificationThreadId, + ); const mappedParams = isMainThread ? this.withBufferedCommandOutput(method, params) : params; @@ -1198,10 +1208,19 @@ export class CodexAppServerAgent extends BaseAcpAgent { ? notification : this.mapSubagentNotification(notification, notificationThreadId); if (visibleNotification) { - void this.client - .sessionUpdate(visibleNotification) - .catch((err) => this.logger.warn("sessionUpdate failed", err)); - this.appendNotification(this.sessionId, 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); } } @@ -1318,15 +1337,15 @@ export class CodexAppServerAgent extends BaseAcpAgent { method: string, params: unknown, senderThreadId: string | undefined, - ): void { + ): string[] { if ( method !== APP_SERVER_NOTIFICATIONS.ITEM_STARTED && method !== APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED ) { - return; + return []; } const item = (params as { item?: AppServerItem })?.item; - this.captureSubagentRelationshipItem(item, senderThreadId); + return this.captureSubagentRelationshipItem(item, senderThreadId); } private restoreSubagentRelationships( @@ -1342,7 +1361,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { private captureSubagentRelationshipItem( item: AppServerItem | undefined, senderThreadId: string | undefined, - ): void { + ): string[] { if ( item?.type !== "collabAgentToolCall" || (item.tool !== "spawnAgent" && @@ -1351,7 +1370,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { !item.id || !item.receiverThreadIds?.length ) { - return; + return []; } const parentToolCallId = senderThreadId && senderThreadId !== this.threadId @@ -1360,6 +1379,30 @@ export class CodexAppServerAgent extends BaseAcpAgent { 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( @@ -1369,18 +1412,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { if (!notification || !threadId) return null; const parentToolCallId = this.subagentParents.get(threadId); if (!parentToolCallId) return null; - const update = notification.update as SessionNotification["update"] & { - _meta?: Record; - toolCallId?: string; - }; - if ( - update.sessionUpdate !== "agent_message_chunk" && - update.sessionUpdate !== "agent_thought_chunk" && - update.sessionUpdate !== "tool_call" && - update.sessionUpdate !== "tool_call_update" - ) { - return null; - } + if (!isSubagentActivityNotification(notification)) return null; + const update = notification.update; const toolCallId = update.toolCallId ? subagentToolCallId(threadId, update.toolCallId) : undefined; @@ -1829,6 +1862,23 @@ 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, From 9f16aa499132012155f6f4b1eaca22f01410cc3e Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Thu, 16 Jul 2026 15:36:20 -0400 Subject: [PATCH 4/6] fix(codex): render plans in approval UI Generated-By: PostHog Code Task-Id: 2cd7c8b3-ddf5-4e4c-994d-1f5bbafabff0 --- .../codex-app-server-agent.test.ts | 84 ++++++++++++++++++- .../codex-app-server-agent.ts | 53 +++++++++++- .../adapters/codex-app-server/mapping.test.ts | 10 +-- .../src/adapters/codex-app-server/mapping.ts | 15 +--- .../src/adapters/codex-app-server/protocol.ts | 4 +- .../session-update/PlanApprovalView.test.tsx | 9 ++ .../session-update/PlanApprovalView.tsx | 7 +- 7 files changed, 151 insertions(+), 31 deletions(-) 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 23355aee71..92db215610 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 @@ -2757,7 +2757,10 @@ 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 } = {}, + ) { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } }, "turn/start": { turn: { id: "turn_1" } }, @@ -2770,7 +2773,15 @@ describe("CodexAppServerAgent", () => { }> = []; const client = { sessionUpdate: async (n: unknown) => { - sessionUpdates.push(n as { update?: Record }); + const notification = n as { update?: Record }; + if ( + options.rejectPlanToolUpdates && + (notification.update?.sessionUpdate === "tool_call" || + notification.update?.sessionUpdate === "tool_call_update") + ) { + throw new Error("renderer disconnected"); + } + sessionUpdates.push(notification); }, requestPermission: async (params: { toolCall: Record; @@ -2898,6 +2909,42 @@ 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\n1. do it" }, + }, + ], + rawInput: { plan: "# The plan\n\n1. do it" }, + status: "in_progress", + }, + }); + 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( @@ -2924,7 +2971,7 @@ describe("CodexAppServerAgent", () => { }); 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); @@ -2938,12 +2985,43 @@ 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("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 8b1ce61b89..3afe701e93 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 @@ -1011,6 +1011,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { ], rawInput: { plan: proposal.text }, }; + await this.emitPlanApprovalToolCall({ + sessionUpdate: "tool_call", + ...toolCall, + status: "in_progress", + }); const options = [ { optionId: "auto", @@ -1053,8 +1058,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { }); const settled = await Promise.race([permission, cancelled]); this.planHandoffCancel = undefined; - if (!settled) return { kind: "stay" }; + if (!settled) { + await this.completePlanApprovalToolCall(toolCallId, "failed"); + return { kind: "stay" }; + } if (settled.failed) { + await this.completePlanApprovalToolCall(toolCallId, "failed"); this.logger.warn("plan implementation prompt failed; staying in plan", { error: String(settled.err), }); @@ -1066,25 +1075,63 @@ export class CodexAppServerAgent extends BaseAcpAgent { } const response = settled.res; if (this.session.cancelled || response.outcome.outcome !== "selected") { + await 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)) { + await this.completePlanApprovalToolCall(toolCallId, "failed"); + return { kind: "stay" }; + } + if (optionId === "auto") { + await this.completePlanApprovalToolCall(toolCallId, "completed"); + return { kind: "implement", mode: "auto" }; + } // Double-gated: only ever offered under ALLOW_BYPASS, and re-checked here. if (optionId === "full-access" && ALLOW_BYPASS) { + await this.completePlanApprovalToolCall(toolCallId, "completed"); return { kind: "implement", mode: "full-access" }; } if (optionId === "reject_with_feedback") { const feedback = (response as { _meta?: { customInput?: unknown } })._meta ?.customInput; if (typeof feedback === "string" && feedback.trim()) { + await this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "feedback", feedback: feedback.trim() }; } } + await this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } + private async completePlanApprovalToolCall( + toolCallId: string, + status: "completed" | "failed", + ): Promise { + await this.emitPlanApprovalToolCall({ + sessionUpdate: "tool_call_update", + toolCallId, + status, + }); + } + + private async emitPlanApprovalToolCall( + update: Record, + ): Promise { + try { + await this.client.sessionUpdate({ + sessionId: this.sessionId, + update, + } as unknown as Parameters[0]); + } catch (error) { + this.logger.warn("Failed to emit plan approval tool call update", { + error: String(error), + sessionUpdate: update.sessionUpdate, + toolCallId: update.toolCallId, + }); + } + } + /** Emit a plain agent message (user-facing status the model didn't produce). */ private broadcastAgentText(text: string): void { if (!this.sessionId) return; 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..5e46a17c09 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", () => { diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index ac10a3a266..0c83934100 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); 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/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index f9b86585bb..6d3c011c56 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 @@ -84,6 +84,15 @@ describe("PlanApprovalView", () => { expect(screen.getByText(PLAN_MARKER)).toBeInTheDocument(); }); + it("shows the rejected status when the plan tool call fails", () => { + renderView({ toolCall: makeToolCall({ status: "failed" }) }); + + expect(screen.getByText(/\(plan rejected\)/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /show plan/i }), + ).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..e2ef49712f 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, @@ -45,7 +45,8 @@ export function PlanApprovalView({ return null; }, [content, toolCall.rawInput]); - const showResult = isComplete || wasCancelled; + const wasRejected = isFailed || wasCancelled; + const showResult = isComplete || wasRejected; const canTogglePlan = showResult && !!planText; const planContentId = `plan-content-${toolCall.toolCallId}`; @@ -58,7 +59,7 @@ export function PlanApprovalView({ Plan approved — proceeding with implementation - ) : wasCancelled ? ( + ) : wasRejected ? ( (Plan rejected) ) : null; From 5b5e747599e4dabeb6f3495f95a03ad4c0db0a8c Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Thu, 16 Jul 2026 15:52:55 -0400 Subject: [PATCH 5/6] fix(codex): harden plan approval lifecycle Generated-By: PostHog Code Task-Id: 2cd7c8b3-ddf5-4e4c-994d-1f5bbafabff0 --- .../codex-app-server-agent.test.ts | 55 ++++++- .../codex-app-server-agent.ts | 142 ++++++++++++------ .../adapters/codex-app-server/mapping.test.ts | 16 +- .../src/adapters/codex-app-server/mapping.ts | 32 ++-- .../session-update/PlanApprovalView.test.tsx | 43 +++++- .../session-update/PlanApprovalView.tsx | 40 ++--- 6 files changed, 244 insertions(+), 84 deletions(-) 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 92db215610..2e9c4ac2f6 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 @@ -2759,7 +2759,10 @@ describe("CodexAppServerAgent", () => { // permissionOutcome may be a value or a function (per-call, e.g. a pending promise). function makePlanAgent( permissionOutcome: unknown, - options: { rejectPlanToolUpdates?: boolean } = {}, + options: { + rejectPlanToolUpdates?: boolean; + stallPlanToolUpdates?: boolean; + } = {}, ) { const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } }, @@ -2775,11 +2778,15 @@ describe("CodexAppServerAgent", () => { sessionUpdate: async (n: unknown) => { const notification = n as { update?: Record }; if ( - options.rejectPlanToolUpdates && - (notification.update?.sessionUpdate === "tool_call" || - notification.update?.sessionUpdate === "tool_call_update") + notification.update?.sessionUpdate === "tool_call" || + notification.update?.sessionUpdate === "tool_call_update" ) { - throw new Error("renderer disconnected"); + if (options.rejectPlanToolUpdates) { + throw new Error("renderer disconnected"); + } + if (options.stallPlanToolUpdates) { + return new Promise(() => {}); + } } sessionUpdates.push(notification); }, @@ -2916,6 +2923,22 @@ describe("CodexAppServerAgent", () => { 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", @@ -2923,7 +2946,6 @@ describe("CodexAppServerAgent", () => { }, ], rawInput: { plan: "# The plan\n\n1. do it" }, - status: "in_progress", }, }); expect(sessionUpdates).toContainEqual({ @@ -3022,6 +3044,27 @@ describe("CodexAppServerAgent", () => { ); }); + 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 3afe701e93..774955ea99 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 @@ -242,6 +242,8 @@ export class CodexAppServerAgent extends BaseAcpAgent { 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. */ @@ -879,6 +881,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(); @@ -936,13 +939,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(); @@ -994,28 +1006,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 }, - }; - await this.emitPlanApprovalToolCall({ - sessionUpdate: "tool_call", - ...toolCall, - status: "in_progress", - }); + const toolCall = this.buildPlanApprovalToolCall(proposal); + this.emitPlanProposal(toolCall, proposal.text); const options = [ { optionId: "auto", @@ -1059,11 +1060,11 @@ export class CodexAppServerAgent extends BaseAcpAgent { const settled = await Promise.race([permission, cancelled]); this.planHandoffCancel = undefined; if (!settled) { - await this.completePlanApprovalToolCall(toolCallId, "failed"); + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } if (settled.failed) { - await this.completePlanApprovalToolCall(toolCallId, "failed"); + this.completePlanApprovalToolCall(toolCallId, "failed"); this.logger.warn("plan implementation prompt failed; staying in plan", { error: String(settled.err), }); @@ -1075,61 +1076,106 @@ export class CodexAppServerAgent extends BaseAcpAgent { } const response = settled.res; if (this.session.cancelled || response.outcome.outcome !== "selected") { - await this.completePlanApprovalToolCall(toolCallId, "failed"); + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } const optionId = response.outcome.optionId; if (!offered.has(optionId)) { - await this.completePlanApprovalToolCall(toolCallId, "failed"); + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } if (optionId === "auto") { - await this.completePlanApprovalToolCall(toolCallId, "completed"); - return { kind: "implement", mode: "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) { - await this.completePlanApprovalToolCall(toolCallId, "completed"); - 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()) { - await this.completePlanApprovalToolCall(toolCallId, "failed"); + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "feedback", feedback: feedback.trim() }; } } - await this.completePlanApprovalToolCall(toolCallId, "failed"); + this.completePlanApprovalToolCall(toolCallId, "failed"); return { kind: "stay" }; } - private async completePlanApprovalToolCall( + 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", - ): Promise { - await this.emitPlanApprovalToolCall({ + ): void { + this.emitPlanApprovalToolCall({ sessionUpdate: "tool_call_update", toolCallId, status, }); } - private async emitPlanApprovalToolCall( - update: Record, - ): Promise { - try { - await this.client.sessionUpdate({ - sessionId: this.sessionId, - update, - } as unknown as Parameters[0]); - } catch (error) { + private emitPlanApprovalToolCall(update: Record): void { + const notification = { + sessionId: this.sessionId, + update, + } as unknown as Parameters[0]; + this.appendNotification(this.sessionId, 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, }); - } + }); } /** Emit a plain agent message (user-facing status the model didn't produce). */ @@ -1414,6 +1460,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { )?.item; if (item?.type === "plan" && typeof item.text === "string" && item.text) { this.planProposal = { itemId: item.id ?? "codex-plan", text: item.text }; + if (this.config.mode === "plan" && this.streamedPlanToolCallId) { + this.emitPlanProposal( + this.buildPlanApprovalToolCall(this.planProposal), + this.planProposal.text, + ); + } } } @@ -1431,6 +1483,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { const previousText = this.planProposal?.itemId === proposalId ? this.planProposal.text : ""; this.planProposal = { itemId: proposalId, text: previousText + delta }; + 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). */ 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 5e46a17c09..a44aa3b927 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -603,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 0c83934100..9458dbb019 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -267,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/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index 6d3c011c56..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,29 +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 rejected status when the plan tool call fails", () => { + it("shows the not-approved status when the plan tool call fails", () => { renderView({ toolCall: makeToolCall({ status: "failed" }) }); - expect(screen.getByText(/\(plan rejected\)/i)).toBeInTheDocument(); + 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 e2ef49712f..b70c281953 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -27,40 +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 wasRejected = isFailed || wasCancelled; - const showResult = isComplete || wasRejected; + 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 - ) : wasRejected ? ( - (Plan rejected) + ) : wasNotApproved ? ( + (Plan not approved) ) : null; return ( From 674edb13e43b8d39249d1975fc391f58b150b7c2 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Thu, 16 Jul 2026 16:07:09 -0400 Subject: [PATCH 6/6] fix(codex): bound streamed plan snapshots Generated-By: PostHog Code Task-Id: 7305b2f0-aa76-413e-b0ea-a2078250be66 --- .../codex-app-server-agent.test.ts | 58 +++++++++++++++++++ .../codex-app-server-agent.ts | 46 ++++++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) 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 2e9c4ac2f6..e7a289838a 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 @@ -2992,6 +2992,64 @@ 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, sessionUpdates, 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 774955ea99..7c16302caf 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 @@ -115,6 +115,8 @@ type GoalCommand = | { kind: "resume" } | { kind: "set"; objective: string }; +const MAX_PLAN_PROPOSAL_CHARS = 100_000; + type CodexSkill = { name?: string; description?: string; @@ -1168,7 +1170,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { sessionId: this.sessionId, update, } as unknown as Parameters[0]; - this.appendNotification(this.sessionId, notification); + this.appendPlanApprovalNotification(notification); void this.client.sessionUpdate(notification).catch((error) => { this.logger.warn("Failed to emit plan approval tool call update", { error: String(error), @@ -1178,6 +1180,36 @@ export class CodexAppServerAgent extends BaseAcpAgent { }); } + 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; @@ -1459,7 +1491,10 @@ 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), @@ -1482,7 +1517,12 @@ 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),