Skip to content

Commit 9f0946b

Browse files
authored
Report thread goal updates as agent messages (#189)
1 parent 28ff383 commit 9f0946b

5 files changed

Lines changed: 186 additions & 2 deletions

File tree

src/CodexEventHandler.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type {
2121
ItemStartedNotification,
2222
ThreadItem,
2323
ModelReroutedNotification,
24+
ThreadGoalClearedNotification,
25+
ThreadGoalUpdatedNotification,
2426
ThreadTokenUsageUpdatedNotification,
2527
TurnPlanUpdatedNotification,
2628
WarningNotification
@@ -148,6 +150,10 @@ export class CodexEventHandler {
148150
return this.handleFuzzyFileSearchSessionUpdated(notification.params);
149151
case "fuzzyFileSearch/sessionCompleted":
150152
return this.handleFuzzyFileSearchSessionCompleted(notification.params);
153+
case "thread/goal/updated":
154+
return this.createThreadGoalUpdatedEvent(notification.params);
155+
case "thread/goal/cleared":
156+
return this.createThreadGoalClearedEvent(notification.params);
151157
// ignored events
152158
case "command/exec/outputDelta":
153159
case "hook/started":
@@ -182,8 +188,6 @@ export class CodexEventHandler {
182188
case "rawResponseItem/completed":
183189
case "thread/started":
184190
case "item/plan/delta":
185-
case "thread/goal/updated":
186-
case "thread/goal/cleared":
187191
case "remoteControl/status/changed":
188192
case "app/list/updated":
189193
case "thread/settings/updated":
@@ -253,6 +257,48 @@ export class CodexEventHandler {
253257
};
254258
}
255259

260+
private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent {
261+
const status = this.formatThreadGoalStatus(event.goal.status);
262+
const objective = event.goal.objective.trim();
263+
const text = objective.includes("\n")
264+
? `Goal updated (${status}):\n${objective}`
265+
: `Goal updated (${status}): ${objective}`;
266+
return {
267+
sessionUpdate: "agent_message_chunk",
268+
content: {
269+
type: "text",
270+
text,
271+
},
272+
};
273+
}
274+
275+
private formatThreadGoalStatus(status: ThreadGoalUpdatedNotification["goal"]["status"]): string {
276+
switch (status) {
277+
case "active":
278+
return "active";
279+
case "paused":
280+
return "paused";
281+
case "budgetLimited":
282+
return "budget limited";
283+
case "blocked":
284+
return "blocked";
285+
case "usageLimited":
286+
return "usage limited";
287+
case "complete":
288+
return "complete";
289+
}
290+
}
291+
292+
private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent {
293+
return {
294+
sessionUpdate: "agent_message_chunk",
295+
content: {
296+
type: "text",
297+
text: "Goal cleared.",
298+
},
299+
};
300+
}
301+
256302
private async createItemEvent(event: ItemStartedNotification): Promise<UpdateSessionEvent | null> {
257303
switch (event.item.type) {
258304
case "fileChange":
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "agent_message_chunk",
8+
"content": {
9+
"type": "text",
10+
"text": "Goal cleared."
11+
}
12+
}
13+
}
14+
]
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "agent_message_chunk",
8+
"content": {
9+
"type": "text",
10+
"text": "Goal updated (budget limited):\nFirst task\nSecond task"
11+
}
12+
}
13+
}
14+
]
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "agent_message_chunk",
8+
"content": {
9+
"type": "text",
10+
"text": "Goal updated (active): Ship the goal update"
11+
}
12+
}
13+
}
14+
]
15+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import type { SessionState } from "../../CodexAcpServer";
3+
import type { ServerNotification } from "../../app-server";
4+
import { AgentMode } from "../../AgentMode";
5+
import {
6+
createCodexMockTestFixture,
7+
createTestSessionState,
8+
setupPromptAndSendNotifications,
9+
type CodexMockTestFixture,
10+
} from "../acp-test-utils";
11+
12+
describe("CodexEventHandler - thread goal events", () => {
13+
let mockFixture: CodexMockTestFixture;
14+
const sessionId = "test-session-id";
15+
16+
beforeEach(() => {
17+
mockFixture = createCodexMockTestFixture();
18+
vi.clearAllMocks();
19+
});
20+
21+
const sessionState: SessionState = createTestSessionState({
22+
sessionId,
23+
currentModelId: "model-id[effort]",
24+
agentMode: AgentMode.DEFAULT_AGENT_MODE,
25+
});
26+
27+
it("should send thread goal updates as agent messages", async () => {
28+
const goalUpdatedNotification: ServerNotification = {
29+
method: "thread/goal/updated",
30+
params: {
31+
threadId: sessionId,
32+
turnId: "turn-1",
33+
goal: {
34+
threadId: sessionId,
35+
objective: "Ship the goal update",
36+
status: "active",
37+
tokenBudget: null,
38+
tokensUsed: 42,
39+
timeUsedSeconds: 12,
40+
createdAt: 1710000000,
41+
updatedAt: 1710000012,
42+
},
43+
},
44+
};
45+
46+
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]);
47+
48+
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
49+
"data/thread-goal-updated.json"
50+
);
51+
});
52+
53+
it("should format multiline thread goal updates", async () => {
54+
const goalUpdatedNotification: ServerNotification = {
55+
method: "thread/goal/updated",
56+
params: {
57+
threadId: sessionId,
58+
turnId: null,
59+
goal: {
60+
threadId: sessionId,
61+
objective: " First task\nSecond task\n ",
62+
status: "budgetLimited",
63+
tokenBudget: 1000,
64+
tokensUsed: 1000,
65+
timeUsedSeconds: 30,
66+
createdAt: 1710000000,
67+
updatedAt: 1710000030,
68+
},
69+
},
70+
};
71+
72+
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]);
73+
74+
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
75+
"data/thread-goal-updated-multiline.json"
76+
);
77+
});
78+
79+
it("should send thread goal cleared as an agent message", async () => {
80+
const goalClearedNotification: ServerNotification = {
81+
method: "thread/goal/cleared",
82+
params: {
83+
threadId: sessionId,
84+
},
85+
};
86+
87+
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalClearedNotification]);
88+
89+
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
90+
"data/thread-goal-cleared.json"
91+
);
92+
});
93+
});

0 commit comments

Comments
 (0)