@gajae-code/ai 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/types/auth-storage.d.ts +2 -2
  3. package/dist/types/model-cache.d.ts +2 -0
  4. package/dist/types/provider-models/special.d.ts +3 -1
  5. package/dist/types/providers/anthropic.d.ts +1 -0
  6. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  7. package/dist/types/providers/cursor/gen/agent_pb.d.ts +3854 -107
  8. package/dist/types/providers/cursor-pi-args.d.ts +119 -0
  9. package/dist/types/providers/cursor.d.ts +8 -1
  10. package/dist/types/providers/openai-codex-responses.d.ts +2 -0
  11. package/dist/types/providers/openai-responses-shared.d.ts +1 -1
  12. package/dist/types/types.d.ts +41 -1
  13. package/dist/types/utils/block-symbols.d.ts +6 -0
  14. package/dist/types/utils/discovery/openai-compatible.d.ts +2 -0
  15. package/dist/types/utils/idle-iterator.d.ts +5 -2
  16. package/dist/types/utils/oauth/kimi.d.ts +3 -9
  17. package/dist/types/utils/oauth/openrouter.d.ts +1 -0
  18. package/dist/types/utils/oauth/types.d.ts +1 -1
  19. package/package.json +4 -4
  20. package/src/auth-broker/remote-store.ts +13 -2
  21. package/src/auth-storage.ts +10 -11
  22. package/src/model-cache.ts +78 -0
  23. package/src/model-manager.ts +194 -25
  24. package/src/provider-models/special.ts +67 -4
  25. package/src/providers/anthropic.ts +115 -40
  26. package/src/providers/aws-credential-config.ts +2 -3
  27. package/src/providers/aws-credentials.ts +2 -3
  28. package/src/providers/azure-openai-responses.ts +18 -2
  29. package/src/providers/cursor/exec-modern.ts +497 -0
  30. package/src/providers/cursor/gen/agent_pb.ts +4687 -181
  31. package/src/providers/cursor/proto/agent.proto +1007 -0
  32. package/src/providers/cursor-pi-args.ts +187 -0
  33. package/src/providers/cursor.ts +382 -47
  34. package/src/providers/google-auth.ts +2 -3
  35. package/src/providers/openai-codex-responses.ts +358 -73
  36. package/src/providers/openai-completions.ts +2 -2
  37. package/src/providers/openai-responses-shared.ts +55 -6
  38. package/src/providers/openai-responses.ts +27 -4
  39. package/src/stream.ts +8 -3
  40. package/src/types.ts +55 -0
  41. package/src/utils/block-symbols.ts +11 -0
  42. package/src/utils/discovery/openai-compatible.ts +21 -6
  43. package/src/utils/idle-iterator.ts +22 -4
  44. package/src/utils/oauth/index.ts +6 -0
  45. package/src/utils/oauth/kimi.ts +14 -8
  46. package/src/utils/oauth/kiro.ts +2 -2
  47. package/src/utils/oauth/openrouter.ts +16 -0
  48. package/src/utils/oauth/types.ts +1 -0
@@ -8,6 +8,7 @@ import {
8
8
  fetchWithRetry,
9
9
  logger,
10
10
  readSseJson,
11
+ sanitizeHeaderComponent,
11
12
  structuredCloneJSON,
12
13
  } from "@gajae-code/utils";
13
14
  import type OpenAI from "openai";
@@ -39,6 +40,7 @@ import {
39
40
  type Tool,
40
41
  type ToolCall,
41
42
  type ToolChoice,
43
+ type ToolResultMessage,
42
44
  } from "../types";
