diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 70c3131..fb6198d 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -18,6 +18,8 @@ import type { ItemCompletedNotification, ItemStartedNotification, ThreadItem, ModelReroutedNotification, + ThreadGoalClearedNotification, + ThreadGoalUpdatedNotification, ThreadTokenUsageUpdatedNotification, TurnPlanUpdatedNotification, WarningNotification @@ -138,6 +140,10 @@ export class CodexEventHandler { return this.handleFuzzyFileSearchSessionUpdated(notification.params); case "fuzzyFileSearch/sessionCompleted": return this.handleFuzzyFileSearchSessionCompleted(notification.params); + case "thread/goal/updated": + return this.createThreadGoalUpdatedEvent(notification.params); + case "thread/goal/cleared": + return this.createThreadGoalClearedEvent(notification.params); // ignored events case "command/exec/outputDelta": case "item/autoApprovalReview/started": @@ -174,8 +180,6 @@ export class CodexEventHandler { case "rawResponseItem/completed": case "thread/started": case "item/plan/delta": - case "thread/goal/updated": - case "thread/goal/cleared": case "remoteControl/status/changed": case "app/list/updated": case "thread/settings/updated": @@ -245,6 +249,48 @@ export class CodexEventHandler { }; } + private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent { + const status = this.formatThreadGoalStatus(event.goal.status); + const objective = event.goal.objective.trim(); + const text = objective.includes("\n") + ? `Goal updated (${status}):\n${objective}` + : `Goal updated (${status}): ${objective}`; + return { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text, + }, + }; + } + + private formatThreadGoalStatus(status: ThreadGoalUpdatedNotification["goal"]["status"]): string { + switch (status) { + case "active": + return "active"; + case "paused": + return "paused"; + case "budgetLimited": + return "budget limited"; + case "blocked": + return "blocked"; + case "usageLimited": + return "usage limited"; + case "complete": + return "complete"; + } + } + + private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent { + return { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "Goal cleared.", + }, + }; + } + private async createItemEvent(event: ItemStartedNotification): Promise { switch (event.item.type) { case "fileChange": diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json b/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json new file mode 100644 index 0000000..bfd4ac7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/thread-goal-cleared.json @@ -0,0 +1,15 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Goal cleared." + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json new file mode 100644 index 0000000..79c6d2c --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated-multiline.json @@ -0,0 +1,15 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Goal updated (budget limited):\nFirst task\nSecond task" + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/thread-goal-updated.json b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json new file mode 100644 index 0000000..19e32e9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/thread-goal-updated.json @@ -0,0 +1,15 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Goal updated (active): Ship the goal update" + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/thread-goal-events.test.ts b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts new file mode 100644 index 0000000..d121cf8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/thread-goal-events.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { SessionState } from "../../CodexAcpServer"; +import type { ServerNotification } from "../../app-server"; +import { AgentMode } from "../../AgentMode"; +import { + createCodexMockTestFixture, + createTestSessionState, + setupPromptAndSendNotifications, + type CodexMockTestFixture, +} from "../acp-test-utils"; + +describe("CodexEventHandler - thread goal events", () => { + 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("should send thread goal updates as agent messages", async () => { + const goalUpdatedNotification: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: "turn-1", + goal: { + threadId: sessionId, + objective: "Ship the goal update", + status: "active", + tokenBudget: null, + tokensUsed: 42, + timeUsedSeconds: 12, + createdAt: 1710000000, + updatedAt: 1710000012, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/thread-goal-updated.json" + ); + }); + + it("should format multiline thread goal updates", async () => { + const goalUpdatedNotification: ServerNotification = { + method: "thread/goal/updated", + params: { + threadId: sessionId, + turnId: null, + goal: { + threadId: sessionId, + objective: " First task\nSecond task\n ", + status: "budgetLimited", + tokenBudget: 1000, + tokensUsed: 1000, + timeUsedSeconds: 30, + createdAt: 1710000000, + updatedAt: 1710000030, + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/thread-goal-updated-multiline.json" + ); + }); + + it("should send thread goal cleared as an agent message", async () => { + const goalClearedNotification: ServerNotification = { + method: "thread/goal/cleared", + params: { + threadId: sessionId, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalClearedNotification]); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + "data/thread-goal-cleared.json" + ); + }); +});