Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
ItemCompletedNotification,
ItemStartedNotification, ThreadItem,
ModelReroutedNotification,
ThreadGoalClearedNotification,
ThreadGoalUpdatedNotification,
ThreadTokenUsageUpdatedNotification,
TurnPlanUpdatedNotification,
WarningNotification
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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<UpdateSessionEvent | null> {
switch (event.item.type) {
case "fileChange":
Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/CodexACPAgent/data/thread-goal-cleared.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "test-session-id",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "Goal cleared."
}
}
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
]
}
15 changes: 15 additions & 0 deletions src/__tests__/CodexACPAgent/data/thread-goal-updated.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
]
}
93 changes: 93 additions & 0 deletions src/__tests__/CodexACPAgent/thread-goal-events.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
Loading