@oh-my-pi/pi-ai 16.1.9 → 16.1.11

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.11] - 2026-06-21
6
+
7
+ ### Fixed
8
+
9
+ - Fixed OpenAI Responses native history replay leaking image generation provider-only fields into the next request, which made OpenAI-compatible proxies reject `pi` tool-calling sessions with `Unknown parameter: input[1].action`. ([#3201](https://github.com/can1357/oh-my-pi/issues/3201))
10
+ - Fixed a stream thought-leakage issue for `gemini-3.5-flash` where the model's internal reasoning JSON could leak into the visible text stream. The stream parser now uses a brace-balanced counting algorithm to accurately slice and discard the leading thought JSON block, with a robust fallback for unescaped double quotes, dynamic tool-name derivation, and preservation of subsequent text deltas without triggering empty-response retries.
11
+
12
+ ## [16.1.10] - 2026-06-21
13
+
14
+ ### Changed
15
+
16
+ - Improved JSON robustness by replacing external dependency with a custom, high-performance parser
17
+ - Strengthened streaming JSON parsing to prevent non-finite numbers from surfacing as `undefined/NaN`
18
+ - Configured JSON parser to reject JS-specific `NaN` and `Infinity` values for tool arguments
19
+ - Replaced the JSON repair/parse helpers (`parseJsonWithRepair`, `parseStreamingJson`) with a single from-scratch tolerant parser (`RelaxedJson`) that accepts single-quoted strings, unquoted object keys, trailing/stray commas, `//` and `/* */` comments, Python `True`/`False`/`None`, raw control characters, invalid escapes, and unescaped apostrophes (`'it's'`). Final parsing still throws on truncated/garbage input (so a malformed tool call is skipped rather than executed with half-formed args) and rejects JS-only `NaN`/`Infinity`; streaming parsing stays non-throwing and rolls back incomplete trailing tokens instead of surfacing `undefined`/`NaN`. The Cursor provider's ad-hoc regex + JSON5 tool-argument parser now routes through the shared parser.
20
+
21
+ ### Fixed
22
+
23
+ - Fixed tool call ID normalization for Anthropic-compatible models
24
+ - Fixed Anthropic Messages replay sanitizing malformed tool-call IDs, including aborted native tool calls with empty IDs, so retries no longer send invalid `tool_use.id` / `tool_result.tool_use_id` pairs.
25
+ - Fixed the Codex Responses WebSocket transport attributing a prior turn's output to the current one on a reused connection: a trailing/duplicate frame from a cleanly-completed previous response that slipped past the queue drain could be consumed as this request's terminal (ending the turn with empty output) or as a stale tool call. Frames are now keyed by `response.id` — a frame carrying the previous response's id is dropped, and one carrying a third id (or a regressed `sequence_number`) fails closed so the turn retries instead of mixing two responses' streams. Idless frames (deltas, the rate-limit/metadata preamble, `response.created`-less streams) still pass through, matching upstream codex-rs.
26
+ - Fixed `transformMessages` pulling an earlier, orphaned tool result onto a later tool call that reused the same id (left behind when compaction folded the originating `tool_use` into a summary). The pending-call flush now pairs each call with a result positioned *after* its assistant turn, so a reused id surfaces its own output rather than a prior turn's.
27
+ - Fixed DashScope 429 rate-limit messages that mention authorization being classified as credential failures, preventing valid API keys from being invalidated after throttling. ([#3172](https://github.com/can1357/oh-my-pi/issues/3172))
28
+ - Fixed OpenCode Go `401 Insufficient balance` quota errors being treated as unknown failures instead of usage-limit errors, restoring credential rotation and fallback chains. ([#3169](https://github.com/can1357/oh-my-pi/issues/3169))
29
+
30
+ ### Removed
31
+
32
+ - Removed the `partial-json` dependency; streaming JSON parsing now uses the in-house `RelaxedJson` parser.
33
+
5
34
  ## [16.1.9] - 2026-06-21
6
35
 
7
36
  ### Added
@@ -1,11 +1,22 @@
1
+ /**
2
+ * Lightweight string-level repair of the escape/control-char hazards that make
3
+ * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
4
+ * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
5
+ * input unchanged when no repair is needed. Pure string→string; does not parse.
6
+ */
1
7
  export declare function repairJson(json: string): string;
8
+ /**
9
+ * Final-parse a JSON value, repairing the common LLM malformations
10
+ * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
11
+ * semantics), then the relaxed parser. Throws when the input is unrepairable,
12
+ * truncated, or carries trailing garbage — so callers can skip a bad tool call
13
+ * rather than execute a half-formed one.
14
+ */
2
15
  export declare function parseJsonWithRepair<T>(json: string): T;
3
16
  /**
4
- * Attempts to parse potentially incomplete JSON during streaming.
5
- * Always returns a valid object, even if the JSON is incomplete.
6
- *
7
- * @param partialJson The partial JSON string from streaming
8
- * @returns Parsed object or empty object if parsing fails
17
+ * Parse possibly-incomplete JSON during streaming. Always returns a value, never
18
+ * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
19
+ * best-effort object for truncated ones.
9
20
  */
10
21
  export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
11
22
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.1.9",
4
+ "version": "16.1.11",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,11 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.9",
42
- "@oh-my-pi/pi-utils": "16.1.9",
43
- "@oh-my-pi/pi-wire": "16.1.9",
41
+ "@oh-my-pi/pi-catalog": "16.1.11",
42
+ "@oh-my-pi/pi-utils": "16.1.11",
43
+ "@oh-my-pi/pi-wire": "16.1.11",
44
44
  "arktype": "^2.2.0",
45
- "partial-json": "^0.1.7",
46
45
  "zod": "^4"
47
46
  },
48
47
  "devDependencies": {
@@ -229,11 +229,10 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
229
229
  if (/\baborted\b|\babort signal\b/i.test(message)) {
230
230
  return { status: 499, type: "request_aborted", message };
231
231
  }
232
- if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
233
- return { status: 401, type: "authentication_error", message };
234
- }
235
232
  if (
236
- // Match rate-limit phrasings without colliding with
233
+ // Match rate-limit phrasings before auth wording: some providers
234
+ // describe throttling as "unauthorized due to rate limit".
235
+ // Keep boundaries so this does not collide with
237
236
  // `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
238
237
  /\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
239
238
  message,
@@ -249,6 +248,9 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
249
248
  ) {
250
249
  return { status: 429, type: "rate_limit_error", message };
251
250
  }
251
+ if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
252
+ return { status: 401, type: "authentication_error", message };
253
+ }
252
254
  if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
253
255
  return { status: 400, type: "invalid_request_error", message };
254
256
  }
@@ -125,7 +125,7 @@ import type {
125
125
  } from "../types";
126
126
  import { normalizeSystemPrompts } from "../utils";
127
127
  import { AssistantMessageEventStream } from "../utils/event-stream";
128
- import { parseStreamingJson } from "../utils/json-parse";
128
+ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
129
129
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
130
130
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
131
131
  import { toolWireSchema } from "../utils/schema/wire";
@@ -1781,13 +1781,10 @@ function parseToolArgsJson(text: string): unknown {
1781
1781
  return text;
1782
1782
  }
1783
1783
  try {
1784
- const normalized = trimmed
1785
- .replace(/\bNone\b/g, "null")
1786
- .replace(/\bTrue\b/g, "true")
1787
- .replace(/\bFalse\b/g, "false");
1788
- return Bun.JSON5.parse(normalized);
1789
- } catch {}
1790
- return text;
1784
+ return parseJsonWithRepair<unknown>(trimmed);
1785
+ } catch {
1786
+ return text;
1787
+ }
1791
1788
  }
1792
1789
 
1793
1790
  function decodeMcpArgValue(value: Uint8Array): unknown {
@@ -64,6 +64,181 @@ export class GeminiCliApiError extends ProviderHttpError {
64
64
  override readonly name = "GeminiCliApiError";
65
65
  }
66
66
 
67
+ function isPlanningLeakPrefix(text: string): boolean {
68
+ const trimmed = text.trimStart();
69
+ if (!trimmed.startsWith("{")) {
70
+ return false;
71
+ }
72
+ const afterBrace = trimmed.slice(1).trimStart();
73
+ if (afterBrace === "") {
74
+ return trimmed.length <= 100;
75
+ }
76
+ if (afterBrace[0] !== '"') {
77
+ return false;
78
+ }
79
+ const nextQuoteIndex = afterBrace.indexOf('"', 1);
80
+ if (nextQuoteIndex === -1) {
81
+ const keyPrefix = afterBrace.slice(1);
82
+ return "thought".startsWith(keyPrefix) && trimmed.length <= 100;
83
+ }
84
+ const key = afterBrace.slice(1, nextQuoteIndex);
85
+ if (key !== "thought") {
86
+ return false;
87
+ }
88
+ const afterKey = afterBrace.slice(nextQuoteIndex + 1).trimStart();
89
+ if (afterKey === "") {
90
+ return trimmed.length <= 100;
91
+ }
92
+ if (afterKey[0] !== ":") {
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ type BufferedPlanningResult =
99
+ | { kind: "incomplete" }
100
+ | { kind: "plain"; visibleText: string }
101
+ | { kind: "leak"; visibleText: string };
102
+
103
+ function isPlanningLeakObject(parsed: unknown, toolNames: Set<string>): boolean {
104
+ if (!parsed || typeof parsed !== "object") return false;
105
+ const record = parsed as Record<string, unknown>;
106
+ const hasThought = typeof record.thought === "string";
107
+ const isOmpTool = typeof record.call === "string" && toolNames.has(record.call);
108
+ const hasToolSignature =
109
+ "_i" in record || "paths" in record || "command" in record || ("path" in record && "content" in record);
110
+ return hasThought || isOmpTool || hasToolSignature;
111
+ }
112
+
113
+ function splitLeadingJsonObject(text: string): { prefixLength: number; jsonText: string; rest: string } | undefined {
114
+ const prefixLength = text.length - text.trimStart().length;
115
+ const trimmed = text.slice(prefixLength);
116
+ if (!trimmed.startsWith("{")) return undefined;
117
+
118
+ let depth = 0;
119
+ let inString = false;
120
+ let escaped = false;
121
+
122
+ for (let index = 0; index < trimmed.length; index += 1) {
123
+ const ch = trimmed[index];
124
+ if (inString) {
125
+ if (escaped) {
126
+ escaped = false;
127
+ continue;
128
+ }
129
+ if (ch === "\\") {
130
+ escaped = true;
131
+ continue;
132
+ }
133
+ if (ch === '"') inString = false;
134
+ continue;
135
+ }
136
+ if (ch === '"') {
137
+ inString = true;
138
+ continue;
139
+ }
140
+ if (ch === "{") {
141
+ depth += 1;
142
+ continue;
143
+ }
144
+ if (ch !== "}") continue;
145
+ depth -= 1;
146
+ if (depth !== 0) continue;
147
+
148
+ const jsonText = trimmed.slice(0, index + 1);
149
+ return {
150
+ prefixLength: prefixLength + index + 1,
151
+ jsonText,
152
+ rest: trimmed.slice(index + 1),
153
+ };
154
+ }
155
+
156
+ return undefined;
157
+ }
158
+
159
+ function splitLeadingJsonObjectIgnoringQuotes(
160
+ text: string,
161
+ ): { prefixLength: number; jsonText: string; rest: string } | undefined {
162
+ const prefixLength = text.length - text.trimStart().length;
163
+ const trimmed = text.slice(prefixLength);
164
+ if (!trimmed.startsWith("{")) return undefined;
165
+
166
+ let depth = 0;
167
+ for (let index = 0; index < trimmed.length; index += 1) {
168
+ const ch = trimmed[index];
169
+ if (ch === "{") {
170
+ depth += 1;
171
+ } else if (ch === "}") {
172
+ depth -= 1;
173
+ if (depth === 0) {
174
+ return {
175
+ prefixLength: prefixLength + index + 1,
176
+ jsonText: trimmed.slice(0, index + 1),
177
+ rest: trimmed.slice(index + 1),
178
+ };
179
+ }
180
+ }
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ function consumePlanningBuffer(text: string, toolNames: Set<string>, isFinal = false): BufferedPlanningResult {
186
+ if (!isPlanningLeakPrefix(text)) {
187
+ return { kind: "plain", visibleText: text };
188
+ }
189
+
190
+ // Try standard brace-balanced slicing first (respecting quotes and escapes)
191
+ let leading = splitLeadingJsonObject(text);
192
+
193
+ // If standard parsing fails (e.g. due to unescaped quotes), fall back to quote-ignoring brace-balanced slicing
194
+ if (!leading) {
195
+ leading = splitLeadingJsonObjectIgnoringQuotes(text);
196
+ }
197
+
198
+ if (!leading) {
199
+ if (isFinal) {
200
+ // At EOF, if the buffer has a leak signature but no closing brace at all, discard the whole buffer.
201
+ const trimmed = text.trim();
202
+ const hasThoughtKey = trimmed.includes('"thought"');
203
+ const hasToolKey = Array.from(toolNames).some(name => trimmed.includes(`"${name}"`));
204
+ const hasToolSignature =
205
+ trimmed.includes('"_i"') ||
206
+ trimmed.includes('"paths"') ||
207
+ trimmed.includes('"command"') ||
208
+ (trimmed.includes('"path"') && trimmed.includes('"content"'));
209
+ if (hasThoughtKey || hasToolKey || hasToolSignature) {
210
+ return { kind: "leak", visibleText: "" };
211
+ }
212
+ return { kind: "plain", visibleText: text };
213
+ }
214
+ return { kind: "incomplete" };
215
+ }
216
+
217
+ let parsed: unknown;
218
+ try {
219
+ parsed = JSON.parse(leading.jsonText);
220
+ } catch {
221
+ // Fallback to substring matching if JSON parsing fails due to unescaped quotes
222
+ const hasThoughtKey = leading.jsonText.includes('"thought"');
223
+ const hasToolKey = Array.from(toolNames).some(name => leading.jsonText.includes(`"${name}"`));
224
+ const hasToolSignature =
225
+ leading.jsonText.includes('"_i"') ||
226
+ leading.jsonText.includes('"paths"') ||
227
+ leading.jsonText.includes('"command"') ||
228
+ (leading.jsonText.includes('"path"') && leading.jsonText.includes('"content"'));
229
+ const isLeak = hasThoughtKey || hasToolKey || hasToolSignature;
230
+ if (isLeak) {
231
+ return { kind: "leak", visibleText: leading.rest };
232
+ }
233
+ // Unparseable leading object is not safe to strip; release it as normal text.
234
+ return { kind: "plain", visibleText: text };
235
+ }
236
+
237
+ return isPlanningLeakObject(parsed, toolNames)
238
+ ? { kind: "leak", visibleText: leading.rest }
239
+ : { kind: "plain", visibleText: text };
240
+ }
241
+
67
242
  export interface GoogleGeminiCliOptions extends StreamOptions {
68
243
  /**
69
244
  * Tool selection mode. String forms map directly to Gemini
@@ -463,6 +638,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
463
638
  const firstEventTimeoutMs =
464
639
  options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(undefined, 300_000);
465
640
  const callerSignal = options?.signal;
641
+ const toolNames = new Set(context.tools?.map(t => t.name) ?? []);
642
+ const isFlashLeakModel = model.id.includes("flash");
466
643
 
467
644
  let started = false;
468
645
  let sawFinishReason = false;
@@ -504,6 +681,22 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
504
681
  const blocks = output.content;
505
682
  const blockIndex = () => blocks.length - 1;
506
683
 
684
+ let isBuffering = false;
685
+ let textBuffer = "";
686
+ let bufferedTextSignature: string | undefined;
687
+
688
+ const emitVisibleText = (delta: string, thoughtSignature?: string) => {
689
+ if (!delta || !currentBlock || currentBlock.type !== "text") return;
690
+ currentBlock.text += delta;
691
+ currentBlock.textSignature = retainThoughtSignature(currentBlock.textSignature, thoughtSignature);
692
+ stream.push({
693
+ type: "text_delta",
694
+ contentIndex: blockIndex(),
695
+ delta,
696
+ partial: output,
697
+ });
698
+ };
699
+
507
700
  for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
508
701
  activeResponse.body!,
509
702
  options?.signal,
@@ -554,17 +747,33 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
554
747
  partial: output,
555
748
  });
556
749
  } else {
557
- currentBlock.text += part.text;
558
- currentBlock.textSignature = retainThoughtSignature(
559
- currentBlock.textSignature,
560
- part.thoughtSignature,
561
- );
562
- stream.push({
563
- type: "text_delta",
564
- contentIndex: blockIndex(),
565
- delta: part.text,
566
- partial: output,
567
- });
750
+ if (isBuffering) {
751
+ textBuffer += part.text;
752
+ bufferedTextSignature = retainThoughtSignature(
753
+ bufferedTextSignature,
754
+ part.thoughtSignature,
755
+ );
756
+ } else if (isFlashLeakModel && part.text.trimStart().startsWith("{")) {
757
+ isBuffering = true;
758
+ textBuffer = part.text;
759
+ bufferedTextSignature = part.thoughtSignature;
760
+ } else {
761
+ emitVisibleText(part.text, part.thoughtSignature);
762
+ }
763
+
764
+ if (isBuffering) {
765
+ const buffered = consumePlanningBuffer(textBuffer, toolNames);
766
+ if (buffered.kind !== "incomplete") {
767
+ if (buffered.kind === "leak") {
768
+ sawLeak = true;
769
+ }
770
+ const visibleSignature = bufferedTextSignature;
771
+ isBuffering = false;
772
+ textBuffer = "";
773
+ bufferedTextSignature = undefined;
774
+ emitVisibleText(buffered.visibleText, visibleSignature);
775
+ }
776
+ }
568
777
  }
569
778
  } else if (part.text === "" && part.thoughtSignature && currentBlock && !part.functionCall) {
570
779
  if (currentBlock.type === "thinking") {
@@ -585,6 +794,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
585
794
  pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
586
795
  currentBlock = null;
587
796
  }
797
+ isBuffering = false;
798
+ textBuffer = "";
588
799
 
589
800
  const providedId = part.functionCall.id;
590
801
  const needsNewId =
@@ -645,14 +856,28 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
645
856
  }
646
857
  }
647
858
 
859
+ if (isBuffering && textBuffer !== "") {
860
+ const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
861
+ if (buffered.kind === "leak") {
862
+ sawLeak = true;
863
+ }
864
+ if (buffered.kind !== "incomplete") {
865
+ emitVisibleText(buffered.visibleText, bufferedTextSignature);
866
+ }
867
+ bufferedTextSignature = undefined;
868
+ isBuffering = false;
869
+ textBuffer = "";
870
+ }
871
+
648
872
  if (currentBlock) {
649
873
  pushBlockEndEvent(currentBlock, blockIndex(), output, stream);
650
874
  }
651
875
 
652
- return hasMeaningfulGoogleContent(output);
876
+ return hasMeaningfulGoogleContent(output) || sawLeak;
653
877
  };
654
878
 
655
879
  let receivedContent = false;
880
+ let sawLeak = false;
656
881
 
657
882
  for (let i = 0; i < endpoints.length; i++) {
658
883
  const endpoint = endpoints[i];
@@ -205,6 +205,17 @@ function isCodexStreamProgressEvent(event: unknown): boolean {
205
205
  return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
206
206
  }
207
207
 
208
+ function extractCodexFrameResponseId(frame: Record<string, unknown>): string | undefined {
209
+ const response = (frame as { response?: { id?: unknown } }).response;
210
+ const id = response?.id;
211
+ return typeof id === "string" && id.length > 0 ? id : undefined;
212
+ }
213
+
214
+ function extractCodexFrameSequenceNumber(frame: Record<string, unknown>): number | undefined {
215
+ const raw = (frame as { sequence_number?: unknown }).sequence_number;
216
+ return typeof raw === "number" && Number.isFinite(raw) ? Math.trunc(raw) : undefined;
217
+ }
218
+
208
219
  type CodexWebSocketTimeoutDetails = {
209
220
  lastEventAt: number;
210
221
  lastEventType?: string;
@@ -2403,6 +2414,12 @@ class CodexWebSocketConnection {
2403
2414
  #lastInboundAt = 0;
2404
2415
  /** Wall-clock of the last heartbeat ping we issued; 0 if none yet. */
2405
2416
  #lastPingAt = 0;
2417
+ /**
2418
+ * Most recent `response.id` accepted on this socket, retained across
2419
+ * requests. Lets the next request drop a trailing/duplicate frame from the
2420
+ * previous (cleanly-completed) response that outlived the queue drain.
2421
+ */
2422
+ #lastSeenResponseId?: string;
2406
2423
 
2407
2424
  constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
2408
2425
  this.#url = url;
@@ -2650,6 +2667,11 @@ class CodexWebSocketConnection {
2650
2667
  let lastProgressEventType: string | undefined;
2651
2668
  let lastEventAt = lastProgressAt;
2652
2669
  let lastEventType: string | undefined;
2670
+ // Cross-request frame guard: lock onto this response's id and reject
2671
+ // frames belonging to another response interleaved on the reused socket.
2672
+ let activeResponseId: string | undefined;
2673
+ let lastSequence: number | undefined;
2674
+ const priorResponseId = this.#lastSeenResponseId;
2653
2675
  while (true) {
2654
2676
  let timeoutMs: number | undefined;
2655
2677
  let timeoutReason: string;
@@ -2691,8 +2713,51 @@ class CodexWebSocketConnection {
2691
2713
  if (next === null) {
2692
2714
  throw new CodexWebSocketTransportError(`websocket closed before response completion`);
2693
2715
  }
2694
- sawFirstEvent = true;
2695
2716
  const eventType = typeof next.type === "string" ? next.type : "";
2717
+ // Cross-request frame guard. The socket is reused across turns. Upstream
2718
+ // codex-rs leans on the protocol guarantee that nothing follows a
2719
+ // response's terminal event, but our queue can still surface a trailing
2720
+ // or duplicate frame from a cleanly-completed prior response after
2721
+ // #dropStaleFrames() drained the queue at send time. Attaching such a
2722
+ // frame to THIS turn misattributes an earlier turn's output (a stale
2723
+ // `response.completed` ends the turn early; a stale item makes the model
2724
+ // see an unrelated call). Only lifecycle events (created/completed/
2725
+ // failed/incomplete) carry a `response.id` — exactly the harmful ones —
2726
+ // so key the guard on it and let idless frames (deltas, the rate-limit/
2727
+ // metadata preamble, created-less streams) pass through, matching
2728
+ // upstream rather than gating on `response.created`.
2729
+ const frameResponseId = extractCodexFrameResponseId(next);
2730
+ const frameSequence = extractCodexFrameSequenceNumber(next);
2731
+ if (frameResponseId !== undefined) {
2732
+ if (activeResponseId === undefined) {
2733
+ if (priorResponseId !== undefined && frameResponseId === priorResponseId) {
2734
+ // Trailing/duplicate frame of the previous response that
2735
+ // outlived the drain. Drop without locking or advancing the
2736
+ // first-event clocks so our own response can still start.
2737
+ continue;
2738
+ }
2739
+ activeResponseId = frameResponseId;
2740
+ } else if (frameResponseId !== activeResponseId) {
2741
+ // A different response is interleaving on the socket; the idless
2742
+ // deltas that follow are indistinguishable, so fail closed
2743
+ // (retryable) instead of risking misattribution.
2744
+ this.close("stale-frame");
2745
+ throw new CodexWebSocketTransportError(
2746
+ `websocket frame for response ${frameResponseId} interleaved into active response ${activeResponseId}`,
2747
+ );
2748
+ }
2749
+ this.#lastSeenResponseId = frameResponseId;
2750
+ }
2751
+ if (frameSequence !== undefined) {
2752
+ if (activeResponseId !== undefined && lastSequence !== undefined && frameSequence < lastSequence) {
2753
+ this.close("stale-frame");
2754
+ throw new CodexWebSocketTransportError(
2755
+ `websocket sequence_number ${frameSequence} regressed below ${lastSequence} within response ${activeResponseId}`,
2756
+ );
2757
+ }
2758
+ lastSequence = frameSequence;
2759
+ }
2760
+ sawFirstEvent = true;
2696
2761
  lastEventAt = Date.now();
2697
2762
  lastEventType = eventType || undefined;
2698
2763
  if (isCodexStreamProgressEvent(next)) {