diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 5ed8ad79..efae65f0 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -72,6 +72,7 @@ export class CodexEventHandler { private readonly activeImageGenerationItems = new Set(); private readonly emittedImageViewItems = new Set(); private readonly seenReasoningDeltaItemIds = new Set(); + private readonly seenAgentMessageDeltaItemIds = new Set(); private readonly terminalCommandIds = new Set(); private readonly terminalCommandOutputIds = new Set(); @@ -230,6 +231,7 @@ export class CodexEventHandler { } private async createTextEvent(event: AgentMessageDeltaNotification): Promise { + this.seenAgentMessageDeltaItemIds.add(event.itemId); return createAgentTextMessageChunk(event.delta, event.itemId); } @@ -387,12 +389,21 @@ export class CodexEventHandler { return this.createExitedReviewModeEvent(event.item); case "contextCompaction": return this.createContextCompactedEvent(); + case "agentMessage": { + if (this.seenAgentMessageDeltaItemIds.delete(event.item.id)) { + return null; + } + const text = event.item.text; + if (text.length > 0) { + return createAgentTextMessageChunk(text, event.item.id); + } + return null; + } //ignored types case "subAgentActivity": case "sleep": case "userMessage": case "hookPrompt": - case "agentMessage": case "enteredReviewMode": case "plan": return null; diff --git a/src/__tests__/CodexACPAgent/agent-message-fallback.test.ts b/src/__tests__/CodexACPAgent/agent-message-fallback.test.ts new file mode 100644 index 00000000..b9452d8e --- /dev/null +++ b/src/__tests__/CodexACPAgent/agent-message-fallback.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServerNotification } from "../../app-server"; +import type { SessionState } from "../../CodexAcpServer"; +import { AgentMode } from "../../AgentMode"; +import { + createCodexMockTestFixture, + createTestSessionState, + setupPromptAndSendNotifications, + type CodexMockTestFixture +} from "../acp-test-utils"; + +describe("CodexEventHandler - agent message fallback", () => { + let mockFixture: CodexMockTestFixture; + const sessionId = "test-session-id"; + + beforeEach(() => { + mockFixture = createCodexMockTestFixture(); + vi.clearAllMocks(); + }); + + const sessionState: SessionState = createTestSessionState({ + sessionId, + currentModelId: "model-id[effort]", + agentMode: AgentMode.DEFAULT_AGENT_MODE + }); + + it("emits agent message text from item/completed when no deltas streamed", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "agentMessage", + id: "msg-1", + text: "Hello!", + phase: null, + memoryCitation: null, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/agent-message-completed-fallback.json" + ); + }); + + it("does not duplicate agent message when deltas were streamed", async () => { + const notifications: ServerNotification[] = [ + { + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "msg-2", + delta: "Hel", + }, + }, + { + method: "item/agentMessage/delta", + params: { + threadId: sessionId, + turnId: "turn-1", + itemId: "msg-2", + delta: "lo!", + }, + }, + { + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-1", + completedAtMs: 0, + item: { + type: "agentMessage", + id: "msg-2", + text: "Hello!", + phase: null, + memoryCitation: null, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/agent-message-deltas-no-duplicate.json" + ); + }); +}); diff --git a/src/__tests__/CodexACPAgent/data/agent-message-completed-fallback.json b/src/__tests__/CodexACPAgent/data/agent-message-completed-fallback.json new file mode 100644 index 00000000..d33f9359 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/agent-message-completed-fallback.json @@ -0,0 +1,16 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "msg-1", + "content": { + "type": "text", + "text": "Hello!" + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/agent-message-deltas-no-duplicate.json b/src/__tests__/CodexACPAgent/data/agent-message-deltas-no-duplicate.json new file mode 100644 index 00000000..15f8da98 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/agent-message-deltas-no-duplicate.json @@ -0,0 +1,32 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "msg-2", + "content": { + "type": "text", + "text": "Hel" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "msg-2", + "content": { + "type": "text", + "text": "lo!" + } + } + } + ] +} \ No newline at end of file