Skip to content

Commit 266d48d

Browse files
committed
Merge fallback history with parsed thread updates
1 parent 2674228 commit 266d48d

5 files changed

Lines changed: 328 additions & 31 deletions

File tree

src/CodexAcpServer.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -857,21 +857,21 @@ export class CodexAcpServer implements acp.Agent {
857857
thread,
858858
sessionState.terminalOutputMode,
859859
);
860-
if (responseItemFallbackUpdates) {
861-
for (const update of responseItemFallbackUpdates) {
862-
await session.update(update);
863-
}
864-
return;
865-
}
866860

861+
const threadUpdates: UpdateSessionEvent[] = [];
867862
for (const turn of thread.turns) {
868863
for (const item of turn.items) {
869864
const updates = await this.createHistoryUpdates(item, sessionState);
870-
for (const update of updates) {
871-
await session.update(update);
872-
}
865+
threadUpdates.push(...updates);
873866
}
874867
}
868+
869+
const updates = responseItemFallbackUpdates
870+
? mergeHistoryUpdates(responseItemFallbackUpdates, threadUpdates)
871+
: threadUpdates;
872+
for (const update of updates) {
873+
await session.update(update);
874+
}
875875
}
876876

877877
private async createHistoryUpdates(item: ThreadItem, sessionState: SessionState): Promise<UpdateSessionEvent[]> {
@@ -1495,6 +1495,71 @@ export class CodexAcpServer implements acp.Agent {
14951495
}
14961496
}
14971497

1498+
function mergeHistoryUpdates(
1499+
responseItemFallbackUpdates: UpdateSessionEvent[],
1500+
threadUpdates: UpdateSessionEvent[],
1501+
): UpdateSessionEvent[] {
1502+
const merged: UpdateSessionEvent[] = [];
1503+
const seen = new Set<string>();
1504+
let fallbackIndex = 0;
1505+
1506+
const pushUpdate = (update: UpdateSessionEvent) => {
1507+
const key = historyUpdateKey(update);
1508+
if (key && seen.has(key)) {
1509+
return;
1510+
}
1511+
if (key) {
1512+
seen.add(key);
1513+
}
1514+
merged.push(update);
1515+
};
1516+
1517+
const flushFallbackThrough = (targetKey: string): boolean => {
1518+
const matchIndex = responseItemFallbackUpdates.findIndex((update, index) => (
1519+
index >= fallbackIndex && historyUpdateKey(update) === targetKey
1520+
));
1521+
if (matchIndex === -1) {
1522+
return false;
1523+
}
1524+
1525+
while (fallbackIndex <= matchIndex) {
1526+
pushUpdate(responseItemFallbackUpdates[fallbackIndex]!);
1527+
fallbackIndex += 1;
1528+
}
1529+
return true;
1530+
};
1531+
1532+
for (const update of threadUpdates) {
1533+
const key = historyUpdateKey(update);
1534+
if (key && flushFallbackThrough(key)) {
1535+
continue;
1536+
}
1537+
pushUpdate(update);
1538+
}
1539+
1540+
while (fallbackIndex < responseItemFallbackUpdates.length) {
1541+
pushUpdate(responseItemFallbackUpdates[fallbackIndex]!);
1542+
fallbackIndex += 1;
1543+
}
1544+
1545+
return merged;
1546+
}
1547+
1548+
function historyUpdateKey(update: UpdateSessionEvent): string | null {
1549+
switch (update.sessionUpdate) {
1550+
case "user_message_chunk":
1551+
case "agent_message_chunk":
1552+
case "agent_thought_chunk":
1553+
return `${update.sessionUpdate}:${JSON.stringify(update.content)}`;
1554+
case "tool_call":
1555+
return `tool_call:${update.toolCallId}:start`;
1556+
case "tool_call_update":
1557+
return `tool_call:${update.toolCallId}:update`;
1558+
default:
1559+
return null;
1560+
}
1561+
}
1562+
14981563
function getRequestedMcpServerNames(mcpServers: Array<acp.McpServer>): Array<string> {
14991564
return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name))));
15001565
}

