From fcedd561613ff6b92ab3e73a76cf6d7afe354495 Mon Sep 17 00:00:00 2001 From: quabug Date: Fri, 11 Sep 2026 03:26:13 -0700 Subject: [PATCH] fix: emit completed messages when providers omit deltas --- src/CodexEventHandler.ts | 14 ++- .../agent-message-events.test.ts | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 7b567541..1407e91d 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(); @@ -623,6 +624,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)); } @@ -745,7 +747,17 @@ export class CodexEventHandler { return this.subagents.legacyCollaborationStarted(event.item); case "agentMessage": this.rememberAgentMessagePhase(event.item); - return null; + if (this.seenAgentMessageDeltaItemIds.delete(event.item.id)) { + return null; + } + if (event.item.text.length === 0) { + return null; + } + return createAgentTextMessageChunk( + event.item.text, + event.item.id, + createCodexMessagePhaseMeta(event.item.phase), + ); case "contextCompaction": return createContextCompactionStartUpdate(event.item); case "subAgentActivity": diff --git a/src/__tests__/CodexACPAgent/agent-message-events.test.ts b/src/__tests__/CodexACPAgent/agent-message-events.test.ts index 732126cd..57fa5ebd 100644 --- a/src/__tests__/CodexACPAgent/agent-message-events.test.ts +++ b/src/__tests__/CodexACPAgent/agent-message-events.test.ts @@ -86,4 +86,102 @@ describe("CodexEventHandler - agent message events", () => { "data/agent-message-phases.json" ); }); + + it("emits completed agent text when a provider omits message deltas", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "agentMessage", + id: "completed-only-message", + text: "Ark-compatible final answer.", + phase: "final_answer", + memoryCitation: null, + delivery: null, + questions: null, + }, + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 1, + item: { + type: "agentMessage", + id: "completed-only-message", + text: "Ark-compatible final answer.", + phase: "final_answer", + memoryCitation: null, + delivery: null, + questions: null, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + const dump = mockFixture.getAcpConnectionDump([]); + expect(dump).toContain("Ark-compatible final answer."); + expect(dump.match(/Ark-compatible final answer\./g)).toHaveLength(1); + }); + + it("does not duplicate completed text after streamed deltas", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-1", + startedAtMs: 0, + item: { + type: "agentMessage", + id: "streamed-message", + text: "", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + }, + }, + { + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "streamed-message", + delta: "Streamed once.", + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 1, + item: { + type: "agentMessage", + id: "streamed-message", + text: "Streamed once.", + phase: null, + memoryCitation: null, + delivery: null, + questions: null, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + const dump = mockFixture.getAcpConnectionDump([]); + expect(dump.match(/Streamed once\./g)).toHaveLength(1); + }); });