diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 7b567541..788eddb5 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -223,6 +223,7 @@ export class CodexEventHandler { private planUpdateChain: Promise = Promise.resolve(); private disposed = false; private readonly seenReasoningDeltaItemIds = new Set(); + private readonly seenAgentMessageDeltaItemIds = new Set(); private readonly terminalCommandIds = new Set(); private readonly terminalCommandOutputIds = new Set(); private readonly agentMessagePhases = new Map(); @@ -448,6 +449,7 @@ export class CodexEventHandler { this.pendingPlanItemIds.clear(); this.planDeltaTextByItemId.clear(); this.lastEmittedPlanTextByItemId.clear(); + this.seenAgentMessageDeltaItemIds.clear(); } private async createUpdateEvent(notification: ServerNotification): Promise { @@ -623,6 +625,7 @@ export class CodexEventHandler { } private async createTextEvent(event: AgentMessageDeltaNotification): Promise { + this.seenAgentMessageDeltaItemIds.add(event.itemId); const phase = this.agentMessagePhases.get(event.itemId) ?? null; return createAgentTextMessageChunk(event.delta, event.itemId, createCodexMessagePhaseMeta(phase)); } @@ -802,6 +805,26 @@ export class CodexEventHandler { return this.subagents.legacyCollaborationCompleted(event.item); case "agentMessage": this.rememberAgentMessagePhase(event.item); + if (event.item.delivery === "async" && event.item.questions !== null) { + const streamed = this.seenAgentMessageDeltaItemIds.delete(event.item.id); + return createAgentTextMessageChunk( + streamed ? "" : event.item.text, + event.item.id, + { + codex: { + ...(event.item.phase ? {phase: event.item.phase} : {}), + asyncUserInput: { + delivery: event.item.delivery, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.item.id, + questions: event.item.questions, + }, + }, + }, + ); + } + this.seenAgentMessageDeltaItemIds.delete(event.item.id); return null; case "plan": { const deltaText = this.planDeltaTextByItemId.get(event.item.id) ?? ""; diff --git a/src/__tests__/CodexACPAgent/agent-message-events.test.ts b/src/__tests__/CodexACPAgent/agent-message-events.test.ts index 732126cd..ec907076 100644 --- a/src/__tests__/CodexACPAgent/agent-message-events.test.ts +++ b/src/__tests__/CodexACPAgent/agent-message-events.test.ts @@ -86,4 +86,128 @@ describe("CodexEventHandler - agent message events", () => { "data/agent-message-phases.json" ); }); + + it("publishes async user input metadata once without repeating streamed question text", async () => { + const question = { + type: "agentMessage" as const, + id: "async-question-streamed", + text: "Choose a lane\n- Stable\n- Preview", + phase: "final_answer" as const, + memoryCitation: null, + delivery: "async" as const, + questions: [{title: "Choose a lane", options: ["Stable", "Preview"]}], + }; + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: {threadId: sessionId, turnId: "turn-async", startedAtMs: 1, item: question}, + }, + { + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-async", + itemId: question.id, + delta: question.text, + }, + }, + { + method: "item/completed", + params: {threadId: sessionId, turnId: "turn-async", completedAtMs: 2, item: question}, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + expect(mockFixture.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate")) + .toEqual([ + { + method: "sessionUpdate", + args: [{ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: question.id, + content: {type: "text", text: question.text}, + _meta: {codex: {phase: "final_answer"}}, + }, + }], + }, + { + method: "sessionUpdate", + args: [{ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: question.id, + content: {type: "text", text: ""}, + _meta: { + codex: { + phase: "final_answer", + asyncUserInput: { + delivery: "async", + threadId: sessionId, + turnId: "turn-async", + itemId: question.id, + questions: [{title: "Choose a lane", options: ["Stable", "Preview"]}], + }, + }, + }, + }, + }], + }, + ]); + }); + + it("publishes a question-only completed async item when no text delta arrived", async () => { + const question = { + type: "agentMessage" as const, + id: "async-question-completed-only", + text: "What should change?", + phase: "final_answer" as const, + memoryCitation: null, + delivery: "async" as const, + questions: [{title: "What should change?", options: null}], + }; + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [ + { + method: "item/started", + params: {threadId: sessionId, turnId: "turn-completed-only", startedAtMs: 2, item: question}, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-completed-only", + completedAtMs: 3, + item: question, + }, + }, + ]); + + expect(mockFixture.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate")) + .toEqual([{ + method: "sessionUpdate", + args: [{ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: question.id, + content: {type: "text", text: "What should change?"}, + _meta: { + codex: { + phase: "final_answer", + asyncUserInput: { + delivery: "async", + threadId: sessionId, + turnId: "turn-completed-only", + itemId: question.id, + questions: [{title: "What should change?", options: null}], + }, + }, + }, + }, + }], + }]); + }); });