From fd3bbbf11cd6a5e34923c81b76a286dd477129d7 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:32:16 +0900 Subject: [PATCH 1/2] fix(responses): finalize adopted WebSocket stage records --- src/server/responses/codex-ws-wire.ts | 5 ++ structure/adapters/registry.md | 2 +- structure/catalog.md | 2 +- structure/clients/claude-desktop.md | 2 +- structure/data-planes/images.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/service-and-sidecars.md | 2 +- structure/providers/xai-grok.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 2 +- structure/transports/streaming-health.md | 2 +- tests/responses/ws-failure-stage.test.ts | 80 ++++++++++++++++++++---- 16 files changed, 87 insertions(+), 26 deletions(-) diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index db2c6f6d30..35764c8352 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -108,6 +108,11 @@ export type CodexWsStageRecord = Omit & { const codexWsStageByResponse = new WeakMap(); export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void { + const current = codexWsStageByResponse.get(response); + if (current) { + Object.assign(current, record); + return; + } codexWsStageByResponse.set(response, record); } diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 908633f265..af89a25a39 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -3,7 +3,7 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). ## Decision diff --git a/structure/catalog.md b/structure/catalog.md index 47a827426b..939c82a823 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -3,7 +3,7 @@ The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. -Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). ## Shared catalog diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index f64a278757..045f6b3cd7 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -6,7 +6,7 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior Codex-native model discovery follows the [shared retirement policy](../catalog.md#shared-catalog). That projection does not migrate existing user-selected Desktop configuration or usage history. -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). Claude-only connections keep their existing non-failing readiness policy; displayed catalog reasons follow the [terminal rendering contract](../runtime.md#cli-readiness-diagnostics) whether they surface at connect time or on a later refresh. diff --git a/structure/data-planes/images.md b/structure/data-planes/images.md index fdbdee6b32..a0ed6c8c98 100644 --- a/structure/data-planes/images.md +++ b/structure/data-planes/images.md @@ -7,7 +7,7 @@ Hosted Responses image-tool eligibility uses the shared compatibility policy wit Codex Spark exception; standalone Images retain the separate relay contract below. See [Responses transport](../transports/responses.md#responses-httpsse). -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). ## Standalone Images diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index aa9aa15f52..c031e07c4e 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -47,7 +47,7 @@ Translated Claude timeline reminders use the Chat adapter's on its exact supported route. This is separate from trailing-notice stabilization and from native Chat message passthrough. -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). ## Chat Completions inbound native path diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 0496735405..e6382b8804 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,7 +1,7 @@ # GUI And Management API The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). ## Dashboard serving diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index e9d8fb7c00..63cc338476 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -6,7 +6,7 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior Service startup and restore use the [catalog retirement policy](../catalog.md#shared-catalog); retirement does not itself change service registration or user-selected model configuration. -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). ## Background service command selection diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 0ca554f4e0..41009fc499 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -7,7 +7,7 @@ Codex-native retirement is scoped to OpenAI catalog/quota evidence. Shared Respo retains xAI provider behavior; see [the catalog boundary](../catalog.md#shared-catalog). -Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](../transports/responses.md#passthrough-sse-stream-shapes-314). ## xAI Grok hardening (official Grok Build contract parity) diff --git a/structure/runtime.md b/structure/runtime.md index e4a50ba3f8..3a6847eb0e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -7,7 +7,7 @@ Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); it requires no runtime lifecycle change or new configuration option. -Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). ## CLI readiness diagnostics diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..b76389bfe9 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -25,7 +25,7 @@ Codex treats qualified names literally and defaults absent namespaces to functio declarations inherit their restored namespace container; the compiler never invents an empty encryption marker when the upstream omitted it or returned a nonempty marker. -Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](transports/byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](transports/responses.md#passthrough-sse-stream-shapes-314). ## Multi-agent surface mode (3-state) diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index e758afeaf2..c6f97cb073 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -2,7 +2,7 @@ How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. +the translator budget, which is why so many documents link here rather than restating them. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 8c04f7b633..41785608dd 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -6,7 +6,7 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. -Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). ## Transport inventory diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..cadb6bebe9 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -450,7 +450,7 @@ caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels an already-sent create before the prelude timer fires. These are transport-fidelity guarantees, not a provider-billing guarantee. -Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed and first-frame durations, frame counters, liveness ping/pong counts, pool reuse, and the OCX/Bun versions. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: the no-replay-after-send contract stands regardless of what it says. +Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed and first-frame durations, frame counters, liveness ping/pong counts, pool reuse, and the OCX/Bun versions. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: the no-replay-after-send contract stands regardless of what it says. Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. Model/tier and immutable diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index 0b54c97058..e1f97c4f4f 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -10,7 +10,7 @@ removing support for non-default WebSocket quota families. Key-auth hosted-search continuations validate account selection after pacing and report a failed terminal on drift; see [continuation binding contract](../runtime.md#hosted-search-continuation-binding). -Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. +Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). ## Heartbeat and stall deadline diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index f2951938af..539856b6f0 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -304,29 +304,85 @@ describe("codex ws stage record marker (#4191)", () => { expect(readCodexWsStage(response)).toEqual(stage); }); - test("a committed exchange ends with the final counters on its stage record", async () => { + test("updating one response preserves its adopted record and leaves another response unchanged", () => { + const first = new Response("first"); + const second = new Response("second"); + markCodexWsStage(first, { ...stage }); + markCodexWsStage(second, { ...stage, reused: true }); + const firstAdopted = readCodexWsStage(first); + const secondAdopted = readCodexWsStage(second); + const finalStage = { ...stage, requestBytes: null, closeCode: null, upstreamFrames: 5, relayedEvents: 4 }; + + markCodexWsStage(first, finalStage); + + expect(readCodexWsStage(first)).toBe(firstAdopted); + expect(firstAdopted).toEqual(finalStage); + expect(readCodexWsStage(second)).toBe(secondAdopted); + expect(secondAdopted).not.toBe(firstAdopted); + expect(secondAdopted).toEqual({ ...stage, reused: true }); + }); + + test("a successful exchange finalizes the stage reference adopted before its terminal", async () => { installFake(ws => { ws.emit("open", {}); ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); - ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); }); - const noFallback = async () => { - throw new Error("fallback must not run after open"); - }; const response = await codexWsUpstreamFetch( CODEX_URL, streamingInit(), noFallback as unknown as typeof fetch, BOUNDED_WS_RUNTIME, ); - expect(response.status).toBe(200); + // handleResponses keeps this reference when the Response resolves, before the body settles. + const adopted = readCodexWsStage(response); + const ws = FakeWebSocket.instances[0]!; + ws.emit("message", { data: JSON.stringify({ + type: "response.output_text.delta", delta: "hi", item_id: "m1", output_index: 0, content_index: 0, + }) }); + ws.emit("message", { data: JSON.stringify({ type: "response.completed", response: { id: "r1" } }) }); await response.text(); - const stage = readCodexWsStage(response); - expect(stage).toBeDefined(); - expect(stage?.requestBytes).toBeNull(); - expect(stage?.closeCode).toBeNull(); - expect(stage?.sent).toBe(true); - expect(stage?.relayedEvents).toBeGreaterThan(0); + + expect(response.status).toBe(200); + expect(readCodexWsStage(response)).toBe(adopted); + expect(adopted).toBeDefined(); + expect(adopted?.requestBytes).toBeNull(); + expect(adopted?.closeCode).toBeNull(); + expect(adopted?.sent).toBe(true); + expect(adopted?.upstreamFrames).toBe(3); + expect(adopted?.relayedEvents).toBe(3); + }); + + test("a body failure finalizes the stage reference adopted before the socket closes", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + }); + const response = await codexWsUpstreamFetch( + CODEX_URL, + streamingInit(), + noFallback as unknown as typeof fetch, + BOUNDED_WS_RUNTIME, + ); + const adopted = readCodexWsStage(response); + const committedBytes = adopted?.requestBytes; + const committedCloseCode = adopted?.closeCode; + const ws = FakeWebSocket.instances[0]!; + const failure = failureMessageOf(response); + ws.emit("message", { data: JSON.stringify({ + type: "response.output_text.delta", delta: "hi", item_id: "m1", output_index: 0, content_index: 0, + }) }); + ws.emit("close", { code: 1006 }); + const message = await failure; + + expect(response.status).toBe(200); + expect(committedBytes).toBeNull(); + expect(committedCloseCode).toBeNull(); + expect(message).toContain("closed before a Responses terminal event (close 1006)"); + expect(readCodexWsStage(response)).toBe(adopted); + expect(adopted?.requestBytes).toBe(Buffer.byteLength(ws.sent[0]!, "utf8")); + expect(adopted?.closeCode).toBe(1006); + expect(adopted?.upstreamFrames).toBe(2); + expect(adopted?.relayedEvents).toBe(2); }); test("the serialized record is numeric/boolean/semver only", () => { From 5227e2b2c709db1fe20329107ac5af9fc6e1c23f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:18:20 +0900 Subject: [PATCH 2/2] fix: finalize websocket stage before cancel usage logging --- src/server/relay-eager.ts | 2 + structure/transports/responses.md | 2 +- tests/responses/ws-failure-stage.test.ts | 75 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts index 8439aea85d..e8388cb709 100644 --- a/src/server/relay-eager.ts +++ b/src/server/relay-eager.ts @@ -466,6 +466,8 @@ export function relaySseEagerBounded( else hooks.onSynthetic(syntheticKind, syntheticReason); } if (cancelled && !hooks.sawTerminal()) { + // Finalize transport telemetry before the cancellation hook persists its usage row. + upstream.abort(); hooks.onClientCancel(); } if (cancelled || upstream.signal.aborted || syntheticKind === "failed" || deliveryFallbackSent) { diff --git a/structure/transports/responses.md b/structure/transports/responses.md index cadb6bebe9..ddaf93e551 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -450,7 +450,7 @@ caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels an already-sent create before the prelude timer fires. These are transport-fidelity guarantees, not a provider-billing guarantee. -Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed and first-frame durations, frame counters, liveness ping/pong counts, pool reuse, and the OCX/Bun versions. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: the no-replay-after-send contract stands regardless of what it says. +Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed and first-frame durations, frame counters, liveness ping/pong counts, pool reuse, and the OCX/Bun versions. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. On eager-relay cancel-drain expiry, upstream cancellation finalizes the transport snapshot before the cancellation hook writes the usage row; an actual terminal observed within the drain still wins over cancellation. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: the no-replay-after-send contract stands regardless of what it says. Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. Model/tier and immutable diff --git a/tests/responses/ws-failure-stage.test.ts b/tests/responses/ws-failure-stage.test.ts index 539856b6f0..d4af512669 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { relaySseEagerBounded } from "../../src/server/relay-eager"; +import { appendUsageEntry, type PersistedUsageEntry } from "../../src/usage/log"; import { classifyCodexWsFailure, closedBeforeTerminalMessage, @@ -385,6 +390,76 @@ describe("codex ws stage record marker (#4191)", () => { expect(adopted?.relayedEvents).toBe(2); }); + test("cancel-drain byte expiry persists the finalized WS stage in usage.jsonl", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-ws-stage-cancel-")); + const upstream = new AbortController(); + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + let relayStarted = false; + try { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + }); + const response = await codexWsUpstreamFetch( + CODEX_URL, + { ...streamingInit(), signal: upstream.signal }, + noFallback as unknown as typeof fetch, + BOUNDED_WS_RUNTIME, + ); + const adopted = readCodexWsStage(response); + expect(adopted).toBeDefined(); + expect(adopted?.requestBytes).toBeNull(); + const entry: PersistedUsageEntry = { + requestId: "req-ws-stage-cancel", timestamp: 1, provider: "openai", model: "gpt-5.5", + status: 499, durationMs: 1000, usageStatus: "unreported", + attempts: [{ ordinal: 1, provider: "openai", model: "gpt-5.5", adapter: "openai-responses", + status: 499, durationMs: 1000, sendCount: 1, recoveryKinds: [], usageStatus: "unreported", + codexWsStage: adopted }], + }; + const synthetic = jest.fn(); + const onClientCancel = jest.fn(() => { + // The real writer is synchronous: keep this test-only path override in the same turn. + const previous = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = dir; + try { appendUsageEntry(entry); } + finally { + if (previous === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previous; + } + }); + const reader = relaySseEagerBounded(response.body!, upstream, { + inspectChunk: () => {}, finishInspection: () => {}, sawTerminal: () => false, + onSynthetic: synthetic, onClientCancel, onDone: finish, + }, { postCancelDrainBytes: 1 }).getReader(); + relayStarted = true; + await reader.read(); + await reader.cancel(); + const ws = FakeWebSocket.instances[0]!; + ws.emit("message", { data: JSON.stringify({ + type: "response.output_text.delta", delta: "hi", item_id: "m1", output_index: 0, content_index: 0, + }) }); + await done; + + const rows = readFileSync(join(dir, "usage.jsonl"), "utf8").trim().split("\n"); + expect(rows).toHaveLength(1); + const persisted = JSON.parse(rows[0]!) as PersistedUsageEntry; + const logged = persisted.attempts?.[0]?.codexWsStage; + expect(logged?.requestBytes).toBe(Buffer.byteLength(ws.sent[0]!, "utf8")); + expect(logged?.upstreamFrames).toBe(2); + expect(logged?.relayedEvents).toBe(2); + expect(logged?.closeCode).toBeNull(); + expect(logged).toEqual(adopted); + expect(onClientCancel).toHaveBeenCalledTimes(1); + expect(synthetic).not.toHaveBeenCalled(); + expect(upstream.signal.aborted).toBe(true); + } finally { + upstream.abort(); + if (relayStarted) await done; + rmSync(dir, { recursive: true }); + } + }); + test("the serialized record is numeric/boolean/semver only", () => { const json = JSON.stringify(stage); expect(json).not.toContain("reason");