43
45
  import {
44
46
  createOpenAIResponsesHistoryPayload,
@@ -115,6 +117,46 @@ const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300000;
115
117
  const CODEX_WEBSOCKET_RETRY_BUDGET = CODEX_MAX_RETRIES;
116
118
  const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
117
119
  const CODEX_PREVIOUS_RESPONSE_STALE_CODES = new Set(["previous_response_not_found", "codex_previous_response_stale"]);
120
+ // Some Codex deployments reject a stale continuation anchor with a generic
121
+ // `invalid_request_error` code and name the anchor only in the message
122
+ // (`Invalid \`previous_response_id\`.`). Match the canonical request-field token
123
+ // with a stale qualifier on either side, so the anchor is cleared and the turn
124
+ // retried with full context instead of killing the session.
125
+ //
126
+ // The compact form deliberately requires the `previous_response_id` field token
127
+ // and is unguarded: a message naming the field itself is about the anchor. Prose
128
+ // references are guarded separately below — the division of labor is compact
129
+ // token (unguarded, field-naming) vs prose phrase (guarded, fault-noun checked
130
+ // on both sides of the anchor phrase), since a deterministic history fault such
131
+ // as `Previous response's tool call ID is malformed.` must stay fatal:
132
+ // replaying it re-sends the same offending item.
133
+ const CODEX_PREVIOUS_RESPONSE_ID_TOKEN = String.raw`previous[ _-]response[ _-]id`;
134
+ // Prose anchor reference ("Previous response with id 'resp_1' not found."):
135
+ // the canonical `previous_response_not_found` wording, which codex-lb re-codes
136
+ // as `invalid_request_error` the same way it re-codes the anchor-expiry fault
137
+ // to `codex_previous_response_stale`. The compact token above cannot match it,
138
+ // so the recovery path missed these and the session died.
139
+ const CODEX_PREVIOUS_RESPONSE_PROSE_TOKEN = `previous[ _-]response`;
140
+ // Sub-field faults INSIDE the previous response (tool call / call id / message
141
+ // id / item) are deterministic history faults: replaying full context re-sends
142
+ // the same offending item. Tempering the qualifier⇄token scan against these
143
+ // tokens keeps them fatal while pure anchor-stale prose still matches.
144
+ const CODEX_PREVIOUS_RESPONSE_STALE_SUBFIELD_GUARD = `tool[ _]calls?|function[ _]calls?|custom[ _]tools?|call[ _-]?ids?|message[ _]?ids?|items?\\b|output[ _-]?items?`;
145
+ const CODEX_ANCHOR_STALE_QUALIFIER = String.raw`invalid|expired|unknown|stale|not[ _-]?found|no longer`;
146
+ const CODEX_PREVIOUS_RESPONSE_STALE_MESSAGE = new RegExp(
147
+ `(?:${CODEX_ANCHOR_STALE_QUALIFIER})[^\\n]{0,48}?${CODEX_PREVIOUS_RESPONSE_ID_TOKEN}` +
148
+ `|${CODEX_PREVIOUS_RESPONSE_ID_TOKEN}[^\\n]{0,48}?(?:${CODEX_ANCHOR_STALE_QUALIFIER})`,
149
+ "i",
150
+ );
151
+ const CODEX_PREVIOUS_RESPONSE_STALE_PROSE_MESSAGE = new RegExp(
152
+ // The fault noun can sit on EITHER side of the anchor phrase AND on either
153
+ // side of the qualifier (`Unknown previous response tool call.`,
154
+ // `Previous response includes an unknown tool call.`), so every alternative
155
+ // carries both the tempered inter-token scan and a post-anchor lookahead.
156
+ `(?:${CODEX_ANCHOR_STALE_QUALIFIER})(?:(?!${CODEX_PREVIOUS_RESPONSE_STALE_SUBFIELD_GUARD})[^\\n]){0,48}?${CODEX_PREVIOUS_RESPONSE_PROSE_TOKEN}(?![^\\n]{0,48}(?:${CODEX_PREVIOUS_RESPONSE_STALE_SUBFIELD_GUARD}))` +
157
+ `|${CODEX_PREVIOUS_RESPONSE_PROSE_TOKEN}(?:(?!${CODEX_PREVIOUS_RESPONSE_STALE_SUBFIELD_GUARD})[^\\n]){0,48}?(?:${CODEX_ANCHOR_STALE_QUALIFIER})(?![^\\n]{0,48}(?:${CODEX_PREVIOUS_RESPONSE_STALE_SUBFIELD_GUARD}))`,
158
+ "i",
159
+ );
118
160
  const CODEX_RETRYABLE_EVENT_CODES = new Set(["model_error", "server_error", "internal_error"]);
119
161
  const CODEX_NON_RETRYABLE_EVENT_CODES = new Set([
120
162
  "invalid_function_parameters",
@@ -163,10 +205,21 @@ const CODEX_PROGRESS_EVENT_TYPES = new Set([
163
205
  "error",
164
206
  ]);
165
207
 
208
+ /**
209
+ * A progress event must carry real semantic payload, matching the Anthropic
210
+ * predicate: a recognized envelope whose `delta` is absent or not a non-empty
211
+ * string is NOT progress — otherwise repeated malformed/no-op deltas reset the
212
+ * idle watchdog indefinitely and a managed attempt need never terminate.
213
+ * Non-delta envelope types (lifecycle, item boundaries, terminal events) count
214
+ * as progress by type alone, as before.
215
+ */
166
216
  function isCodexStreamProgressEvent(event: unknown): boolean {
167
217
  if (!event || typeof event !== "object") return false;
168
218
  const type = (event as { type?: unknown }).type;
169
- return typeof type === "string" && CODEX_PROGRESS_EVENT_TYPES.has(type);
219
+ if (typeof type !== "string" || !CODEX_PROGRESS_EVENT_TYPES.has(type)) return false;
220
+ if (!type.endsWith(".delta")) return true;
221
+ const delta = (event as { delta?: unknown }).delta;
222
+ return typeof delta === "string" && delta.length > 0;
170
223
  }
171
224
  type CodexTransport = "sse" | "websocket";
172
225
  interface CodexInitialTransport {
@@ -174,10 +227,12 @@ interface CodexInitialTransport {
174
227
  requestBodyForState: RequestBody;
175
228
  transport: CodexTransport;
176
229
  toolChoiceFallbackApplied?: boolean;
230
+ /** Whether the dispatched request actually carried a `previous_response_id` anchor. */
231
+ sentPreviousResponseId?: boolean;
177
232
  }
178
233
  type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
179
234
  type CodexThinkingBlock = ThinkingContent & { summaryBuffer: string; rawBuffer: string; summaryStarted: boolean };
180
- type CodexOutputBlock = CodexThinkingBlock | TextContent | (ToolCall & { partialJson: string });
235
+ type CodexOutputBlock = CodexThinkingBlock | TextContent | (ToolCall & { partialJson: string; doneInput?: string });
181
236
  export interface OpenAICodexWebSocketDebugStats {
182
237
  fullContextRequests: number;
183
238
  deltaRequests: number;
@@ -277,11 +332,27 @@ interface CodexStreamRuntime {
277
332
  websocketStreamRetries: number;
278
333
  providerRetryAttempt: number;
279
334
  toolChoiceFallbackAttempted: boolean;
335
+ /**
336
+ * Stale-anchor recovery is one-shot. Once the anchor is cleared the replay no
337
+ * longer carries `previous_response_id`, so a repeated rejection is a real
338
+ * fault and must surface instead of replaying full context up to five times.
339
+ */
340
+ previousResponseRecoveryAttempted: boolean;
341
+ /**
342
+ * Whether the in-flight request actually carried a `previous_response_id`.
343
+ * Anchor recovery is only meaningful when it did: a rejection naming the field
344
+ * on an anchor-free request (`previous_response_id is required`) is a real
345
+ * validation fault, and clearing session state plus resending the identical
346
+ * body would destroy valid metadata to no effect.
347
+ */
348
+ sentPreviousResponseId: boolean;
280
349
  sseRequestBodyOverride?: RequestBody;
281
350
  sawTerminalEvent: boolean;
282
351
  canSafelyReplayWebsocketOverSse: boolean;
283
352
  /** Ids of tool calls that received their terminal `output_item.done`. */
284
353
  finalizedToolCallIds: Set<string>;
354
+ /** Event types whose degraded non-string increment was already diagnosed. */
355
+ degradedIncrementDiagnostics: Set<string>;
285
356
  }
286
357
 
287
358
  interface CodexStreamProcessingContext {
@@ -520,8 +591,13 @@ function createEmptyUsage(): AssistantMessage["usage"] {
520
591
  };
521
592
  }
522
593
 
594
+ /** @internal Exported for tests. */
595
+ export function formatCodexUserAgent(platform: string, release: string, arch: string): string {
596
+ return `pi/${packageJson.version} (${sanitizeHeaderComponent(platform)} ${sanitizeHeaderComponent(release)}; ${sanitizeHeaderComponent(arch)})`;
597
+ }
598
+
523
599
  function getCodexUserAgent(): string {
524
- return `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`;
600
+ return formatCodexUserAgent(os.platform(), os.release(), os.arch());
525
601
  }
526
602
 
527
603
  function getCodexServiceTierCostMultiplier(
@@ -592,6 +668,10 @@ function resetOutputState(output: AssistantMessage): void {
592
668
  function removeTransientBlockIndices(output: AssistantMessage): void {
593
669
  for (const block of output.content) {
594
670
  delete (block as { index?: number }).index;
671
+ if (block.type === "toolCall") {
672
+ delete (block as { partialJson?: string }).partialJson;
673
+ delete (block as { doneInput?: string }).doneInput;
674
+ }
595
675
  }
596
676
  }
597
677
 
@@ -808,8 +888,10 @@ async function openCodexWebSocketTransport(
808
888
  eventStream: AsyncGenerator<Record<string, unknown>>;
809
889
  requestBodyForState: RequestBody;
810
890
  transport: CodexTransport;
891
+ sentPreviousResponseId: boolean;
811
892
  }> {
812
893
  const websocketRequest = buildCodexWebSocketRequest(requestContext.transformedBody, websocketState);
894
+ const sentPreviousResponseId = typeof websocketRequest.previous_response_id === "string";
813
895
  const websocketHeaders = createCodexHeaders(
814
896
  requestContext.requestHeaders,
815
897
  requestContext.accountId,
@@ -827,6 +909,7 @@ async function openCodexWebSocketTransport(
827
909
  sentTurnStateHeader: websocketHeaders.has(X_CODEX_TURN_STATE_HEADER),
828
910
  sentModelsEtagHeader: websocketHeaders.has(X_MODELS_ETAG_HEADER),
829
911
  requestType: websocketRequest.type,
912
+ sentPreviousResponseId,
830
913
  retry,
831
914
  retryBudget: getCodexWebSocketRetryBudget(options),
832
915
  });
@@ -839,7 +922,7 @@ async function openCodexWebSocketTransport(
839
922
  options,
840
923
  requestSetup.firstEventTimeoutMs,
841
924
  );
842
- return { eventStream, requestBodyForState, transport: "websocket" };
925
+ return { eventStream, requestBodyForState, transport: "websocket", sentPreviousResponseId };
843
926
  }
844
927
 
845
928
  async function openCodexSseTransport(
@@ -904,6 +987,7 @@ async function reopenCodexWebSocketRuntimeStream(
904
987
  runtime.eventStream = next.eventStream;
905
988
  runtime.requestBodyForState = next.requestBodyForState;
906
989
  runtime.transport = next.transport;
990
+ runtime.sentPreviousResponseId = next.sentPreviousResponseId === true;
907
991
  state.lastTransport = next.transport;
908
992
  } catch (error) {
909
993
  const wsError = error instanceof Error ? error : new Error(String(error));
@@ -936,6 +1020,8 @@ async function reopenCodexSseRuntimeStream(
936
1020
  runtime.eventStream = next.eventStream;
937
1021
  runtime.requestBodyForState = next.requestBodyForState;
938
1022
  runtime.transport = next.transport;
1023
+ // SSE never attaches `previous_response_id`; only buildCodexWebSocketRequest does.
1024
+ runtime.sentPreviousResponseId = false;
939
1025
  if (state) {
940
1026
  state.lastTransport = next.transport;
941
1027
  }
@@ -947,6 +1033,7 @@ function createCodexStreamRuntime(initial: {
947
1033
  transport: CodexTransport;
948
1034
  websocketState?: CodexWebSocketSessionState;
949
1035
  toolChoiceFallbackApplied?: boolean;
1036
+ sentPreviousResponseId?: boolean;
950
1037
  }): CodexStreamRuntime {
951
1038
  return {
952
1039
  eventStream: initial.eventStream,
@@ -959,12 +1046,15 @@ function createCodexStreamRuntime(initial: {
959
1046
  websocketStreamRetries: 0,
960
1047
  providerRetryAttempt: 0,
961
1048
  toolChoiceFallbackAttempted: initial.toolChoiceFallbackApplied === true,
1049
+ previousResponseRecoveryAttempted: false,
1050
+ sentPreviousResponseId: initial.sentPreviousResponseId === true,
962
1051
  sseRequestBodyOverride: initial.toolChoiceFallbackApplied
963
1052
  ? structuredCloneJSON(initial.requestBodyForState)
964
1053
  : undefined,
965
1054
  sawTerminalEvent: false,
966
1055
  canSafelyReplayWebsocketOverSse: true,
967
1056
  finalizedToolCallIds: new Set<string>(),
1057
+ degradedIncrementDiagnostics: new Set<string>(),
968
1058
  };
969
1059
  }
970
1060
 
@@ -1019,6 +1109,13 @@ function handleCodexStreamEvent(args: {
1019
1109
  runtime.currentItem = item;
1020
1110
  runtime.currentBlock = createOutputBlockForItem(item);
1021
1111
  if (!runtime.currentBlock) return firstTokenTime;
1112
+ const currentBlock = runtime.currentBlock;
1113
+ if (
1114
+ currentBlock.type === "toolCall" &&
1115
+ output.content.some(block => block.type === "toolCall" && block.id === currentBlock.id)
1116
+ ) {
1117
+ throw new Error("Codex stream reused an active tool-call identifier");
1118
+ }
1022
1119
  output.content.push(runtime.currentBlock);
1023
1120
  stream.push({
1024
1121
  type: getOutputBlockStartEventType(runtime.currentBlock),
@@ -1034,7 +1131,13 @@ function handleCodexStreamEvent(args: {
1034
1131
  }
1035
1132
 
1036
1133
  if (eventType === "response.reasoning_summary_text.delta") {
1037
- handleReasoningSummaryTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
1134
+ const delta = normalizeCodexIncrement(
1135
+ rawEvent,
1136
+ "response.reasoning_summary_text.delta",
1137
+ model,
1138
+ runtime.degradedIncrementDiagnostics,
1139
+ );
1140
+ handleReasoningSummaryTextDelta(runtime.currentItem, runtime.currentBlock, delta, stream, output, blockIndex);
1038
1141
  return firstTokenTime;
1039
1142
  }
1040
1143
 
@@ -1044,7 +1147,13 @@ function handleCodexStreamEvent(args: {
1044
1147
  }
1045
1148
 
1046
1149
  if (eventType === "response.reasoning_text.delta") {
1047
- handleReasoningTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
1150
+ const delta = normalizeCodexIncrement(
1151
+ rawEvent,
1152
+ "response.reasoning_text.delta",
1153
+ model,
1154
+ runtime.degradedIncrementDiagnostics,
1155
+ );
1156
+ handleReasoningTextDelta(runtime.currentItem, runtime.currentBlock, delta, stream, output, blockIndex);
1048
1157
  return firstTokenTime;
1049
1158
  }
1050
1159
 
@@ -1054,10 +1163,16 @@ function handleCodexStreamEvent(args: {
1054
1163
  }
1055
1164
 
1056
1165
  if (eventType === "response.output_text.delta") {
1166
+ const delta = normalizeCodexIncrement(
1167
+ rawEvent,
1168
+ "response.output_text.delta",
1169
+ model,
1170
+ runtime.degradedIncrementDiagnostics,
1171
+ );
1057
1172
  handleMessageTextDelta(
1058
1173
  runtime.currentItem,
1059
1174
  runtime.currentBlock,
1060
- rawEvent,
1175
+ delta,
1061
1176
  stream,
1062
1177
  output,
1063
1178
  blockIndex,
@@ -1067,20 +1182,19 @@ function handleCodexStreamEvent(args: {
1067
1182
  }
1068
1183
 
1069
1184
  if (eventType === "response.refusal.delta") {
1070
- handleMessageTextDelta(
1071
- runtime.currentItem,
1072
- runtime.currentBlock,
1185
+ const delta = normalizeCodexIncrement(
1073
1186
  rawEvent,
1074
- stream,
1075
- output,
1076
- blockIndex,
1077
- "refusal",
1187
+ "response.refusal.delta",
1188
+ model,
1189
+ runtime.degradedIncrementDiagnostics,
1078
1190
  );
1191
+ handleMessageTextDelta(runtime.currentItem, runtime.currentBlock, delta, stream, output, blockIndex, "refusal");
1079
1192
  return firstTokenTime;
1080
1193
  }
1081
1194
 
1082
1195
  if (eventType === "response.function_call_arguments.delta") {
1083
- handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
1196
+ const delta = assertStringToolArgumentIncrement(rawEvent, "response.function_call_arguments.delta");
1197
+ handleToolCallArgumentsDelta(runtime.currentItem, runtime.currentBlock, delta, stream, output, blockIndex);
1084
1198
  return firstTokenTime;
1085
1199
  }
1086
1200
 
@@ -1090,7 +1204,8 @@ function handleCodexStreamEvent(args: {
1090
1204
  }
1091
1205
 
1092
1206
  if (eventType === "response.custom_tool_call_input.delta") {
1093
- handleCustomToolCallInputDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex);
1207
+ const delta = assertStringToolArgumentIncrement(rawEvent, "response.custom_tool_call_input.delta");
1208
+ handleCustomToolCallInputDelta(runtime.currentItem, runtime.currentBlock, delta, stream, output, blockIndex);
1094
1209
  return firstTokenTime;
1095
1210
  }
1096
1211
 
@@ -1137,6 +1252,10 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1137
1252
  };
1138
1253
  }
1139
1254
  if (item.type === "custom_tool_call") {
1255
+ const initialInput: unknown = item.input;
1256
+ if (typeof initialInput !== "string") {
1257
+ throw new Error("Codex custom_tool_call started with non-string input");
1258
+ }
1140
1259
  // Wire name flows through unchanged; the agent-loop dispatcher also
1141
1260
  // matches `Tool.customWireName`. Reuse `partialJson` as the
1142
1261
  // accumulation buffer for the raw input string.
@@ -1144,9 +1263,9 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1144
1263
  type: "toolCall",
1145
1264
  id: encodeResponsesToolCallId(item.call_id, item.id),
1146
1265
  name: item.name,
1147
- arguments: { input: item.input ?? "" },
1266
+ arguments: { input: initialInput },
1148
1267
  customWireName: item.name,
1149
- partialJson: item.input ?? "",
1268
+ partialJson: initialInput,
1150
1269
  };
1151
1270
  }
1152
1271
  return null;
@@ -1164,10 +1283,52 @@ function handleReasoningSummaryPartAdded(currentItem: CodexEventItem | null, raw
1164
1283
  currentItem.summary.push((rawEvent as { part: ResponseReasoningItem["summary"][number] }).part);
1165
1284
  }
1166
1285
 
1286
+ /**
1287
+ * Primitive anomalies (undefined, null, numbers, booleans) stay coerced to
1288
+ * an empty string by the increment handlers. Diagnose at most once per event
1289
+ * type per stream, naming only the envelope shape — never the payload.
1290
+ */
1291
+ function normalizeCodexIncrement(
1292
+ rawEvent: Record<string, unknown>,
1293
+ eventType: string,
1294
+ model: Model<"openai-codex-responses">,
1295
+ degradedIncrementDiagnostics: Set<string>,
1296
+ ): string {
1297
+ const raw = (rawEvent as { delta?: unknown }).delta;
1298
+ if (typeof raw === "string") return raw;
1299
+ if (!degradedIncrementDiagnostics.has(eventType)) {
1300
+ degradedIncrementDiagnostics.add(eventType);
1301
+ logger.warn("codex: degraded non-string stream increment to empty string", {
1302
+ model: model.id,
1303
+ provider: model.provider,
1304
+ eventType,
1305
+ receivedType: raw === null ? "null" : typeof raw,
1306
+ });
1307
+ }
1308
+ return "";
1309
+ }
1310
+
1311
+ /**
1312
+ * Tool-argument fragments are positional JSON text. Erasing or coercing ANY
1313
+ * malformed increment — primitive, object, or function — assembles
1314
+ * valid-but-wrong arguments (e.g. `{"n":1` + numeric primitive erased to ""
1315
+ * + `3}` parses as {"n":13} and executes), so the turn fails closed on every
1316
+ * non-string delta. The payload never enters the error message.
1317
+ */
1318
+ function assertStringToolArgumentIncrement(rawEvent: Record<string, unknown>, eventType: string): string {
1319
+ const raw = (rawEvent as { delta?: unknown }).delta;
1320
+ if (typeof raw !== "string") {
1321
+ throw new Error(
1322
+ `Codex stream sent a non-string ${eventType} tool-argument increment; failing the turn instead of assembling wrong tool arguments`,
1323
+ );
1324
+ }
1325
+ return raw;
1326
+ }
1327
+
1167
1328
  function handleReasoningSummaryTextDelta(
1168
1329
  currentItem: CodexEventItem | null,
1169
1330
  currentBlock: CodexOutputBlock | null,
1170
- rawEvent: Record<string, unknown>,
1331
+ delta: string,
1171
1332
  stream: AssistantMessageEventStream,
1172
1333
  output: AssistantMessage,
1173
1334
  blockIndex: () => number,
@@ -1180,7 +1341,6 @@ function handleReasoningSummaryTextDelta(
1180
1341
  currentItem.summary = currentItem.summary || [];
1181
1342
  const lastPart = currentItem.summary[currentItem.summary.length - 1];
1182
1343
  if (!lastPart) return;
1183
- const delta = (rawEvent as { delta?: string }).delta || "";
1184
1344
  currentBlock.thinking += delta;
1185
1345
  currentBlock.summaryBuffer += delta;
1186
1346
  lastPart.text += delta;
@@ -1207,13 +1367,12 @@ function handleReasoningSummaryPartDone(
1207
1367
  function handleReasoningTextDelta(
1208
1368
  currentItem: CodexEventItem | null,
1209
1369
  currentBlock: CodexOutputBlock | null,
1210
- rawEvent: Record<string, unknown>,
1370
+ delta: string,
1211
1371
  stream: AssistantMessageEventStream,
1212
1372
  output: AssistantMessage,
1213
1373
  blockIndex: () => number,
1214
1374
  ): void {
1215
1375
  if (currentItem?.type !== "reasoning" || currentBlock?.type !== "thinking") return;
1216
- const delta = (rawEvent as { delta?: string }).delta || "";
1217
1376
  currentBlock.thinking += delta;
1218
1377
  currentBlock.rawBuffer += delta;
1219
1378
  stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta, partial: output });
@@ -1231,7 +1390,7 @@ function handleContentPartAdded(currentItem: CodexEventItem | null, rawEvent: Re
1231
1390
  function handleMessageTextDelta(
1232
1391
  currentItem: CodexEventItem | null,
1233
1392
  currentBlock: CodexOutputBlock | null,
1234
- rawEvent: Record<string, unknown>,
1393
+ delta: string,
1235
1394
  stream: AssistantMessageEventStream,
1236
1395
  output: AssistantMessage,
1237
1396
  blockIndex: () => number,
@@ -1241,7 +1400,6 @@ function handleMessageTextDelta(
1241
1400
  if (!currentItem.content || currentItem.content.length === 0) return;
1242
1401
  const lastPart = currentItem.content[currentItem.content.length - 1];
1243
1402
  if (!lastPart || lastPart.type !== partType) return;
1244
- const delta = (rawEvent as { delta?: string }).delta || "";
1245
1403
  currentBlock.text += delta;
1246
1404
  if (lastPart.type === "output_text") {
1247
1405
  lastPart.text += delta;
@@ -1254,13 +1412,12 @@ function handleMessageTextDelta(
1254
1412
  function handleToolCallArgumentsDelta(
1255
1413
  currentItem: CodexEventItem | null,
1256
1414
  currentBlock: CodexOutputBlock | null,
1257
- rawEvent: Record<string, unknown>,
1415
+ delta: string,
1258
1416
  stream: AssistantMessageEventStream,
1259
1417
  output: AssistantMessage,
1260
1418
  blockIndex: () => number,
1261
1419
  ): void {
1262
1420
  if (currentItem?.type !== "function_call" || currentBlock?.type !== "toolCall") return;
1263
- const delta = (rawEvent as { delta?: string }).delta || "";
1264
1421
  currentBlock.partialJson += delta;
1265
1422
  currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
1266
1423
  stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
@@ -1283,13 +1440,12 @@ function handleToolCallArgumentsDone(
1283
1440
  function handleCustomToolCallInputDelta(
1284
1441
  currentItem: CodexEventItem | null,
1285
1442
  currentBlock: CodexOutputBlock | null,
1286
- rawEvent: Record<string, unknown>,
1443
+ delta: string,
1287
1444
  stream: AssistantMessageEventStream,
1288
1445
  output: AssistantMessage,
1289
1446
  blockIndex: () => number,
1290
1447
  ): void {
1291
1448
  if (currentItem?.type !== "custom_tool_call" || currentBlock?.type !== "toolCall") return;
1292
- const delta = (rawEvent as { delta?: string }).delta || "";
1293
1449
  currentBlock.partialJson += delta;
1294
1450
  currentBlock.arguments = { input: currentBlock.partialJson };
1295
1451
  stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta, partial: output });
@@ -1301,11 +1457,17 @@ function handleCustomToolCallInputDone(
1301
1457
  rawEvent: Record<string, unknown>,
1302
1458
  ): void {
1303
1459
  if (currentItem?.type !== "custom_tool_call" || currentBlock?.type !== "toolCall") return;
1304
- const input = (rawEvent as { input?: string }).input;
1305
- if (typeof input === "string") {
1306
- currentBlock.partialJson = input;
1307
- currentBlock.arguments = { input };
1460
+ const input = (rawEvent as { input?: unknown }).input;
1461
+ if (typeof input !== "string") {
1462
+ throw new Error("Codex stream sent non-string input in custom_tool_call_input.done");
1308
1463
  }
1464
+ if (currentBlock.partialJson && currentBlock.partialJson !== input) {
1465
+ throw new Error(
1466
+ "Codex custom_tool_call input.done disagrees with the streamed input buffer; failing the turn instead of executing corrupted input",
1467
+ );
1468
+ }
1469
+ currentBlock.doneInput = input;
1470
+ currentBlock.arguments = { input };
1309
1471
  }
1310
1472
 
1311
1473
  function handleOutputItemDone(
@@ -1385,36 +1547,90 @@ function handleOutputItemDone(
1385
1547
  }
1386
1548
 
1387
1549
  if (item.type === "function_call") {
1550
+ if (typeof item.arguments !== "string") {
1551
+ throw new Error("Codex function_call completed with non-string terminal arguments");
1552
+ }
1553
+ let terminalArguments: unknown;
1554
+ try {
1555
+ terminalArguments = JSON.parse(item.arguments);
1556
+ } catch {
1557
+ throw new Error("Codex function_call completed with malformed terminal arguments");
1558
+ }
1559
+ if (!terminalArguments || typeof terminalArguments !== "object" || Array.isArray(terminalArguments)) {
1560
+ throw new Error("Codex function_call terminal arguments were not a JSON object");
1561
+ }
1388
1562
  const id = encodeResponsesToolCallId(item.call_id, item.id);
1563
+ if (runtime.currentBlock?.type !== "toolCall" || runtime.currentBlock.id !== id) {
1564
+ throw new Error("Codex function_call terminal item did not match the active tool call");
1565
+ }
1389
1566
  runtime.finalizedToolCallIds.add(id);
1390
1567
  const toolCall: ToolCall = {
1391
1568
  type: "toolCall",
1392
1569
  id,
1393
1570
  name: codexToolCanonicalName(item.name),
1394
- arguments: parseStreamingJson(item.arguments || "{}"),
1571
+ arguments: terminalArguments as Record<string, unknown>,
1395
1572
  ...(findUnnecessaryUnicodeEscape(item.arguments || "") ? { escapedNonAsciiArguments: true } : {}),
1396
1573
  };
1574
+ Object.assign(runtime.currentBlock, toolCall);
1575
+ delete (runtime.currentBlock as { partialJson?: string }).partialJson;
1576
+ delete (runtime.currentBlock as { doneInput?: string }).doneInput;
1397
1577
  runtime.canSafelyReplayWebsocketOverSse = false;
1398
1578
  stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
1579
+ runtime.currentItem = null;
1580
+ runtime.currentBlock = null;
1399
1581
  return;
1400
1582
  }
1401
1583
 
1402
1584
  if (item.type === "custom_tool_call") {
1585
+ const terminalInput: unknown = item.input;
1586
+ if (typeof terminalInput !== "string") {
1587
+ throw new Error(
1588
+ "Codex custom_tool_call completed with non-string terminal input; failing the turn instead of finalizing from the streamed buffer",
1589
+ );
1590
+ }
1403
1591
  const id = encodeResponsesToolCallId(item.call_id, item.id);
1592
+ if (runtime.currentBlock?.type !== "toolCall" || runtime.currentBlock.id !== id) {
1593
+ throw new Error("Codex custom_tool_call terminal item did not match the active tool call");
1594
+ }
1404
1595
  runtime.finalizedToolCallIds.add(id);
1405
- const rawInput =
1596
+ // The terminal `output_item.done.item.input` is the authoritative
1597
+ // complete input; the streamed `partialJson` buffer is advisory. If both
1598
+ // exist and disagree, the stream was corrupted (dropped/malformed
1599
+ // increments), so fail closed instead of executing either variant —
1600
+ // matching function-call finalization, which always trusts the terminal
1601
+ // `item.arguments`.
1602
+ const streamedInput =
1406
1603
  runtime.currentBlock?.type === "toolCall" && runtime.currentBlock.partialJson
1407
1604
  ? runtime.currentBlock.partialJson
1408
- : (item.input ?? "");
1605
+ : undefined;
1606
+ if (streamedInput !== undefined && streamedInput !== terminalInput) {
1607
+ throw new Error(
1608
+ "Codex custom_tool_call terminal input disagrees with the streamed input buffer; failing the turn instead of executing a corrupted tool call",
1609
+ );
1610
+ }
1611
+ if (
1612
+ runtime.currentBlock?.type === "toolCall" &&
1613
+ runtime.currentBlock.doneInput !== undefined &&
1614
+ runtime.currentBlock.doneInput !== terminalInput
1615
+ ) {
1616
+ throw new Error(
1617
+ "Codex custom_tool_call terminal input disagrees with input.done; failing the turn instead of executing conflicting input",
1618
+ );
1619
+ }
1409
1620
  const toolCall: ToolCall = {
1410
1621
  type: "toolCall",
1411
1622
  id,
1412
1623
  name: item.name,
1413
- arguments: { input: rawInput },
1624
+ arguments: { input: terminalInput },
1414
1625
  customWireName: item.name,
1415
1626
  };
1627
+ Object.assign(runtime.currentBlock, toolCall);
1628
+ delete (runtime.currentBlock as { partialJson?: string }).partialJson;
1629
+ delete (runtime.currentBlock as { doneInput?: string }).doneInput;
1416
1630
  runtime.canSafelyReplayWebsocketOverSse = false;
1417
1631
  stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
1632
+ runtime.currentItem = null;
1633
+ runtime.currentBlock = null;
1418
1634
  return;
1419
1635
  }
1420
1636
 
@@ -1476,6 +1692,12 @@ function handleResponseCompleted(
1476
1692
  // call that never received its `output_item.done` so the agent loop rejects
1477
1693
  // the truncated arguments instead of executing a best-effort partial parse.
1478
1694
  flagTruncatedToolCalls(output, output.stopReason, block => runtime.finalizedToolCallIds.has(block.id));
1695
+ if (
1696
+ output.stopReason === "stop" &&
1697
+ output.content.some(block => block.type === "toolCall" && !runtime.finalizedToolCallIds.has(block.id))
1698
+ ) {
1699
+ throw new Error("Codex response completed with an unfinalized tool call");
1700
+ }
1479
1701
  if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
1480
1702
  output.stopReason = "toolUse";
1481
1703
  }
@@ -1540,6 +1762,7 @@ async function tryRetryWithoutForcedToolChoice(
1540
1762
  runtime.currentBlock = null;
1541
1763
  runtime.sawTerminalEvent = false;
1542
1764
  runtime.nativeOutputItems.length = 0;
1765
+ runtime.finalizedToolCallIds.clear();
1543
1766
  resetOutputState(context.output);
1544
1767
  context.firstTokenTime = undefined;
1545
1768
 
@@ -1559,6 +1782,7 @@ async function tryRetryWithoutForcedToolChoice(
1559
1782
  runtime.requestBodyForState = next.requestBodyForState;
1560
1783
  runtime.sseRequestBodyOverride = next.requestBodyForState;
1561
1784
  runtime.transport = next.transport;
1785
+ runtime.sentPreviousResponseId = false;
1562
1786
  if (websocketState) {
1563
1787
  websocketState.lastTransport = next.transport;
1564
1788
  }
@@ -1635,6 +1859,7 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
1635
1859
  runtime.currentItem = null;
1636
1860
  runtime.currentBlock = null;
1637
1861
  runtime.nativeOutputItems.length = 0;
1862
+ runtime.finalizedToolCallIds.clear();
1638
1863
  resetOutputState(context.output);
1639
1864
  context.firstTokenTime = undefined;
1640
1865
  recordCodexWebSocketFailure(websocketState, true);
@@ -1649,10 +1874,13 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
1649
1874
  }
1650
1875
 
1651
1876
  function isCodexPreviousResponseNotFound(error: unknown): boolean {
1877
+ if (!(error instanceof CodexProviderStreamError)) return false;
1878
+ if (typeof error.code === "string" && CODEX_PREVIOUS_RESPONSE_STALE_CODES.has(error.code)) return true;
1879
+ // Raw provider message only — `error.message` carries appended `code=` metadata
1880
+ // that would supply the stale qualifier the provider never sent.
1652
1881
  return (
1653
- error instanceof CodexProviderStreamError &&
1654
- typeof error.code === "string" &&
1655
- CODEX_PREVIOUS_RESPONSE_STALE_CODES.has(error.code)
1882
+ CODEX_PREVIOUS_RESPONSE_STALE_MESSAGE.test(error.providerMessage) ||
1883
+ CODEX_PREVIOUS_RESPONSE_STALE_PROSE_MESSAGE.test(error.providerMessage)
1656
1884
  );
1657
1885
  }
1658
1886
 
@@ -1664,6 +1892,12 @@ async function tryRecoverCodexPreviousResponseNotFound(
1664
1892
  const websocketState = context.requestContext.websocketState;
1665
1893
  if (
1666
1894
  !isCodexPreviousResponseNotFound(error) ||
1895
+ // An anchor rejection is only actionable when this request actually sent one.
1896
+ // `previous_response_id is required` on an anchor-free request is a genuine
1897
+ // validation fault: clearing session metadata and resending the identical
1898
+ // body cannot fix it.
1899
+ !runtime.sentPreviousResponseId ||
1900
+ runtime.previousResponseRecoveryAttempted ||
1667
1901
  !websocketState ||
1668
1902
  context.options?.fallbackManaged ||
1669
1903
  runtime.transport !== "websocket" ||
@@ -1675,12 +1909,14 @@ async function tryRecoverCodexPreviousResponseNotFound(
1675
1909
  }
1676
1910
 
1677
1911
  runtime.providerRetryAttempt += 1;
1912
+ runtime.previousResponseRecoveryAttempted = true;
1678
1913
  resetCodexWebSocketAppendState(websocketState);
1679
1914
  resetCodexSessionMetadata(websocketState);
1680
1915
  runtime.currentItem = null;
1681
1916
  runtime.currentBlock = null;
1682
1917
  runtime.sawTerminalEvent = false;
1683
1918
  runtime.nativeOutputItems.length = 0;
1919
+ runtime.finalizedToolCallIds.clear();
1684
1920
  resetOutputState(context.output);
1685
1921
  context.firstTokenTime = undefined;
1686
1922
 
@@ -1739,6 +1975,7 @@ async function tryReplayWebsocketFailureOverSse(
1739
1975
  runtime.currentItem = null;
1740
1976
  runtime.currentBlock = null;
1741
1977
  runtime.nativeOutputItems.length = 0;
1978
+ runtime.finalizedToolCallIds.clear();
1742
1979
  resetOutputState(context.output);
1743
1980
  context.firstTokenTime = undefined;
1744
1981
  }
@@ -1779,6 +2016,8 @@ async function tryRetryCodexProviderError(
1779
2016
  runtime.currentItem = null;
1780
2017
  runtime.currentBlock = null;
1781
2018
  runtime.sawTerminalEvent = false;
2019
+ runtime.nativeOutputItems.length = 0;
2020
+ runtime.finalizedToolCallIds.clear();
1782
2021
  resetOutputState(context.output);
1783
2022
  context.firstTokenTime = undefined;
1784
2023
  await scheduler.wait(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, {
@@ -1821,6 +2060,7 @@ function finalizeCodexResponse(
1821
2060
  throw new Error("Codex response failed");
1822
2061
  }
1823
2062
 
2063
+ removeTransientBlockIndices(output);
1824
2064
  output.providerPayload = createOpenAIResponsesHistoryPayload(context.model.provider, runtime.nativeOutputItems);
1825
2065
  output.duration = Date.now() - context.startTime;
1826
2066
  if (completion.firstTokenTime) {
@@ -2290,10 +2530,15 @@ class CodexWebSocketConnection {
2290
2530
  this.#socket = socket;
2291
2531
  let settled = false;
2292
2532
  let timeout: NodeJS.Timeout | undefined;
2533
+ const clearPending = () => {
2534
+ if (timeout) clearTimeout(timeout);
2535
+ if (signal) signal.removeEventListener("abort", onAbort);
2536
+ };
2293
2537
  const onAbort = () => {
2294
2538
  socket.close(1000, "aborted");
2295
2539
  if (!settled) {
2296
2540
  settled = true;
2541
+ clearPending();
2297
2542
  reject(createCodexWebSocketTransportError("request was aborted"));
2298
2543
  }
2299
2544
  };
@@ -2304,17 +2549,16 @@ class CodexWebSocketConnection {
2304
2549
  signal.addEventListener("abort", onAbort, { once: true });
2305
2550
  }
2306
2551
  }
2307
- const clearPending = () => {
2308
- if (timeout) clearTimeout(timeout);
2309
- if (signal) signal.removeEventListener("abort", onAbort);
2310
- };
2311
- timeout = setTimeout(() => {
2312
- socket.close(1000, "connect-timeout");
2313
- if (!settled) {
2314
- settled = true;
2315
- reject(createCodexWebSocketTransportError("connection timeout"));
2316
- }
2317
- }, CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS);
2552
+ if (!settled) {
2553
+ timeout = setTimeout(() => {
2554
+ socket.close(1000, "connect-timeout");
2555
+ if (!settled) {
2556
+ settled = true;
2557
+ clearPending();
2558
+ reject(createCodexWebSocketTransportError("connection timeout"));
2559
+ }
2560
+ }, CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS);
2561
+ }
2318
2562
 
2319
2563
  socket.onopen = event => {
2320
2564
  if (!settled) {
@@ -2352,6 +2596,7 @@ class CodexWebSocketConnection {
2352
2596
  };
2353
2597
  socket.onmessage = event => {
2354
2598
  try {
2599
+ if (!this.#activeRequest) return;
2355
2600
  const text = typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf-8");
2356
2601
  if (!text) return;
2357
2602
  const parsed = JSON.parse(text) as Record<string, unknown>;
@@ -2382,6 +2627,7 @@ class CodexWebSocketConnection {
2382
2627
  request: Record<string, unknown>,
2383
2628
  signal?: AbortSignal,
2384
2629
  firstEventTimeoutMs?: number,
2630
+ idleTimeoutMs = this.#idleTimeoutMs,
2385
2631
  ): AsyncGenerator<Record<string, unknown>> {
2386
2632
  if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
2387
2633
  throw createCodexWebSocketTransportError("websocket connection is unavailable");
@@ -2405,14 +2651,23 @@ class CodexWebSocketConnection {
2405
2651
  try {
2406
2652
  this.#socket.send(JSON.stringify(request));
2407
2653
  let sawFirstProgress = false;
2408
- let lastProgressAt = Date.now();
2654
+ const startedAt = Date.now();
2655
+ let lastProgressAt = startedAt;
2409
2656
  while (true) {
2410
- let timeoutMs = firstEventTimeoutMs;
2657
+ let timeoutMs =
2658
+ firstEventTimeoutMs === undefined ? undefined : firstEventTimeoutMs - (Date.now() - startedAt);
2411
2659
  if (sawFirstProgress) {
2412
- timeoutMs = this.#idleTimeoutMs - (Date.now() - lastProgressAt);
2660
+ timeoutMs = idleTimeoutMs - (Date.now() - lastProgressAt);
2413
2661
  if (timeoutMs <= 0) {
2662
+ this.close("idle-timeout");
2414
2663
  throw createCodexWebSocketTransportError("idle timeout waiting for websocket");
2415
2664
  }
2665
+ } else if (timeoutMs !== undefined && timeoutMs <= 0) {
2666
+ this.close("first-event-timeout");
2667
+ throw createCodexWebSocketTransportError(
2668
+ "timeout waiting for first websocket event",
2669
+ STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
2670
+ );
2416
2671
  }
2417
2672
  const next = await this.#nextMessage(
2418
2673
  timeoutMs,
@@ -2429,20 +2684,19 @@ class CodexWebSocketConnection {
2429
2684
  sawFirstProgress = true;
2430
2685
  lastProgressAt = Date.now();
2431
2686
  }
2432
- yield next;
2433
2687
  const eventType = typeof next.type === "string" ? next.type : "";
2434
- if (
2688
+ const terminal =
2435
2689
  eventType === "response.completed" ||
2436
2690
  eventType === "response.done" ||
2437
2691
  eventType === "response.incomplete" ||
2438
2692
  eventType === "response.failed" ||
2439
- eventType === "error"
2440
- ) {
2441
- break;
2442
- }
2693
+ eventType === "error";
2694
+ yield next;
2695
+ if (terminal) break;
2443
2696
  }
2444
2697
  } finally {
2445
2698
  this.#activeRequest = false;
2699
+ this.#queue.length = 0;
2446
2700
  if (signal) {
2447
2701
  signal.removeEventListener("abort", onAbort);
2448
2702
  }
@@ -2485,9 +2739,9 @@ class CodexWebSocketConnection {
2485
2739
  await promise;
2486
2740
  if (timeout) clearTimeout(timeout);
2487
2741
  if (timedOut && this.#queue.length === 0) {
2488
- if (providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE) {
2489
- this.close("first-event-timeout");
2490
- }
2742
+ this.close(
2743
+ providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE ? "first-event-timeout" : "idle-timeout",
2744
+ );
2491
2745
  return createCodexWebSocketTransportError(timeoutReason, providerCode);
2492
2746
  }
2493
2747
  }
@@ -2590,7 +2844,12 @@ async function openCodexWebSocketEventStream(
2590
2844
  firstEventTimeoutMs?: number,
2591
2845
  ): Promise<AsyncGenerator<Record<string, unknown>>> {
2592
2846
  const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal, options);
2593
- return connection.streamRequest(request, signal, firstEventTimeoutMs);
2847
+ return connection.streamRequest(
2848
+ request,
2849
+ signal,
2850
+ firstEventTimeoutMs,
2851
+ getCodexWebSocketIdleTimeoutMs(options?.streamIdleTimeoutMs),
2852
+ );
2594
2853
  }
2595
2854
 
2596
2855
  function createCodexHeaders(
@@ -2710,8 +2969,27 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
2710
2969
  // `function_call_output` (OpenAI rejects mismatched pairs).
2711
2970
  const customCallIds = new Set<string>();
2712
2971
  const knownCallIds = new Set<string>();
2972
+ // Consecutive tool results are batched into one append call so every output
2973
+ // of the turn stays contiguous before the collected image user message;
2974
+ // per-result image user messages interleave with sibling outputs and break
2975
+ // tool_use→tool_result adjacency through Anthropic-translating proxies (#4807).
2976
+ let pendingToolResults: ToolResultMessage[] = [];
2977
+ const flushPendingToolResults = (): void => {
2978
+ if (pendingToolResults.length === 0) return;
2979
+ appendResponsesToolResultMessages(messages, pendingToolResults, model, false, knownCallIds, customCallIds);
2980
+ pendingToolResults = [];
2981
+ };
2713
2982
 
2714
2983
  for (const msg of transformedMessages) {
2984
+ if (msg.role === "toolResult") {
2985
+ pendingToolResults.push(msg);
2986
+ msgIndex += 1;
2987
+ continue;
2988
+ }
2989
+ // Flush pending tool results before any non-tool-result message so the
2990
+ // batched outputs (and their collected image user message) stay directly
2991
+ // after their assistant tool-call turn, never behind later turns (#4807).
2992
+ flushPendingToolResults();
2715
2993
  if (msg.role === "user" || msg.role === "developer") {
2716
2994
  const providerPayload = (msg as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
2717
2995
  const historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
@@ -2780,15 +3058,9 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
2780
3058
  messages.push(...outputItems);
2781
3059
  }
2782
3060
  msgIndex += 1;
2783
- continue;
2784
3061
  }
2785
-
2786
- if (msg.role === "toolResult") {
2787
- appendResponsesToolResultMessages(messages, msg, model, false, knownCallIds, customCallIds);
2788
- }
2789
-
2790
- msgIndex += 1;
2791
3062
  }
3063
+ flushPendingToolResults();
2792
3064
 
2793
3065
  return messages;
2794
3066
  }
@@ -2897,12 +3169,20 @@ function getCodexEventErrorMessage(rawEvent: Record<string, unknown>): string {
2897
3169
  class CodexProviderStreamError extends Error {
2898
3170
  readonly retryable: boolean;
2899
3171
  readonly code?: string;
2900
-
2901
- constructor(message: string, retryable: boolean, code?: string) {
3172
+ /**
3173
+ * Provider-supplied message, before display formatting appends `code=`/`status=`
3174
+ * metadata. Classification must read this, never `message`: the formatted string
3175
+ * mixes the code into the prose and would let `code=invalid_request_error`
3176
+ * satisfy a message pattern the provider never actually sent.
3177
+ */
3178
+ readonly providerMessage: string;
3179
+
3180
+ constructor(message: string, retryable: boolean, code: string | undefined, providerMessage: string) {
2902
3181
  super(message);
2903
3182
  this.name = "CodexProviderStreamError";
2904
3183
  this.retryable = retryable;
2905
3184
  this.code = code;
3185
+ this.providerMessage = providerMessage;
2906
3186
  }
2907
3187
  }
2908
3188
 
@@ -2928,7 +3208,12 @@ function createCodexProviderStreamError(rawEvent: Record<string, unknown>): Code
2928
3208
  typeof rawEvent.type === "string" && rawEvent.type === "error"
2929
3209
  ? formatCodexErrorEvent(rawEvent, code, message)
2930
3210
  : (formatCodexFailure(rawEvent) ?? "Codex response failed");
2931
- return new CodexProviderStreamError(formattedMessage, isRetryableCodexFailureEvent(rawEvent), code || undefined);
3211
+ return new CodexProviderStreamError(
3212
+ formattedMessage,
3213
+ isRetryableCodexFailureEvent(rawEvent),
3214
+ code || undefined,
3215
+ message,
3216
+ );
2932
3217
  }
2933
3218
 
2934
3219
  function isRetryableCodexProviderError(error: unknown): boolean {