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/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..a6a79f8205 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`); later stream settlements finalize that same object in place so the reference `handleResponses` adopted onto the serving attempt cannot retain commit-time counters. 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. 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 f2951938af..85ac1db7f6 100644 --- a/tests/responses/ws-failure-stage.test.ts +++ b/tests/responses/ws-failure-stage.test.ts @@ -304,6 +304,24 @@ describe("codex ws stage record marker (#4191)", () => { expect(readCodexWsStage(response)).toEqual(stage); }); + test("later stage updates finalize the record already adopted by logging", () => { + const response = new Response("ok"); + markCodexWsStage(response, stage); + const adopted = readCodexWsStage(response); + const finalStage = { + ...stage, + requestBytes: 4321, + upstreamFrames: 5, + relayedEvents: 4, + elapsedMs: 1200, + }; + + markCodexWsStage(response, finalStage); + + expect(readCodexWsStage(response)).toBe(adopted); + expect(adopted).toEqual(finalStage); + }); + test("a committed exchange ends with the final counters on its stage record", async () => { installFake(ws => { ws.emit("open", {});