fix(cli): log Codex Responses WebSocket history/usage per logical turn, not per connection (#7588)

ResponsesWsSession.persistHistory() guarded on a single historyLogged
boolean set once for the lifetime of the WebSocket connection. When a
Codex client reuses one connection for multiple sequential
response.create turns, only the first terminal event was persisted to
call_logs — every subsequent turn's usage/history was silently
dropped. firstResponseBody had the same per-connection freeze (||=),
so even a hypothetical second log entry would still carry turn 1's
request body.

Replace the boolean with a Set keyed by the terminal event's
response.id (falling back to a session-scoped sentinel for
session-ending failure paths that don't carry a response id: prepare
failure, upstream error/close, connect failure), and track each
turn's own request body via currentRequestBody instead of freezing on
firstResponseBody. This logs exactly once per logical turn while
keeping session-ending failures logged exactly once, and each logged
call now carries its own terminal response id and request payload.

Regression test: tests/unit/responses-ws-proxy-multi-turn-history.test.ts
opens one WS connection, sends two response.create turns, and asserts
two distinct call-log entries land at the internal bridge, each with
its own response id and request body.

Closes #7388
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 07:11:50 -03:00
committed by GitHub
parent 52b26c88c9
commit de9cfcd940
3 changed files with 288 additions and 4 deletions

View File

@@ -31,6 +31,9 @@ const WS_QUERY_TOKEN_KEYS = ["api_key", "token", "access_token"];
const textDecoder = new TextDecoder();
const DEFAULT_MAX_WS_BUFFER_BYTES = 16 * 1024 * 1024;
const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024;
// #7388: sentinel turn key for session-ending terminal events that don't carry
// a `response.id` (prepare failure, upstream error/close, connect failure).
const SESSION_TERMINAL_TURN_KEY = "__session_terminal__";
class WebSocketInputTooLargeError extends Error {
constructor(message, reason = "message_too_large") {
@@ -414,8 +417,16 @@ class ResponsesWsSession {
this.upstream = null;
this.upstreamReady = null;
this.firstResponseBody = null;
this.currentRequestBody = null;
this.preparedContext = null;
this.historyLogged = false;
// #7388: logging must be scoped per logical turn (one `response.create`
// through its terminal event), not once for the lifetime of the WS
// connection — a single boolean here silently dropped every turn after
// the first on a reused connection. Terminal events carry a
// `response.id` we can key on; session-ending failure paths (prepare
// failure, upstream error/close, connect failure) don't, so they fall
// back to a session-scoped sentinel key that still logs exactly once.
this.loggedTurnIds = new Set();
this.lastSeenAt = Date.now();
this.pingTimer = setInterval(() => {
@@ -577,6 +588,7 @@ class ResponsesWsSession {
throw new Error("First Responses WebSocket message must be response.create");
}
this.firstResponseBody ||= responseBody;
this.currentRequestBody = responseBody;
const prepared = await callInternal(
this.fetchImpl,
@@ -681,6 +693,12 @@ class ResponsesWsSession {
upstream.send(jsonStringifySafe(firstMessage));
return;
}
// #7388: a reused WS connection forwards subsequent response.create
// turns straight through (ensureUpstream() only runs once); track each
// turn's own request body so persistHistory() attaches the right
// clientRequest instead of always the first turn's.
const nextTurnBody = getResponseCreatePayload(message);
if (nextTurnBody !== null) this.currentRequestBody = nextTurnBody;
this.upstream.send(jsonStringifySafe(message));
} catch (error) {
const code = error?.code || "upstream_websocket_connect_failed";
@@ -705,8 +723,17 @@ class ResponsesWsSession {
terminalMessage = null,
responseBody = null,
} = {}) {
if (this.historyLogged || !this.firstResponseBody) return;
this.historyLogged = true;
if (!this.firstResponseBody) return;
// #7388: key the "already logged" guard per logical turn instead of once
// per WS connection. Terminal events from a real response carry
// `response.id` — use it so each turn on a reused connection logs
// independently, while the same id firing twice (retries) still logs
// exactly once. Session-ending failure paths (prepare failure, upstream
// error/close, connect failure) don't carry a response id — they end the
// session, so they share one sentinel key and still log exactly once.
const turnId = toStringOrNull(terminalMessage?.response?.id) || SESSION_TERMINAL_TURN_KEY;
if (this.loggedTurnIds.has(turnId)) return;
this.loggedTurnIds.add(turnId);
const finishedAt = Date.now();
try {
@@ -723,7 +750,7 @@ class ResponsesWsSession {
success,
errorCode,
errorMessage,
clientRequest: this.firstResponseBody,
clientRequest: this.currentRequestBody || this.firstResponseBody,
terminalMessage,
responseBody,
sourceFormat: "openai-responses",