Skip to content

Commit 4c098d3

Browse files
committed
feat: Stream reasoning events as agent thoughts
We weren't showing any reasoning/thinking before, but this also allows for streaming in the reasoning deltas if the backend supports it.
1 parent 7080ea7 commit 4c098d3

7 files changed

Lines changed: 216 additions & 18 deletions

src/CodexAcpClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ export class CodexAcpClient {
480480
input: input,
481481
approvalPolicy: agentMode.approvalPolicy,
482482
sandboxPolicy: agentMode.sandboxPolicy,
483-
summary: disableSummary ? "none" : null,
483+
summary: disableSummary ? "none" : "auto",
484484
effort: effort,
485485
model: modelId.model,
486486
serviceTier: serviceTier,

src/CodexEventHandler.ts

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import type {
2020
ItemStartedNotification,
2121
ThreadItem,
2222
ModelReroutedNotification,
23+
ReasoningSummaryPartAddedNotification,
24+
ReasoningSummaryTextDeltaNotification,
25+
ReasoningTextDeltaNotification,
2326
ThreadGoalClearedNotification,
2427
ThreadGoalUpdatedNotification,
2528
ThreadTokenUsageUpdatedNotification,
@@ -60,6 +63,7 @@ export class CodexEventHandler {
6063
private readonly activeGuardianApprovalReviews = new Set<string>();
6164
private readonly activeImageGenerationItems = new Set<string>();
6265
private readonly emittedImageViewItems = new Set<string>();
66+
private readonly seenReasoningDeltaItemIds = new Set<string>();
6367

6468
constructor(connection: acp.AgentSideConnection, sessionState: SessionState) {
6569
this.connection = connection;
@@ -145,6 +149,12 @@ export class CodexEventHandler {
145149
return this.handleGuardianApprovalReviewCompleted(notification.params);
146150
case "thread/compacted":
147151
return this.createContextCompactedEvent();
152+
case "item/reasoning/summaryTextDelta":
153+
return this.createReasoningDeltaEvent(notification.params);
154+
case "item/reasoning/textDelta":
155+
return this.createReasoningDeltaEvent(notification.params);
156+
case "item/reasoning/summaryPartAdded":
157+
return this.createReasoningSectionBreakEvent(notification.params);
148158
case "model/rerouted":
149159
return this.createModelReroutedEvent(notification.params);
150160
case "fuzzyFileSearch/sessionUpdated":
@@ -159,9 +169,6 @@ export class CodexEventHandler {
159169
case "command/exec/outputDelta":
160170
case "hook/started":
161171
case "hook/completed":
162-
case "item/reasoning/summaryTextDelta":
163-
case "item/reasoning/summaryPartAdded":
164-
case "item/reasoning/textDelta":
165172
case "turn/diff/updated":
166173
case "item/commandExecution/terminalInteraction":
167174
case "item/fileChange/outputDelta":
@@ -290,6 +297,28 @@ export class CodexEventHandler {
290297
};
291298
}
292299

300+
private createReasoningDeltaEvent(
301+
event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification
302+
): UpdateSessionEvent {
303+
this.seenReasoningDeltaItemIds.add(event.itemId);
304+
return this.createAgentThoughtEvent(event.delta);
305+
}
306+
307+
private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent {
308+
this.seenReasoningDeltaItemIds.add(event.itemId);
309+
return this.createAgentThoughtEvent("\n\n");
310+
}
311+
312+
private createAgentThoughtEvent(text: string): UpdateSessionEvent {
313+
return {
314+
sessionUpdate: "agent_thought_chunk",
315+
content: {
316+
type: "text",
317+
text,
318+
}
319+
};
320+
}
321+
293322
private async createItemEvent(event: ItemStartedNotification): Promise<UpdateSessionEvent | null> {
294323
switch (event.item.type) {
295324
case "fileChange":
@@ -351,15 +380,10 @@ export class CodexEventHandler {
351380
}
352381
return createImageGenerationUpdate(event.item);
353382
case "reasoning":
354-
const summary = event.item.summary[0];
355-
if (!summary) return null;
356-
return {
357-
sessionUpdate: "agent_thought_chunk",
358-
content: {
359-
type: "text",
360-
text: summary
361-
}
383+
if (this.seenReasoningDeltaItemIds.delete(event.item.id)) {
384+
return null;
362385
}
386+
return this.createCompletedReasoningEvent(event.item);
363387
case "webSearch":
364388
return createWebSearchCompleteUpdate(event.item);
365389
case "collabAgentToolCall":
@@ -376,6 +400,15 @@ export class CodexEventHandler {
376400
}
377401
}
378402

403+
private createCompletedReasoningEvent(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent | null {
404+
const parts = item.summary.length > 0 ? item.summary : item.content;
405+
const text = parts.filter(part => part.length > 0).join("\n\n");
406+
if (text.length === 0) {
407+
return null;
408+
}
409+
return this.createAgentThoughtEvent(text);
410+
}
411+
379412
private createExitedReviewModeEvent(item: ThreadItem & { type: "exitedReviewMode" }): UpdateSessionEvent | null {
380413
const text = item.review.trim();
381414
if (text.length === 0) {

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,22 +1179,22 @@ describe('ACP server test', { timeout: 40_000 }, () => {
11791179
};
11801180
}
11811181

1182-
it ('should disable resasoning.summary if key authorization is used', async () => {
1182+
it ('should disable reasoning.summary if key authorization is used', async () => {
11831183
const { mockFixture, turnStartSpy } = setupPromptFixture({ account: { type: "apiKey" } });
11841184

11851185
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
11861186

11871187
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: "none" }));
11881188
});
11891189

1190-
it ('should not disable resasoning.summary by default', async () => {
1190+
it ('should enable reasoning.summary by default', async () => {
11911191
const { mockFixture, turnStartSpy } = setupPromptFixture({
11921192
account: { type: "chatgpt", email: "test@example.com", planType: "pro" },
11931193
});
11941194

11951195
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
11961196

1197-
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null }));
1197+
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: "auto" }));
11981198
});
11991199

12001200
it ('should disable reasoning.summary when model lacks reasoning', async () => {
@@ -1208,7 +1208,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
12081208
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: "none" }));
12091209
});
12101210

1211-
it ('should not disable reasoning.summary when model supports reasoning', async () => {
1211+
it ('should enable reasoning.summary when model supports reasoning', async () => {
12121212
const { mockFixture, turnStartSpy } = setupPromptFixture({
12131213
account: { type: "chatgpt", email: "test@example.com", planType: "pro" },
12141214
supportedReasoningEfforts: [
@@ -1219,7 +1219,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
12191219

12201220
await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] });
12211221

1222-
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null }));
1222+
expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: "auto" }));
12231223
});
12241224

