Skip to content

Commit 947d2eb

Browse files
committed
Suppress multiple identical notifications
1 parent d743136 commit 947d2eb

3 files changed

Lines changed: 111 additions & 13 deletions

File tree

src/CodexAcpServer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ import packageJson from "../package.json";
6666
import {isJetBrains2026_1Client} from "./JBUtils";
6767
import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode";
6868

69+
export interface ThreadGoalSnapshot {
70+
objective: string;
71+
status: string;
72+
tokenBudget: number | null;
73+
}
74+
6975
export interface SessionState {
7076
sessionId: string,
7177
currentModelId: string,
@@ -85,6 +91,7 @@ export interface SessionState {
8591
currentModelSupportsFast: boolean;
8692
sessionMcpServers?: Array<string>;
8793
terminalOutputMode: TerminalOutputMode;
94+
currentGoal?: ThreadGoalSnapshot | null;
8895
}
8996

9097
interface PendingMcpStartupSession {

src/CodexEventHandler.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
FuzzyFileSearchSessionUpdatedNotification,
44
ServerNotification
55
} from "./app-server";
6-
import type {SessionState} from "./CodexAcpServer";
6+
import type {SessionState, ThreadGoalSnapshot} from "./CodexAcpServer";
77
import * as acp from "@agentclientprotocol/sdk";
88
import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk";
99
import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection";
@@ -265,9 +265,15 @@ export class CodexEventHandler {
265265
};
266266
}
267267

268-
private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent {
268+
private createThreadGoalUpdatedEvent(event: ThreadGoalUpdatedNotification): UpdateSessionEvent | null {
269+
const goalSnapshot = this.createThreadGoalSnapshot(event);
270+
if (this.sameThreadGoalSnapshot(this.sessionState.currentGoal, goalSnapshot)) {
271+
return null;
272+
}
273+
this.sessionState.currentGoal = goalSnapshot;
274+
269275
const status = this.formatThreadGoalStatus(event.goal.status);
270-
const objective = event.goal.objective.trim();
276+
const objective = goalSnapshot.objective;
271277
const text = objective.includes("\n")
272278
? `Goal updated (${status}):\n${objective}`
273279
: `Goal updated (${status}): ${objective}`;
@@ -297,7 +303,12 @@ export class CodexEventHandler {
297303
}
298304
}
299305

300-
private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent {
306+
private createThreadGoalClearedEvent(_event: ThreadGoalClearedNotification): UpdateSessionEvent | null {
307+
if (this.sessionState.currentGoal === null) {
308+
return null;
309+
}
310+
this.sessionState.currentGoal = null;
311+
301312
return {
302313
sessionUpdate: "agent_message_chunk",
303314
content: {
@@ -307,6 +318,25 @@ export class CodexEventHandler {
307318
};
308319
}
309320

321+
private createThreadGoalSnapshot(event: ThreadGoalUpdatedNotification): ThreadGoalSnapshot {
322+
return {
323+
objective: event.goal.objective.trim(),
324+
status: event.goal.status,
325+
tokenBudget: event.goal.tokenBudget,
326+
};
327+
}
328+
329+
private sameThreadGoalSnapshot(
330+
left: ThreadGoalSnapshot | null | undefined,
331+
right: ThreadGoalSnapshot
332+
): boolean {
333+
return left !== null
334+
&& left !== undefined
335+
&& left.objective === right.objective
336+
&& left.status === right.status
337+
&& left.tokenBudget === right.tokenBudget;
338+
}
339+
310340
private createReasoningDeltaEvent(
311341
event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification
312342
): UpdateSessionEvent {

src/__tests__/CodexACPAgent/thread-goal-events.test.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,6 @@ describe("CodexEventHandler - thread goal events", () => {
1818
vi.clearAllMocks();
1919
});
2020

21-
const sessionState: SessionState = createTestSessionState({
22-
sessionId,
23-
currentModelId: "model-id[effort]",
24-
agentMode: AgentMode.DEFAULT_AGENT_MODE,
25-
});
26-
2721
it("should send thread goal updates as agent messages", async () => {
2822
const goalUpdatedNotification: ServerNotification = {
2923
method: "thread/goal/updated",
@@ -43,7 +37,7 @@ describe("CodexEventHandler - thread goal events", () => {
4337
},
4438
};
4539

46-
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]);
40+
await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [goalUpdatedNotification]);
4741

4842
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
4943
"data/thread-goal-updated.json"
@@ -69,7 +63,7 @@ describe("CodexEventHandler - thread goal events", () => {
6963
},
7064
};
7165

72-
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalUpdatedNotification]);
66+
await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [goalUpdatedNotification]);
7367

7468
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
7569
"data/thread-goal-updated-multiline.json"
@@ -84,10 +78,77 @@ describe("CodexEventHandler - thread goal events", () => {
8478
},
8579
};
8680

87-
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [goalClearedNotification]);
81+
await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [goalClearedNotification]);
8882

8983
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
9084
"data/thread-goal-cleared.json"
9185
);
9286
});
87+
88+
it("should suppress duplicate thread goal updates", async () => {
89+
const goalUpdatedNotification: ServerNotification = {
90+
method: "thread/goal/updated",
91+
params: {
92+
threadId: sessionId,
93+
turnId: "turn-1",
94+
goal: {
95+
threadId: sessionId,
96+
objective: "Ship the goal update",
97+
status: "active",
98+
tokenBudget: null,
99+
tokensUsed: 42,
100+
timeUsedSeconds: 12,
101+
createdAt: 1710000000,
102+
updatedAt: 1710000012,
103+
},
104+
},
105+
};
106+
const duplicateGoalUpdatedNotification: ServerNotification = {
107+
method: "thread/goal/updated",
108+
params: {
109+
...goalUpdatedNotification.params,
110+
goal: {
111+
...goalUpdatedNotification.params.goal,
112+
tokensUsed: 84,
113+
timeUsedSeconds: 24,
114+
updatedAt: 1710000024,
115+
},
116+
},
117+
};
118+
119+
await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [
120+
goalUpdatedNotification,
121+
duplicateGoalUpdatedNotification,
122+
]);
123+
124+
const events = mockFixture.getAcpConnectionEvents([]);
125+
expect(events).toHaveLength(1);
126+
expect(events[0]!.args[0].update.content.text).toBe("Goal updated (active): Ship the goal update");
127+
});
128+
129+
it("should suppress duplicate thread goal cleared notifications", async () => {
130+
const goalClearedNotification: ServerNotification = {
131+
method: "thread/goal/cleared",
132+
params: {
133+
threadId: sessionId,
134+
},
135+
};
136+
137+
await setupPromptAndSendNotifications(mockFixture, sessionId, createSessionState(), [
138+
goalClearedNotification,
139+
goalClearedNotification,
140+
]);
141+
142+
const events = mockFixture.getAcpConnectionEvents([]);
143+
expect(events).toHaveLength(1);
144+
expect(events[0]!.args[0].update.content.text).toBe("Goal cleared.");
145+
});
146+
147+
function createSessionState(): SessionState {
148+
return createTestSessionState({
149+
sessionId,
150+
currentModelId: "model-id[effort]",
151+
agentMode: AgentMode.DEFAULT_AGENT_MODE,
152+
});
153+
}
93154
});

0 commit comments

Comments
 (0)