src/ResponseItemHistoryFallback.ts

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,32 @@ type AcpToolCallUpdateStatus = NonNullable<Extract<UpdateSessionEvent, {
1616
type LegacyFunctionCallUpdate = {
1717
update: AcpToolCallEvent;
1818
usesTerminal: boolean;
19+
isExecCommand: boolean;
1920
};
2021
type ParsedShellCommand = {
2122
tokens: string[];
2223
};
2324

25+
function historyFallbackUpdateKey(update: UpdateSessionEvent): string | null {
26+
switch (update.sessionUpdate) {
27+
case "user_message_chunk":
28+
case "agent_message_chunk":
29+
case "agent_thought_chunk":
30+
return `${update.sessionUpdate}:${JSON.stringify(update.content)}`;
31+
case "tool_call":
32+
return `tool_call:${update.toolCallId}:start`;
33+
case "tool_call_update":
34+
return `tool_call:${update.toolCallId}:update`;
35+
default:
36+
return null;
37+
}
38+
}
39+
2440
export async function createResponseItemHistoryFallbackUpdates(
2541
thread: Thread,
2642
terminalOutputMode: TerminalOutputMode,
2743
): Promise<UpdateSessionEvent[] | null> {
28-
if (!thread.path || threadHasToolItems(thread)) {
44+
if (!thread.path) {
2945
return null;
3046
}
3147

@@ -36,16 +52,32 @@ export async function createResponseItemHistoryFallbackUpdates(
3652
return null;
3753
}
3854

39-
return parseResponseItemHistoryFallback(contents, terminalOutputMode);
55+
return parseResponseItemHistoryFallback(contents, terminalOutputMode, toolCallIdsFromThread(thread));
4056
}
4157

4258
export function parseResponseItemHistoryFallback(
4359
contents: string,
4460
terminalOutputMode: TerminalOutputMode,
61+
existingToolCallIds: Set<string> = new Set(),
4562
): UpdateSessionEvent[] | null {
4663
const updates: UpdateSessionEvent[] = [];
4764
const terminalToolCallIds = new Set<string>();
48-
let sawFunctionCall = false;
65+
const execToolCallIds = new Set<string>();
66+
const skippedToolCallIds = new Set<string>();
67+
const emittedToolCallIds = new Set<string>();
68+
let recoveredFunctionCall = false;
69+
let lastUpdateKey: string | null = null;
70+
71+
const pushUpdates = (nextUpdates: UpdateSessionEvent[]) => {
72+
for (const update of nextUpdates) {
73+
const key = historyFallbackUpdateKey(update);
74+
if (key && key === lastUpdateKey) {
75+
continue;
76+
}
77+
updates.push(update);
78+
lastUpdateKey = key;
79+
}
80+
};
4981

5082
for (const line of contents.split(/\r?\n/)) {
5183
const record = parseJsonRecord(line);
@@ -55,7 +87,7 @@ export function parseResponseItemHistoryFallback(
5587

5688
const eventMsgUpdates = createEventMsgUpdates(record);
5789
if (eventMsgUpdates) {
58-
updates.push(...eventMsgUpdates);
90+
pushUpdates(eventMsgUpdates);
5991
continue;
6092
}
6193

@@ -66,27 +98,48 @@ export function parseResponseItemHistoryFallback(
6698

6799
switch (item["type"]) {
68100
case "message":
69-
updates.push(...createMessageUpdates(item));
101+
pushUpdates(createMessageUpdates(item));
70102
break;
71103
case "reasoning":
72-
updates.push(...createReasoningUpdates(item));
104+
pushUpdates(createReasoningUpdates(item));
73105
break;
74106
case "function_call": {
107+
const toolCallId = stringValue(item["call_id"]);
108+
if (toolCallId && existingToolCallIds.has(toolCallId)) {
109+
skippedToolCallIds.add(toolCallId);
110+
break;
111+
}
112+
if (toolCallId && emittedToolCallIds.has(toolCallId)) {
113+
break;
114+
}
75115
const result = createFunctionCallUpdate(item);
76116
if (!result) {
77117
break;
78118
}
79-
sawFunctionCall = true;
119+
recoveredFunctionCall = true;
120+
emittedToolCallIds.add(result.update.toolCallId);
80121
if (result.usesTerminal) {
81122
terminalToolCallIds.add(result.update.toolCallId);
82123
}
83-
updates.push(result.update);
124+
if (result.isExecCommand) {
125+
execToolCallIds.add(result.update.toolCallId);
126+
}
127+
pushUpdates([result.update]);
84128
break;
85129
}
86130
case "function_call_output": {
87-
const update = createFunctionCallOutputUpdate(item, terminalOutputMode, terminalToolCallIds);
131+
const toolCallId = stringValue(item["call_id"]);
132+
if (toolCallId && skippedToolCallIds.has(toolCallId)) {
133+
break;
134+
}
135+
const update = createFunctionCallOutputUpdate(
136+
item,
137+
terminalOutputMode,
138+
terminalToolCallIds,
139+
execToolCallIds,
140+
);
88141
if (update) {
89-
updates.push(update);
142+
pushUpdates([update]);
90143
}
91144
break;
92145
}
@@ -95,14 +148,23 @@ export function parseResponseItemHistoryFallback(
95148
}
96149
}
97150

98-
return sawFunctionCall ? updates : null;
151+
return recoveredFunctionCall ? updates : null;
99152
}
100153

101-
function threadHasToolItems(thread: Thread): boolean {
102-
return thread.turns.some((turn) => turn.items.some(isToolThreadItem));
154+
function toolCallIdsFromThread(thread: Thread): Set<string> {
155+
const ids = new Set<string>();
156+
for (const turn of thread.turns) {
157+
for (const item of turn.items) {
158+
const id = toolCallIdFromThreadItem(item);
159+
if (id) {
160+
ids.add(id);
161+
}
162+
}
163+
}
164+
return ids;
103165
}
104166

105-
function isToolThreadItem(item: ThreadItem): boolean {
167+
function toolCallIdFromThreadItem(item: ThreadItem): string | null {
106168
switch (item.type) {
107169
case "commandExecution":
108170
case "fileChange":
@@ -112,7 +174,7 @@ function isToolThreadItem(item: ThreadItem): boolean {
112174
case "webSearch":
113175
case "imageView":
114176
case "imageGeneration":
115-
return true;
177+
return item.id;
116178
case "userMessage":
117179
case "hookPrompt":
118180
case "agentMessage":
@@ -122,7 +184,7 @@ function isToolThreadItem(item: ThreadItem): boolean {
122184
case "enteredReviewMode":
123185
case "exitedReviewMode":
124186
case "contextCompaction":
125-
return false;
187+
return null;
126188
}
127189
}
128190

@@ -312,6 +374,7 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate |
312374
return {
313375
update: createCommandActionEvent(toolCallId, "inProgress", cwd, commandAction),
314376
usesTerminal: false,
377+
isExecCommand: true,
315378
};
316379
}
317380

@@ -325,28 +388,30 @@ function createFunctionCallUpdate(item: JsonRecord): LegacyFunctionCallUpdate |
325388
};
326389

327390
if (!functionCallUsesTerminal(item)) {
328-
return { update, usesTerminal: false };
391+
return { update, usesTerminal: false, isExecCommand: false };
329392
}
330393

331394
return {
332395
update: withTerminalContent(update, toolCallId, cwd),
333396
usesTerminal: true,
397+
isExecCommand: true,
334398
};
335399
}
336400

337401
function createFunctionCallOutputUpdate(
338402
item: JsonRecord,
339403
terminalOutputMode: TerminalOutputMode,
340404
terminalToolCallIds: Set<string>,
405+
execToolCallIds: Set<string>,
341406
): UpdateSessionEvent | null {
342407
const toolCallId = stringValue(item["call_id"]);
343408
if (!toolCallId) {
344409
return null;
345410
}
346411

347412
const output = outputText(item["output"]);
348-
const exitCode = parseExitCode(output);
349-
const status = statusFromExitCode(exitCode);
413+
const exitCode = parseExitCode(item["output"], output);
414+
const status = statusFromExitCode(exitCode, output, execToolCallIds.has(toolCallId));
350415
if (!terminalToolCallIds.has(toolCallId)) {
351416
return {
352417
sessionUpdate: "tool_call_update",
@@ -1004,7 +1069,15 @@ function outputText(output: unknown): string {
10041069
}).join("\n");
10051070
}
10061071

1007-
function parseExitCode(output: string): number | null {
1072+
function parseExitCode(rawOutput: unknown, output: string): number | null {
1073+
const record = asRecord(rawOutput);
1074+
if (record) {
1075+
const exitCode = numberValue(record["exit_code"]) ?? numberValue(record["exitCode"]);
1076+
if (exitCode !== null) {
1077+
return exitCode;
1078+
}
1079+
}
1080+
10081081
const match = output.match(/Process exited with code (-?\d+)/);
10091082
if (!match) {
10101083
return null;
@@ -1019,8 +1092,25 @@ function parseExitCode(output: string): number | null {
10191092
return Number.isFinite(exitCode) ? exitCode : null;
10201093
}
10211094

1022-
function statusFromExitCode(exitCode: number | null): AcpToolCallUpdateStatus {
1023-
return exitCode === null || exitCode === 0 ? "completed" : "failed";
1095+
function statusFromExitCode(
1096+
exitCode: number | null,
1097+
output: string,
1098+
isExecCommand: boolean,
1099+
): AcpToolCallUpdateStatus {
1100+
if (exitCode !== null) {
1101+
return exitCode === 0 ? "completed" : "failed";
1102+
}
1103+
1104+
return isExecCommand && looksLikeCommandFailure(output) ? "failed" : "completed";
1105+
}
1106+
1107+
function looksLikeCommandFailure(output: string): boolean {
1108+
const trimmed = output.trim();
1109+
if (trimmed.length === 0) {
1110+
return false;
1111+
}
1112+
1113+
return /(^|\n)(Error|Failed|Command failed|Sandbox error|No such file or directory|Permission denied|Operation not permitted|ENOENT|EACCES)(:|\b)/i.test(trimmed);
10241114
}
10251115

10261116
function asRecord(value: unknown): JsonRecord | null {
@@ -1033,3 +1123,7 @@ function asRecord(value: unknown): JsonRecord | null {
10331123
function stringValue(value: unknown): string | null {
10341124
return typeof value === "string" ? value : null;
10351125
}
1126+
1127+
function numberValue(value: unknown): number | null {
1128+
return typeof value === "number" && Number.isFinite(value) ? value : null;
1129+
}

src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@
8787
}
8888
]
8989
}
90+
{
91+
"method": "sessionUpdate",
92+
"args": [
93+
{
94+
"sessionId": "session-legacy",
95+
"update": {
96+
"sessionUpdate": "agent_message_chunk",
97+
"content": {
98+
"type": "text",
99+
"text": "Plan:\nInspect project files"
100+
}
101+
}
102+
}
103+
]
104+
}
90105
{
91106
"method": "sessionUpdate",
92107
"args": [

0 commit comments

Comments
 (0)