12251225
it ('should reject prompt with images when model does not support image input', async () => {
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_thought_chunk",
8+
"content": {
9+
"type": "text",
10+
"text": "First summary\n\nSecond summary"
11+
}
12+
}
13+
}
14+
]
15+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "agent_thought_chunk",
8+
"content": {
9+
"type": "text",
10+
"text": "First thought"
11+
}
12+
}
13+
}
14+
]
15+
}
16+
{
17+
"method": "sessionUpdate",
18+
"args": [
19+
{
20+
"sessionId": "test-session-id",
21+
"update": {
22+
"sessionUpdate": "agent_thought_chunk",
23+
"content": {
24+
"type": "text",
25+
"text": "\n\n"
26+
}
27+
}
28+
}
29+
]
30+
}
31+
{
32+
"method": "sessionUpdate",
33+
"args": [
34+
{
35+
"sessionId": "test-session-id",
36+
"update": {
37+
"sessionUpdate": "agent_thought_chunk",
38+
"content": {
39+
"type": "text",
40+
"text": "Raw reasoning detail"
41+
}
42+
}
43+
}
44+
]
45+
}

src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"excludeTmpdirEnvVar": false,
4646
"excludeSlashTmp": false
4747
},
48-
"summary": null,
48+
"summary": "auto",
4949
"effort": "effort",
5050
"model": "model",
5151
"serviceTier": null
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { ServerNotification } from "../../app-server";
3+
import type { SessionState } from "../../CodexAcpServer";
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 - reasoning 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("streams reasoning deltas and section breaks without duplicating the completed item", async () => {
28+
const notifications: ServerNotification[] = [
29+
{
30+
method: "item/reasoning/summaryTextDelta",
31+
params: {
32+
threadId: sessionId,
33+
turnId: "turn-1",
34+
itemId: "reasoning-1",
35+
summaryIndex: 0,
36+
delta: "First thought",
37+
},
38+
},
39+
{
40+
method: "item/reasoning/summaryPartAdded",
41+
params: {
42+
threadId: sessionId,
43+
turnId: "turn-1",
44+
itemId: "reasoning-1",
45+
summaryIndex: 1,
46+
},
47+
},
48+
{
49+
method: "item/reasoning/textDelta",
50+
params: {
51+
threadId: sessionId,
52+
turnId: "turn-1",
53+
itemId: "reasoning-1",
54+
contentIndex: 0,
55+
delta: "Raw reasoning detail",
56+
},
57+
},
58+
{
59+
method: "item/completed",
60+
params: {
61+
threadId: sessionId,
62+
turnId: "turn-1",
63+
completedAtMs: 0,
64+
item: {
65+
type: "reasoning",
66+
id: "reasoning-1",
67+
summary: ["Completed summary should not duplicate"],
68+
content: ["Completed content should not duplicate"],
69+
},
70+
},
71+
},
72+
];
73+
74+
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications);
75+
76+
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
77+
"data/reasoning-deltas-and-section-break.json"
78+
);
79+
});
80+
81+
it("emits all completed reasoning parts when no deltas streamed", async () => {
82+
const notifications: ServerNotification[] = [
83+
{
84+
method: "item/completed",
85+
params: {
86+
threadId: sessionId,
87+
turnId: "turn-1",
88+
completedAtMs: 0,
89+
item: {
90+
type: "reasoning",
91+
id: "reasoning-2",
92+
summary: ["First summary", "Second summary"],
93+
content: ["Raw content fallback"],
94+
},
95+
},
96+
},
97+
];
98+
99+
await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications);
100+
101+
await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(
102+
"data/reasoning-completed-parts.json"
103+
);
104+
});
105+
});

0 commit comments

Comments
 (0)