From a571c103fa75a69c73d887e739866cefd1d84d45 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 19:12:30 +0300 Subject: [PATCH 01/10] feat(codex): add opt-in WebSocket transport for ChatGPT subscriptions (#1963) --- docs/architecture/codex-websocket.md | 56 +++ .../src/__tests__/provider-settings.test.ts | 20 + packages/types/src/provider-settings.ts | 1 + packages/types/src/provider-settings/index.ts | 2 +- .../src/provider-settings/openai-codex.ts | 5 + pnpm-lock.yaml | 13 + src/api/providers/CodexWebSocketTransport.ts | 122 ++++++ .../__tests__/CodexWebSocketTransport.spec.ts | 407 ++++++++++++++++++ .../providers/__tests__/openai-codex.spec.ts | 96 +++++ .../CodexWebSocketConnection.ts | 83 ++++ .../CodexWebSocketContinuation.ts | 65 +++ .../CodexWebSocketItemSnapshot.ts | 58 +++ .../CodexWebSocketRequestScope.ts | 34 ++ .../codex-websocket/CodexWebSocketResponse.ts | 45 ++ .../CodexWebSocketUnavailableError.ts | 2 + .../codex-websocket/PreparedCodexRequest.ts | 12 + src/api/providers/codex-websocket/protocol.ts | 39 ++ src/api/providers/openai-codex.ts | 33 +- .../__tests__/ProviderSettingsManager.spec.ts | 35 ++ .../__tests__/apiConversationHistory.spec.ts | 35 ++ src/core/task/apiConversationHistory.ts | 7 + .../webview/__tests__/ClineProvider.spec.ts | 14 + src/eslint-suppressions.json | 2 +- src/package.json | 2 + webview-ui/playwright/roo-code-types.ts | 1 + webview-ui/playwright/vite.config.ts | 1 + .../SettingsView.unsaved-changes.spec.tsx | 40 ++ .../settings/providers/OpenAICodex.tsx | 5 + .../providers/OpenAICodexWebSocketToggle.tsx | 25 ++ .../providers/__tests__/OpenAICodex.spec.tsx | 24 ++ .../__tests__/OpenAICodex.visual.fixture.tsx | 6 + .../__tests__/OpenAICodex.visual.tsx | 8 +- ...penai-codex-speed-selector-states-dark.png | Bin 7318 -> 19690 bytes ...enai-codex-speed-selector-states-light.png | Bin 7347 -> 20202 bytes webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + 36 files changed, 1296 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/codex-websocket.md create mode 100644 src/api/providers/CodexWebSocketTransport.ts create mode 100644 src/api/providers/__tests__/CodexWebSocketTransport.spec.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketConnection.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketContinuation.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketResponse.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketUnavailableError.ts create mode 100644 src/api/providers/codex-websocket/PreparedCodexRequest.ts create mode 100644 src/api/providers/codex-websocket/protocol.ts create mode 100644 webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx diff --git a/docs/architecture/codex-websocket.md b/docs/architecture/codex-websocket.md new file mode 100644 index 0000000000..04c233fac3 --- /dev/null +++ b/docs/architecture/codex-websocket.md @@ -0,0 +1,56 @@ +# Codex WebSocket transport + +The `openAiCodexUseWebSocket` provider preference enables WebSocket streaming for +OpenAI Codex authenticated with a ChatGPT subscription. It defaults to `false`. +The Settings view buffers the checkbox in the selected provider profile until +Save. Provider schemas, profile storage, import/export, and the existing +`apiConfiguration` webview round trip carry the value without a separate global +setting or environment variable. + +## Responsibilities + +- `CodexWebSocketTransport` translates requests into `response.create`, emits the + ordinary Responses events, and enforces one active request. The provider's existing event processor handles + text, reasoning, tools, and usage for both HTTP and WebSocket streams. +- `CodexWebSocketRequestScope` combines caller cancellation with the request's + inactivity deadline and releases its timer and abort listener when disposed. +- `CodexWebSocketResponse` tracks output, completion, and the one permitted + cache-miss recovery independently of socket IO. +- `CodexWebSocketConnection` owns authentication, the socket, and connection + lifetime. It rotates before one hour, reconnects when headers change, closes + after two idle minutes, and throttles failed upgrades for one minute. New + credentials bypass that cooldown. +- `CodexWebSocketContinuation` retains the cached response and compares request + settings and history prefixes to decide whether continuation is safe. Real edits, + compaction, or an explicit reset require full context. +- `CodexWebSocketItemSnapshot` normalizes history items and retains only hashes. + Response-only metadata does not cause false mismatches, and diagnostics identify + changed fields without retaining messages or encrypted payloads. + `PreparedCodexRequest` defines the shared request contract in a separate file. + +The subscription endpoint is `wss://chatgpt.com/backend-api/codex/responses` and +uses the Codex `responses_websockets=2026-02-06` beta header and Responses Lite +metadata. This is distinct from the public API-key Responses endpoint. + +## Recovery rules + +An HTTP upgrade failure allows the provider's existing HTTP fallback. After a +request is sent, a disconnect or protocol error must not automatically replay it: +the server may already have accepted it. A `previous_response_not_found` error +permits exactly one full-context resend, only before any response event arrives. +Failed or incomplete responses preserve the server error details. Stop closes +the connection and clears continuation; the next request starts with full context. + +Reasoning summaries and encrypted reasoning are saved together in conversation +history. The encrypted state is replayed to the provider; the visible summary is +kept for display. This preservation applies to HTTP providers too. + +## Verification + +Loopback WebSocket tests cover connection reuse, incremental input, history +changes, cancellation, timeout, rotation, idle cleanup, cache recovery, upgrade +fallback and refreshed credentials. Provider integration tests cover routing and +preventing ambiguous HTTP replays. Settings tests cover cached editing, Save, +profile persistence, import/export and the host-to-webview round trip for true, +false and unset values. The Codex settings gallery covers both checkbox states in +light and dark themes. diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index c5a79002a4..cb14b21275 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -138,6 +138,26 @@ describe("OpenAI Codex provider settings", () => { }) }) +describe("OpenAI Codex WebSocket preference", () => { + it.each([true, false, undefined])("round-trips %s through both provider schemas", (enabled) => { + const settings = { + apiProvider: providerIdentifiers.openaiCodex, + ...(enabled !== undefined ? { openAiCodexUseWebSocket: enabled } : {}), + } + expect(providerSettingsSchema.parse(settings)).toEqual(settings) + expect(providerSettingsSchemaDiscriminated.parse(JSON.parse(JSON.stringify(settings)))).toEqual(settings) + expect(PROVIDER_SETTINGS_KEYS).toContain("openAiCodexUseWebSocket") + }) + it.each(["true", 1, null])("rejects non-boolean value %s", (enabled) => { + expect( + providerSettingsSchema.safeParse({ + apiProvider: providerIdentifiers.openaiCodex, + openAiCodexUseWebSocket: enabled, + }).success, + ).toBe(false) + }) +}) + describe("getApiProtocol", () => { it("preserves API protocol wire values", () => { expect(ANTHROPIC_API_PROTOCOL).toBe("anthropic") diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index b243971cc3..0aeb4888dd 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -5,6 +5,7 @@ import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./provider-settings/co export { DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS, OPEN_AI_CODEX_SERVICE_TIER_KEY, + DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET, parseOpenAiExtraBody, kimiCodeAuthMethodSchema, type KimiCodeAuthMethod, diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 5cdadb4b2c..814c367c45 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -36,7 +36,7 @@ import { basetenProviderDefinition } from "./baseten.js" import type { ProviderDefinition } from "./common.js" -export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js" +export { OPEN_AI_CODEX_SERVICE_TIER_KEY, DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET } from "./openai-codex.js" export { parseOpenAiExtraBody, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS } from "./openai.js" export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js" export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js" diff --git a/packages/types/src/provider-settings/openai-codex.ts b/packages/types/src/provider-settings/openai-codex.ts index 93117224ae..2c2edbfd5d 100644 --- a/packages/types/src/provider-settings/openai-codex.ts +++ b/packages/types/src/provider-settings/openai-codex.ts @@ -1,3 +1,5 @@ +import { z } from "zod" + import { providerIdentifiers } from "../provider-identifiers.js" import { openAiCodexServiceTierSchema } from "../model.js" import { @@ -7,6 +9,8 @@ import { createProviderDefinition, } from "./common.js" +export const DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET = false + export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" export const openAiCodexProviderDefinition = createProviderDefinition({ @@ -15,6 +19,7 @@ export const openAiCodexProviderDefinition = createProviderDefinition({ getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), schema: { ...apiModelIdProviderModelShape, + openAiCodexUseWebSocket: z.boolean().optional(), // Codex "Fast" mode maps to the Responses API priority service tier. [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTierSchema.optional(), }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b12ecd1c77..395cb40b73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: workerpool: specifier: ^9.2.0 version: 9.2.0 + ws: + specifier: ^8.18.3 + version: 8.21.3 yaml: specifier: ^2.8.0 version: 2.9.0 @@ -666,6 +669,9 @@ importers: '@types/vscode': specifier: 1.100.0 version: 1.100.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.2 '@vitest/coverage-v8': specifier: 4.1.9 version: 4.1.9(vitest@4.1.11) @@ -3573,6 +3579,9 @@ packages: '@types/vscode@1.100.0': resolution: {integrity: sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==} + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -11853,6 +11862,10 @@ snapshots: '@types/vscode@1.100.0': {} + '@types/ws@8.18.2': + dependencies: + '@types/node': 22.20.1 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': diff --git a/src/api/providers/CodexWebSocketTransport.ts b/src/api/providers/CodexWebSocketTransport.ts new file mode 100644 index 0000000000..0bd8586bef --- /dev/null +++ b/src/api/providers/CodexWebSocketTransport.ts @@ -0,0 +1,122 @@ +import { on } from "node:events" +import type WebSocket from "ws" + +import { CodexWebSocketConnection } from "./codex-websocket/CodexWebSocketConnection" +import { CodexWebSocketContinuation } from "./codex-websocket/CodexWebSocketContinuation" +import type { PreparedCodexRequest } from "./codex-websocket/PreparedCodexRequest" +import { + asJsonObject, + parseResponseEvent, + type CodexResponseEvent, + type CodexWebSocketOptions, +} from "./codex-websocket/protocol" +import { CodexWebSocketRequestScope } from "./codex-websocket/CodexWebSocketRequestScope" +import { CodexWebSocketResponse } from "./codex-websocket/CodexWebSocketResponse" + +export { CodexWebSocketUnavailableError } from "./codex-websocket/CodexWebSocketUnavailableError" + +const CODEX_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses" + +/** Task-local Responses transport. Connection and history management are independent of provider event handling. */ +export class CodexWebSocketTransport { + private busy = false + + constructor( + private readonly connection: CodexWebSocketConnection, + private readonly continuation: CodexWebSocketContinuation, + ) {} + + static create(url = CODEX_WEBSOCKET_URL): CodexWebSocketTransport { + const continuation = new CodexWebSocketContinuation() + const connection = new CodexWebSocketConnection(url, () => continuation.reset()) + return new CodexWebSocketTransport(connection, continuation) + } + + resetContinuation(): void { + this.continuation.reset() + } + close(): void { + this.connection.close() + } + + async *stream(body: unknown, options: CodexWebSocketOptions): AsyncGenerator { + if (this.busy) throw new Error("Concurrent requests on a Codex WebSocket are not supported") + options.signal.throwIfAborted() + const scope = new CodexWebSocketRequestScope(options, () => this.close()) + let response: CodexWebSocketResponse | undefined + this.busy = true + try { + const socket = await this.connection.acquire({ ...options, signal: scope.signal }) + const prepared = this.continuation.prepare(body) + response = new CodexWebSocketResponse(this.continuation, prepared) + yield* this.readResponse(socket, prepared, options.headers, scope, response) + } catch (error) { + if (scope.signal.aborted) throw scope.signal.reason + throw error + } finally { + scope.dispose() + this.busy = false + if (!response?.completed || scope.signal.aborted) this.close() + else this.connection.release() + } + } + + private async *readResponse( + socket: WebSocket, + prepared: PreparedCodexRequest, + headers: Record, + scope: CodexWebSocketRequestScope, + response: CodexWebSocketResponse, + ): AsyncGenerator { + // Register before sending so a fast reply cannot be lost. + const events = on(socket, "message", { signal: scope.signal, close: ["close"] }) + try { + scope.refreshTimeout() + this.send(socket, prepared, headers, prepared.fullContextReason === undefined) + for await (const [data] of events) { + scope.refreshTimeout() + const event = parseResponseEvent(String(data)) + if (response.accept(event) === "retry") { + this.send(socket, prepared, headers, false, "server cache miss") + continue + } + if (response.completed) scope.clearTimeout() + yield event + if (response.completed) return + } + throw new Error("Codex WebSocket closed before response completed; retry the request") + } finally { + await events.return?.() + } + } + + private send( + socket: WebSocket, + prepared: PreparedCodexRequest, + headers: Record, + incremental: boolean, + reason = prepared.fullContextReason, + ): void { + const { request, input, previousResponseId, offset } = prepared + const nextInput = incremental ? input.slice(offset) : input + const clientMetadata = { + ...(request.client_metadata ? asJsonObject(request.client_metadata) : {}), + ...(headers["x-openai-internal-codex-responses-lite"] === "true" + ? { ws_request_header_x_openai_internal_codex_responses_lite: "true" } + : {}), + } + console.info( + `[Codex WebSocket] Sending ${incremental ? "incremental" : "full"} context` + + ` (${nextInput.length}/${input.length} input items${incremental ? "" : `; reason: ${reason}`})`, + ) + socket.send( + JSON.stringify({ + ...request, + type: "response.create", + client_metadata: clientMetadata, + previous_response_id: incremental ? previousResponseId : undefined, + input: nextInput, + }), + ) + } +} diff --git a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts new file mode 100644 index 0000000000..349c1af09b --- /dev/null +++ b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts @@ -0,0 +1,407 @@ +import { once } from "node:events" +import { WebSocketServer, type WebSocket } from "ws" +import nock from "nock" +import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "../CodexWebSocketTransport" +import { collectStream } from "../../../test-utils/stream" + +type Request = Record + +describe("CodexWebSocketTransport", () => { + let server: WebSocketServer + let transport: CodexWebSocketTransport + let requests: Request[] + let connections: number + let reply: (request: Request, socket: WebSocket) => void + const options = () => ({ + headers: { Authorization: "Bearer test-token" }, + signal: new AbortController().signal, + timeoutMs: 2_000, + }) + const body = (input: unknown[] = [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }]) => ({ + model: "test-model", + stream: true, + store: false, + input, + }) + const complete = (socket: WebSocket, output: unknown[] = []) => + socket.send( + JSON.stringify({ + type: "response.completed", + response: { id: `resp_${requests.length}`, output, usage: { input_tokens: 1, output_tokens: 2 } }, + }), + ) + + beforeEach(async () => { + nock.enableNetConnect(/^127\.0\.0\.1:/) + requests = [] + connections = 0 + reply = (_request, socket) => complete(socket) + server = new WebSocketServer({ host: "127.0.0.1", port: 0 }) + await once(server, "listening") + const address = server.address() + if (typeof address === "string" || address === null) throw new Error("Missing test server address") + transport = CodexWebSocketTransport.create(`ws://127.0.0.1:${address.port}/responses`) + server.on("connection", (socket) => { + connections++ + socket.on("message", (data) => { + const request: Request = JSON.parse(String(data)) + requests.push(request) + reply(request, socket) + }) + }) + }) + + afterEach(async () => { + vi.restoreAllMocks() + transport?.close() + nock.disableNetConnect() + for (const socket of server.clients) socket.terminate() + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }) + + it("sends the Codex v2 handshake and Responses Lite metadata", async () => { + let headers: Record = {} + server.on("headers", (_headers, request) => { + headers = request.headers + }) + const events = await collectStream( + transport.stream(body(), { + ...options(), + headers: { Authorization: "Bearer test-token", "x-openai-internal-codex-responses-lite": "true" }, + }), + ) + expect(headers).toMatchObject({ + authorization: "Bearer test-token", + "openai-beta": "responses_websockets=2026-02-06", + }) + expect(requests[0]).toMatchObject({ + type: "response.create", + stream: true, + store: false, + client_metadata: { + ws_request_header_x_openai_internal_codex_responses_lite: "true", + }, + }) + expect(events[0]).toMatchObject({ type: "response.completed", response: { usage: { output_tokens: 2 } } }) + }) + + it("reuses a socket and sends only new tool output after reconstructed reasoning and tool calls", async () => { + const output = [ + { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Thinking" }], + encrypted_content: "encrypted", + }, + { + type: "message", + id: "msg_1", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: "Reading", annotations: [] }], + }, + { + type: "function_call", + id: "fc_1", + status: "completed", + call_id: "call_1", + name: "read_file", + arguments: '{"path":"a","start":1}', + }, + ] + reply = (_request, socket) => complete(socket, requests.length === 1 ? output : []) + await collectStream(transport.stream(body(), options())) + const toolOutput = { type: "function_call_output", call_id: "call_1", output: "file content" } + await collectStream( + transport.stream( + body([ + ...body().input, + { type: "reasoning", id: "rs_1", encrypted_content: "encrypted" }, + { role: "assistant", content: [{ type: "output_text", text: "Reading" }] }, + { + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: '{"start":1,"path":"a"}', + }, + toolOutput, + ]), + options(), + ), + ) + expect(connections).toBe(1) + expect(requests[1]).toMatchObject({ previous_response_id: "resp_1", input: [toolOutput] }) + }) + + it.each(["history", "settings", "explicit reset"])("replays full context after %s changes", async (change) => { + const log = vi.spyOn(console, "info") + await collectStream(transport.stream(body(), options())) + if (change === "explicit reset") transport.resetContinuation() + const next = body(change === "history" ? [{ role: "user", content: "Compacted history" }] : body().input) + if (change === "settings") next.model = "another-model" + await collectStream(transport.stream(next, options())) + expect(requests[1].previous_response_id).toBeUndefined() + expect(requests[1].input).toEqual(next.input) + const reason = + change === "history" + ? "history changed" + : change === "settings" + ? "request settings changed" + : "no cached response" + expect(log).toHaveBeenLastCalledWith(expect.stringContaining(`reason: ${reason}`)) + }) + + it("recovers a missing previous response once with the full context", async () => { + const log = vi.spyOn(console, "info") + reply = (_request, socket) => { + if (requests.length === 2) + socket.send(JSON.stringify({ type: "error", error: { code: "previous_response_not_found" } })) + else complete(socket) + } + await collectStream(transport.stream(body(), options())) + const next = body([...body().input, { role: "user", content: "Next" }]) + await collectStream(transport.stream(next, options())) + expect(requests[1].previous_response_id).toBe("resp_1") + expect(requests[2]).toMatchObject({ input: next.input }) + expect(requests[2].previous_response_id).toBeUndefined() + expect(log).toHaveBeenLastCalledWith(expect.stringContaining("reason: server cache miss")) + }) + + it.each(["function_call", "message"])("continues after unencrypted reasoning followed by %s", async (type) => { + const call = { type: "function_call", call_id: "call_1", name: "list_files", arguments: "{}" } + const output = + type === "function_call" ? call : { role: "assistant", content: [{ type: "output_text", text: "Ready" }] } + reply = (_request, socket) => + complete( + socket, + requests.length === 1 + ? [{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "Thinking" }] }, output] + : [], + ) + await collectStream(transport.stream(body(), options())) + const next = + type === "function_call" + ? { type: "function_call_output", call_id: "call_1", output: "files" } + : { role: "user", content: "Continue" } + const history = [...body().input, output, next] + await collectStream(transport.stream(body(history), options())) + expect(requests[1]).toMatchObject({ previous_response_id: "resp_1", input: [next] }) + const followup = { role: "user", content: "Next step" } + await collectStream(transport.stream(body([...history, followup]), options())) + expect(requests[2]).toMatchObject({ previous_response_id: "resp_2", input: [followup] }) + expect(connections).toBe(1) + }) + + it("replays full context when encrypted reasoning is omitted", async () => { + const call = { type: "function_call", call_id: "call_1", name: "list_files", arguments: "{}" } + reply = (_request, socket) => + complete( + socket, + requests.length === 1 ? [{ type: "reasoning", id: "rs_1", encrypted_content: "encrypted" }, call] : [], + ) + await collectStream(transport.stream(body(), options())) + const next = body([...body().input, call, { type: "function_call_output", call_id: "call_1", output: "files" }]) + await collectStream(transport.stream(next, options())) + expect(requests[1].previous_response_id).toBeUndefined() + expect(requests[1].input).toEqual(next.input) + }) + + it("identifies changed message content without logging the content", async () => { + const log = vi.spyOn(console, "info") + const message = { role: "assistant", content: [{ type: "output_text", text: "private text" }] } + reply = (_request, socket) => complete(socket, requests.length === 1 ? [message] : []) + await collectStream(transport.stream(body(), options())) + await collectStream( + transport.stream( + body([ + ...body().input, + { ...message, content: [{ type: "output_text", text: "changed private text" }] }, + ]), + options(), + ), + ) + expect(log).toHaveBeenLastCalledWith(expect.stringContaining("message -> message; fields: content")) + expect(log.mock.calls.flat().join(" ")).not.toContain("private text") + }) + + it("reconnects with full context when credentials change", async () => { + await collectStream(transport.stream(body(), options())) + await collectStream( + transport.stream(body(), { ...options(), headers: { Authorization: "Bearer refreshed-token" } }), + ) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }) + + it("rotates a connection before the server's one-hour limit", async () => { + await collectStream(transport.stream(body(), options())) + const later = Date.now() + 55 * 60_000 + vi.spyOn(Date, "now").mockReturnValue(later) + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }) + + it("never labels a disconnect after sending as safe to replay over HTTP", async () => { + reply = (_request, socket) => socket.close() + const error = await collectStream(transport.stream(body(), options())).catch((error: unknown) => error) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(CodexWebSocketUnavailableError) + expect(requests).toHaveLength(1) + }) + + it("permits HTTP fallback after a rejected upgrade and stops retrying the socket", async () => { + server.options.verifyClient = () => false + await expect(collectStream(transport.stream(body(), options()))).rejects.toBeInstanceOf( + CodexWebSocketUnavailableError, + ) + await expect(collectStream(transport.stream(body(), options()))).rejects.toBeInstanceOf( + CodexWebSocketUnavailableError, + ) + expect(connections).toBe(0) + expect(requests).toHaveLength(0) + }) + + it("attempts WebSocket again after credentials change following a failed upgrade", async () => { + server.options.verifyClient = () => false + await expect(collectStream(transport.stream(body(), options()))).rejects.toBeInstanceOf( + CodexWebSocketUnavailableError, + ) + server.options.verifyClient = () => true + await collectStream( + transport.stream(body(), { ...options(), headers: { Authorization: "Bearer refreshed-token" } }), + ) + expect(connections).toBe(1) + expect(requests[0].previous_response_id).toBeUndefined() + }) + + it("retries a transient upgrade failure after the cooldown", async () => { + server.options.verifyClient = () => false + await expect(collectStream(transport.stream(body(), options()))).rejects.toBeInstanceOf( + CodexWebSocketUnavailableError, + ) + server.options.verifyClient = () => true + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 60_000) + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(1) + expect(requests[0].previous_response_id).toBeUndefined() + }) + + it("reconnects with full history after the server closes an idle socket", async () => { + await collectStream(transport.stream(body(), options())) + const socket = [...server.clients][0] + const closed = once(socket, "close") + socket.close() + await closed + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }) + + it("never replays a cache miss after any response event has arrived", async () => { + await collectStream(transport.stream(body(), options())) + reply = (_request, socket) => { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "Accepted" })) + socket.send(JSON.stringify({ type: "error", error: { code: "previous_response_not_found" } })) + } + await expect(collectStream(transport.stream(body(), options()))).rejects.toThrow("previous_response_not_found") + expect(requests).toHaveLength(2) + }) + + it.each([ + { type: "response.failed", response: { error: { code: "server_error", message: "Service unavailable" } } }, + { type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" } } }, + ])("preserves server failure details for $type", async (event) => { + reply = (_request, socket) => socket.send(JSON.stringify(event)) + await expect(collectStream(transport.stream(body(), options()))).rejects.toThrow( + event.type === "response.failed" ? "Service unavailable" : "max_output_tokens", + ) + }) + + it.each(["response.failed", "response.incomplete"])("reports %s as a failed request", async (type) => { + reply = (_request, socket) => socket.send(JSON.stringify({ type, response: { id: "failed" } })) + await expect(collectStream(transport.stream(body(), options()))).rejects.toThrow(type) + }) + + it("does not loop when full-context recovery also fails", async () => { + await collectStream(transport.stream(body(), options())) + reply = (_request, socket) => + socket.send(JSON.stringify({ type: "error", error: { code: "previous_response_not_found" } })) + await expect(collectStream(transport.stream(body(), options()))).rejects.toThrow("previous_response_not_found") + expect(requests).toHaveLength(3) + }) + + it("closes the socket on Stop and starts fresh on the next request", async () => { + const controller = new AbortController() + reply = (_request, socket) => { + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "Partial" })) + } + const stream = transport.stream(body(), { ...options(), signal: controller.signal }) + expect((await stream.next()).value).toMatchObject({ delta: "Partial" }) + controller.abort() + await expect(stream.next()).rejects.toThrow() + reply = (_request, socket) => complete(socket) + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }) + + it("bounds a silent response with a timeout", async () => { + reply = () => {} + await expect(collectStream(transport.stream(body(), { ...options(), timeoutMs: 100 }))).rejects.toThrow() + }) + + it.each(["null", "[]", "{}", '{"type":42}', "invalid JSON"])( + "rejects malformed response %s and clears the connection", + async (payload) => { + reply = (_request, socket) => socket.send(payload) + const error = await collectStream(transport.stream(body(), options())).catch((error: unknown) => error) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(CodexWebSocketUnavailableError) + reply = (_request, socket) => complete(socket) + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }, + ) + + it("closes a completed idle connection after two minutes", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + try { + await collectStream(transport.stream(body(), options())) + const closed = once([...server.clients][0], "close") + await vi.advanceTimersByTimeAsync(120_000) + await closed + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it("clears the request deadline before yielding completion to a paused consumer", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + const stream = transport.stream(body(), { ...options(), timeoutMs: 2_000 }) + try { + expect((await stream.next()).value).toMatchObject({ type: "response.completed" }) + await vi.advanceTimersByTimeAsync(10_000) + expect((await stream.next()).done).toBe(true) + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(1) + expect(requests[1].previous_response_id).toBe("resp_1") + } finally { + await stream.return(undefined) + vi.useRealTimers() + } + }) + + it("rejects overlapping requests without disturbing the active stream", async () => { + reply = (_request, socket) => + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "Partial" })) + const stream = transport.stream(body(), options()) + await stream.next() + await expect(collectStream(transport.stream(body(), options()))).rejects.toThrow("Concurrent requests") + await stream.return(undefined) + }) +}) diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 35c1f5de60..74c5dc8fe9 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -11,6 +11,7 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, SERVICE_TIER_KEY } from "@roo-code/types" import { OpenAiCodexHandler, transformResponsesLiteBody } from "../openai-codex" +import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "../CodexWebSocketTransport" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -28,6 +29,101 @@ function createCompletedStream() { ]) } +describe("OpenAiCodexHandler WebSocket transport", () => { + beforeEach(() => { + vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + }) + afterEach(() => { + vitest.restoreAllMocks() + vitest.unstubAllEnvs() + vitest.unstubAllGlobals() + }) + + it("routes Lite requests through the transport and returns existing usage chunks", async () => { + const stream = vitest + .spyOn(CodexWebSocketTransport.prototype, "stream") + .mockReturnValue(createCompletedStream()) + const reset = vitest.spyOn(CodexWebSocketTransport.prototype, "resetContinuation") + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-6.1-sol", openAiCodexUseWebSocket: true }) + const chunks = await collectStream( + handler.createMessage("System", [], { + taskId: "task-1", + suppressPreviousResponseId: true, + }), + ) + expect(stream).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.arrayContaining([expect.objectContaining({ type: "additional_tools" })]), + }), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "ChatGPT-Account-Id": "acct_test", + session_id: "task-1", + }), + }), + ) + expect(reset).toHaveBeenCalledOnce() + expect(chunks).toContainEqual(expect.objectContaining({ type: "usage", inputTokens: 1, outputTokens: 1 })) + }) + + it.each([undefined, false])("keeps SDK streaming when WebSocket is %s", async (enabled) => { + vitest.stubEnv("ZOO_CODE_CODEX_WEBSOCKET", "1") + const stream = vitest.spyOn(CodexWebSocketTransport.prototype, "stream") + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: enabled }) + const create = vitest.fn().mockResolvedValue(createCompletedStream()) + Reflect.set(handler, "client", { responses: { create } }) + await collectStream(handler.createMessage("System", [])) + expect(create).toHaveBeenCalledOnce() + expect(stream).not.toHaveBeenCalled() + }) + + it("uses the HTTP fallback for a failed upgrade", async () => { + vitest.spyOn(CodexWebSocketTransport.prototype, "stream").mockImplementation(() => { + throw new CodexWebSocketUnavailableError("Upgrade rejected") + }) + const fetch = vitest.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n', + ), + ) + controller.close() + }, + }), + }) + vitest.stubGlobal("fetch", fetch) + await collectStream( + new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: true }).createMessage( + "System", + [], + ), + ) + expect(fetch).toHaveBeenCalledOnce() + }) + + it("does not replay an ambiguously accepted request over HTTP", async () => { + vitest.spyOn(CodexWebSocketTransport.prototype, "stream").mockImplementation(() => { + throw new Error("Connection closed after send") + }) + const fetch = vitest.fn() + vitest.stubGlobal("fetch", fetch) + await expect( + collectStream( + new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: true }).createMessage( + "System", + [], + ), + ), + ).rejects.toThrow("Connection closed after send") + expect(fetch).not.toHaveBeenCalled() + }) +}) + describe("OpenAiCodexHandler.getModel", () => { it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini", "gpt-5.3-codex-spark"])( "should return specified model when a valid model id is provided: %s", diff --git a/src/api/providers/codex-websocket/CodexWebSocketConnection.ts b/src/api/providers/codex-websocket/CodexWebSocketConnection.ts new file mode 100644 index 0000000000..ea9eba8fef --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketConnection.ts @@ -0,0 +1,83 @@ +import { once } from "node:events" +import WebSocket from "ws" + +import { CodexWebSocketUnavailableError } from "./CodexWebSocketUnavailableError" +import { fingerprint, type CodexWebSocketOptions } from "./protocol" + +const MAX_CONNECTION_AGE_MS = 55 * 60_000 +const IDLE_CONNECTION_TIMEOUT_MS = 120_000 +const HANDSHAKE_TIMEOUT_MS = 10_000 +const UPGRADE_RETRY_DELAY_MS = 60_000 + +/** Owns a single authenticated connection and bounds its lifetime between requests. */ +export class CodexWebSocketConnection { + private socket?: WebSocket + private key?: string + private unavailableKey?: string + private unavailableUntil = 0 + private connectedAt = 0 + private idleTimer?: NodeJS.Timeout + + constructor( + private readonly url: string, + private readonly resetContinuation: () => void, + ) {} + + async acquire(options: CodexWebSocketOptions): Promise { + options.signal.throwIfAborted() + clearTimeout(this.idleTimer) + const key = fingerprint(options.headers) + if (this.unavailableKey === key && Date.now() < this.unavailableUntil) { + throw new CodexWebSocketUnavailableError("Codex WebSocket unavailable; using HTTP") + } + if ( + this.key === key && + this.socket?.readyState === WebSocket.OPEN && + Date.now() - this.connectedAt < MAX_CONNECTION_AGE_MS + ) { + return this.socket + } + this.close() + const socket = new WebSocket(this.url, { + headers: { ...options.headers, "OpenAI-Beta": "responses_websockets=2026-02-06" }, + handshakeTimeout: Math.min(options.timeoutMs, HANDSHAKE_TIMEOUT_MS), + }) + this.socket = socket + // Idle errors must never become uncaught EventEmitter errors; stale sockets cannot clear new state. + socket.on("error", () => { + if (this.socket === socket) this.resetContinuation() + }) + socket.on("close", () => { + if (this.socket === socket) this.close() + }) + try { + await once(socket, "open", { signal: options.signal }) + } catch (error) { + options.signal.throwIfAborted() + this.unavailableKey = key + this.unavailableUntil = Date.now() + UPGRADE_RETRY_DELAY_MS + console.warn("[Codex WebSocket] Upgrade failed; falling back to HTTP") + throw new CodexWebSocketUnavailableError("Codex WebSocket upgrade failed", { cause: error }) + } + this.key = key + this.unavailableKey = undefined + this.connectedAt = Date.now() + console.info("[Codex WebSocket] Connected") + return socket + } + + release(): void { + clearTimeout(this.idleTimer) + this.idleTimer = setTimeout(() => this.close(), IDLE_CONNECTION_TIMEOUT_MS) + this.idleTimer.unref() + } + + close(): void { + clearTimeout(this.idleTimer) + const socket = this.socket + this.socket = undefined + this.key = undefined + this.resetContinuation() + socket?.terminate() + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts b/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts new file mode 100644 index 0000000000..95dc921b68 --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts @@ -0,0 +1,65 @@ +import { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" +import type { PreparedCodexRequest } from "./PreparedCodexRequest" +import { fingerprint, asJsonObject, type JsonObject } from "./protocol" + +/** Compares the server output with the history Zoo can reconstruct, retaining only hashes. */ +export class CodexWebSocketContinuation { + private cached?: { id: string; settings: string; input: CodexWebSocketItemSnapshot[] } + + reset(): void { + this.cached = undefined + } + + prepare(body: unknown): PreparedCodexRequest { + const request = asJsonObject(body) + if (!Array.isArray(request.input)) throw new Error("Codex WebSocket input must be an array") + const { input: _input, ...settings } = request + const settingsKey = fingerprint(settings) + const snapshots = request.input.map((item) => CodexWebSocketItemSnapshot.create(item)) + return { + request, + input: request.input, + snapshots, + settings: settingsKey, + previousResponseId: this.cached?.id, + offset: this.cached?.input.length ?? 0, + fullContextReason: this.getFullContextReason(settingsKey, snapshots), + } + } + + record(prepared: PreparedCodexRequest, response: JsonObject, streamedOutput: unknown[]): void { + const output = Array.isArray(response.output) ? response.output : streamedOutput + // Zoo replays only encrypted reasoning. Plain reasoning remains in the server cache, + // but must not occupy a slot in the local history prefix. + const replayable = output.filter((value) => { + const item = asJsonObject(value) + return item.type !== "reasoning" || Boolean(item.encrypted_content) + }) + this.cached = + typeof response.id === "string" + ? { + id: response.id, + settings: prepared.settings, + input: [ + ...prepared.snapshots, + ...replayable.map((item) => CodexWebSocketItemSnapshot.create(item)), + ], + } + : undefined + } + + private getFullContextReason(settings: string, input: CodexWebSocketItemSnapshot[]): string | undefined { + const previous = this.cached + if (!previous) return "no cached response" + if (previous.settings !== settings) return "request settings changed" + if (previous.input.length > input.length) return "history shortened" + const index = previous.input.findIndex((item, index) => item.hash !== input[index].hash) + if (index !== -1) { + const before = previous.input[index] + const after = input[index] + const fields = before.changedFields(after) + return `history changed at item ${index}: ${before.type} -> ${after.type}; fields: ${fields.join(", ")}` + } + return undefined + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts b/src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts new file mode 100644 index 0000000000..7e0b1dd043 --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts @@ -0,0 +1,58 @@ +import { fingerprint, asJsonObject, type JsonObject } from "./protocol" + +/** Retain hashes only; diagnostic logs must never expose messages, arguments or encrypted reasoning. */ +export class CodexWebSocketItemSnapshot { + private constructor( + readonly hash: string, + readonly type: string, + private readonly fields: Record, + ) {} + + static create(value: unknown): CodexWebSocketItemSnapshot { + const item = CodexWebSocketItemSnapshot.normalize(value) + return new CodexWebSocketItemSnapshot( + fingerprint(item), + String(item.type ?? "message"), + Object.fromEntries(Object.entries(item).map(([key, field]) => [key, fingerprint(field)])), + ) + } + + changedFields(other: CodexWebSocketItemSnapshot): string[] { + return Object.keys({ ...this.fields, ...other.fields }).filter((key) => this.fields[key] !== other.fields[key]) + } + + /** Compare wire history with Zoo's reconstructed history, ignoring response-only metadata. */ + private static normalize(value: unknown): JsonObject { + const item = asJsonObject(value) + switch (item.type) { + case "message": + case undefined: + return { + role: item.role, + content: Array.isArray(item.content) + ? item.content.map((part: unknown) => { + const content = asJsonObject(part) + return content.type === "output_text" + ? { type: content.type, text: content.text } + : content + }) + : item.content, + } + case "function_call": { + let args = item.arguments + if (typeof args === "string") { + try { + args = JSON.parse(args) + } catch { + // Malformed arguments must still compare exactly. + } + } + return { type: item.type, call_id: item.call_id, name: item.name, arguments: args } + } + case "reasoning": + return { type: item.type, id: item.id, encrypted_content: item.encrypted_content } + default: + return item + } + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts b/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts new file mode 100644 index 0000000000..9f0cb537cc --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts @@ -0,0 +1,34 @@ +import type { CodexWebSocketOptions } from "./protocol" + +/** Owns cancellation and the inactivity timer for a single request. */ +export class CodexWebSocketRequestScope { + readonly signal: AbortSignal + private readonly controller = new AbortController() + private timeout?: NodeJS.Timeout + + constructor( + private readonly options: CodexWebSocketOptions, + private readonly onAbort: () => void, + ) { + this.signal = AbortSignal.any([options.signal, this.controller.signal]) + this.signal.addEventListener("abort", onAbort, { once: true }) + } + + refreshTimeout(): void { + this.clearTimeout() + this.timeout = setTimeout( + () => this.controller.abort(new Error("Codex WebSocket stream timed out")), + this.options.timeoutMs, + ) + } + + clearTimeout(): void { + clearTimeout(this.timeout) + this.timeout = undefined + } + + dispose(): void { + this.clearTimeout() + this.signal.removeEventListener("abort", this.onAbort) + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketResponse.ts b/src/api/providers/codex-websocket/CodexWebSocketResponse.ts new file mode 100644 index 0000000000..8e5ae2b97d --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketResponse.ts @@ -0,0 +1,45 @@ +import { CodexWebSocketContinuation } from "./CodexWebSocketContinuation" +import type { PreparedCodexRequest } from "./PreparedCodexRequest" +import { asJsonObject, responseError, type CodexResponseEvent } from "./protocol" + +/** Tracks response completion and the one safe cache-miss recovery, independently of socket IO. */ +export class CodexWebSocketResponse { + private received = false + private recovered = false + private readonly output: unknown[] = [] + private finished = false + + constructor( + private readonly continuation: CodexWebSocketContinuation, + private readonly prepared: PreparedCodexRequest, + ) {} + + get completed(): boolean { + return this.finished + } + + accept(event: CodexResponseEvent): "emit" | "retry" { + if (event.type === "error") return this.handleError(event) + if (event.type === "response.failed" || event.type === "response.incomplete") throw responseError(event) + this.received = true + if (event.type === "response.output_item.done") this.output.push(event.item) + if (event.type === "response.completed" || event.type === "response.done") { + this.continuation.record(this.prepared, asJsonObject(event.response), this.output) + this.finished = true + } + return "emit" + } + + private handleError(event: CodexResponseEvent): "retry" { + const error = asJsonObject(event.error) + const canRecover = + error.code === "previous_response_not_found" && + this.prepared.fullContextReason === undefined && + !this.received && + !this.recovered + if (!canRecover) throw responseError(event) + this.recovered = true + this.continuation.reset() + return "retry" + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketUnavailableError.ts b/src/api/providers/codex-websocket/CodexWebSocketUnavailableError.ts new file mode 100644 index 0000000000..5c1a05b4f4 --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketUnavailableError.ts @@ -0,0 +1,2 @@ +/** Only a failed upgrade permits automatic replay over HTTP. */ +export class CodexWebSocketUnavailableError extends Error {} diff --git a/src/api/providers/codex-websocket/PreparedCodexRequest.ts b/src/api/providers/codex-websocket/PreparedCodexRequest.ts new file mode 100644 index 0000000000..09c7822a90 --- /dev/null +++ b/src/api/providers/codex-websocket/PreparedCodexRequest.ts @@ -0,0 +1,12 @@ +import type { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" +import type { JsonObject } from "./protocol" + +export interface PreparedCodexRequest { + request: JsonObject + input: unknown[] + snapshots: CodexWebSocketItemSnapshot[] + settings: string + previousResponseId?: string + offset: number + fullContextReason?: string +} diff --git a/src/api/providers/codex-websocket/protocol.ts b/src/api/providers/codex-websocket/protocol.ts new file mode 100644 index 0000000000..56542e3665 --- /dev/null +++ b/src/api/providers/codex-websocket/protocol.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto" +import stringify from "safe-stable-stringify" + +export type JsonObject = Record +export type CodexResponseEvent = JsonObject & { type: string } + +export interface CodexWebSocketOptions { + headers: Record + signal: AbortSignal + timeoutMs: number +} + +export function asJsonObject(value: unknown): JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Invalid Codex WebSocket payload") + } + return value as JsonObject +} + +export function parseResponseEvent(data: string): CodexResponseEvent { + const event = asJsonObject(JSON.parse(data)) + if (typeof event.type !== "string") throw new Error("Invalid Codex WebSocket event type") + return event as CodexResponseEvent +} + +export function fingerprint(value: unknown): string { + return createHash("sha256") + .update(stringify(value) ?? "null") + .digest("hex") +} + +export function responseError(event: CodexResponseEvent): Error { + const response = event.response ? asJsonObject(event.response) : undefined + const error = event.error ?? response?.error ?? response?.incomplete_details + const details = error ? asJsonObject(error) : {} + return new Error( + `Codex WebSocket (${String(event.status ?? event.type)}): ${String(details.message ?? details.code ?? details.reason ?? "request failed")}`, + ) +} diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index ff8f4a24b0..24c753983b 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -6,6 +6,7 @@ import OpenAI from "openai" import { type ModelInfo, OPEN_AI_CODEX_SERVICE_TIER_KEY, + DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET, OpenAiCodexServiceTier, openAiCodexDefaultModelId, OpenAiCodexModelId, @@ -24,6 +25,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" +import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "./CodexWebSocketTransport" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -129,6 +131,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion protected options: ApiHandlerOptions private readonly providerName = "OpenAI Codex" private client?: OpenAI + private readonly webSocketTransport?: CodexWebSocketTransport // Complete response output array private lastResponseOutput: any[] | undefined // Last top-level response id @@ -181,6 +184,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion constructor(options: ApiHandlerOptions) { super() this.options = options + if (options.openAiCodexUseWebSocket ?? DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET) { + this.webSocketTransport = CodexWebSocketTransport.create() + } // Generate a new session ID for standalone handler usage (fallback) this.sessionId = uuidv7() } @@ -248,6 +254,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.sawTextDeltaInCurrentResponse = false this.sawSdkEventInCurrentResponse = false this.streamedToolCallIds.clear() + if (metadata?.suppressPreviousResponseId) this.webSocketTransport?.resetContinuation() // Get access token from OAuth manager let accessToken = await openAiCodexOAuthManager.getAccessToken() @@ -491,13 +498,20 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion timeout: this.timeoutMs, }) - const stream = (await (client as any).responses.create(requestBody, { - signal: this.abortController.signal, - // If the SDK supports per-request overrides, ensure headers are present. - headers: codexHeaders, - })) as AsyncIterable - - if (typeof (stream as any)?.[Symbol.asyncIterator] !== "function") { + const sdkRequest: OpenAI.Responses.ResponseCreateParamsStreaming = requestBody + const stream = this.webSocketTransport + ? this.webSocketTransport.stream(requestBody, { + headers: { ...codexHeaders, Authorization: `Bearer ${accessToken}` }, + signal: this.abortController.signal, + timeoutMs: this.timeoutMs, + }) + : await client.responses.create(sdkRequest, { + signal: this.abortController.signal, + // If the SDK supports per-request overrides, ensure headers are present. + headers: codexHeaders, + }) + + if (typeof stream?.[Symbol.asyncIterator] !== "function") { throw new Error( "OpenAI SDK did not return an AsyncIterable for Responses API streaming. Falling back to SSE.", ) @@ -530,6 +544,11 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (this.sawSdkEventInCurrentResponse || this.abortController?.signal.aborted) { throw sdkErr } + // A sent WebSocket request may be accepted even before its first event arrives. + // Only a failed HTTP upgrade is safe to replay through the existing fallback. + if (this.webSocketTransport && !(sdkErr instanceof CodexWebSocketUnavailableError)) { + throw sdkErr + } // Fallback to manual SSE via fetch (Codex backend). yield* this.makeCodexRequest(requestBody, model, accessToken, effectiveSessionId) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 13e1aeeb2d..297d9fd877 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -459,6 +459,41 @@ describe("ProviderSettingsManager", () => { expect(storedConfig).toEqual(expectedConfig) }) + it.each([true, false, undefined])( + "round-trips Codex WebSocket preference %s in a saved profile", + async (enabled) => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: {} }, + modeApiConfigs: {}, + }), + ) + const configuration = { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + ...(enabled !== undefined ? { openAiCodexUseWebSocket: enabled } : {}), + } + await providerSettingsManager.saveConfig("codex", configuration) + const stored = mockSecrets.store.mock.calls.at(-1)?.[1] + if (typeof stored !== "string") throw new Error("Profile was not stored") + mockSecrets.get.mockResolvedValue(stored) + expect(await providerSettingsManager.getProfile({ name: "codex" })).toMatchObject(configuration) + expect((await providerSettingsManager.getProfile({ name: "codex" })).openAiCodexUseWebSocket).toBe( + enabled, + ) + const exported = await providerSettingsManager.export() + expect(exported.apiConfigs.codex.openAiCodexUseWebSocket).toBe(enabled) + await providerSettingsManager.import(exported) + const imported = mockSecrets.store.mock.calls.at(-1)?.[1] + if (typeof imported !== "string") throw new Error("Profile was not imported") + mockSecrets.get.mockResolvedValue(imported) + expect((await providerSettingsManager.getProfile({ name: "codex" })).openAiCodexUseWebSocket).toBe( + enabled, + ) + }, + ) + it.each([OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority] as const)( "should persist the OpenAI Codex %s speed preference", async (openAiCodexServiceTier) => { diff --git a/src/core/task/__tests__/apiConversationHistory.spec.ts b/src/core/task/__tests__/apiConversationHistory.spec.ts index 7313e4fa1c..bacb3c03d0 100644 --- a/src/core/task/__tests__/apiConversationHistory.spec.ts +++ b/src/core/task/__tests__/apiConversationHistory.spec.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { prepareApiConversationMessage } from "../apiConversationHistory.js" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import type { ApiHandler } from "../../../api" describe("prepareApiConversationMessage", () => { beforeEach(() => { @@ -83,6 +84,40 @@ describe("prepareApiConversationMessage", () => { ]) }) + it.each(["reasoning-1", undefined])("preserves encrypted reasoning alongside its visible text (id: %s)", (id) => { + const api = { + createMessage: vi.fn(), + getModel: vi.fn(), + countTokens: vi.fn(), + getEncryptedContent: () => ({ encrypted_content: "encrypted", ...(id ? { id } : {}) }), + } + const result = prepareApiConversationMessage({ + message: { + role: "assistant", + content: [ + { type: "text", text: "Reading files" }, + { type: "tool_use", id: "call-1", name: "read_file", input: { path: "a.ts" } }, + ], + }, + reasoning: "visible reasoning", + api, + apiConfiguration: { apiProvider: providerIdentifiers.openaiCodex }, + apiConversationHistory: [], + }) + + expect(result.content).toEqual([ + { + type: "reasoning", + text: "visible reasoning", + summary: [], + encrypted_content: "encrypted", + ...(id ? { id } : {}), + }, + { type: "text", text: "Reading files" }, + { type: "tool_use", id: "call-1", name: "read_file", input: { path: "a.ts" } }, + ]) + }) + it("appends thought signatures for non-Anthropic protocols", () => { const result = prepareApiConversationMessage({ message: { role: "assistant", content: "answer" }, diff --git a/src/core/task/apiConversationHistory.ts b/src/core/task/apiConversationHistory.ts index d1e609c8dc..c75fb4f1c5 100644 --- a/src/core/task/apiConversationHistory.ts +++ b/src/core/task/apiConversationHistory.ts @@ -82,6 +82,13 @@ function prepareAssistantMessage( type: "reasoning", text: reasoning, summary: [] as any[], + // A visible summary must not discard the encrypted state needed for continuation. + ...(reasoningData?.encrypted_content + ? { + encrypted_content: reasoningData.encrypted_content, + ...(reasoningData.id ? { id: reasoningData.id } : {}), + } + : {}), } prependContentBlock(messageWithTs, reasoningBlock) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 0e4aa4aa18..f9f1ca6eff 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1945,6 +1945,20 @@ describe("ClineProvider", () => { expect(postedState.apiConfiguration).toMatchObject(expectedConfiguration) }) + test.each([true, false, undefined])( + "returns saved Codex WebSocket preference %s through the webview round trip", + async (enabled) => { + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.openaiCodex, + ...(enabled !== undefined ? { openAiCodexUseWebSocket: enabled } : {}), + } + await provider.contextProxy.setProviderSettings(configuration) + expect(provider.contextProxy.getProviderSettings().openAiCodexUseWebSocket).toBe(enabled) + expect((await provider.getState()).apiConfiguration.openAiCodexUseWebSocket).toBe(enabled) + expect((await provider.getStateToPostToWebview()).apiConfiguration.openAiCodexUseWebSocket).toBe(enabled) + }, + ) + test.each([true, false, undefined])( "returns saved OpenAI-compatible reasoning settings to the webview when enabled is %s", async (enableReasoningEffort) => { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 908159f7ab..fb3369f638 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -391,7 +391,7 @@ }, "api/providers/openai-codex.ts": { "@typescript-eslint/no-explicit-any": { - "count": 31 + "count": 28 } }, "api/providers/openai-native.ts": { diff --git a/src/package.json b/src/package.json index 0931ca779d..02f0b050ee 100644 --- a/src/package.json +++ b/src/package.json @@ -540,6 +540,7 @@ "vscode-material-icons": "^0.1.1", "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", + "ws": "^8.18.3", "yaml": "^2.8.0", "zod": "3.25.76" }, @@ -557,6 +558,7 @@ "@types/ps-tree": "1.1.6", "@types/semver-compare": "1.0.3", "@types/vscode": "1.100.0", + "@types/ws": "^8.18.1", "@vitest/coverage-v8": "4.1.9", "@vscode/vsce": "3.9.2", "ai": "6.0.218", diff --git a/webview-ui/playwright/roo-code-types.ts b/webview-ui/playwright/roo-code-types.ts index 5d0c0bbf15..3748a0b09c 100644 --- a/webview-ui/playwright/roo-code-types.ts +++ b/webview-ui/playwright/roo-code-types.ts @@ -1,4 +1,5 @@ export { DEFAULT_MODES } from "../../packages/types/src/mode" +export { DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET } from "../../packages/types/src/provider-settings/openai-codex" export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES = false export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED = false diff --git a/webview-ui/playwright/vite.config.ts b/webview-ui/playwright/vite.config.ts index 6a25d792f5..01a0b4b548 100644 --- a/webview-ui/playwright/vite.config.ts +++ b/webview-ui/playwright/vite.config.ts @@ -13,6 +13,7 @@ const rooCodeTypesShimImporters = [ "/webview-ui/src/components/chat/CodeIndexPopover.tsx", "/webview-ui/src/components/chat/ModeSelector.tsx", "/webview-ui/src/components/settings/UISettings.tsx", + "/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx", ] export default defineConfig({ diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 575da5a833..1a02f5750a 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -8,6 +8,7 @@ import React from "react" import SettingsView from "../SettingsView" import { vscode } from "@src/utils/vscode" +import { OpenAICodexWebSocketToggle } from "../providers/OpenAICodexWebSocketToggle" const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => {}) @@ -684,6 +685,45 @@ describe("SettingsView - Unsaved Changes Detection", () => { }) }) + it.each([true, false, undefined])( + "buffers Codex WebSocket edits from %s until Save despite a live refresh", + async (enabled) => { + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.openaiCodex, + apiModelId: "gpt-5.6-sol", + openAiCodexUseWebSocket: enabled, + } + vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: configuration, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( + setApiConfigurationField("openAiCodexUseWebSocket", value)} + /> + )) + const view = renderWithExtensionState(, { queryClient }) + fireEvent.click(await screen.findByRole("checkbox", { name: "settings:openAiCodexWebSocket.label" })) + vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: { ...configuration }, + }) + view.rerender() + const checkbox = screen.getByRole("checkbox", { name: "settings:openAiCodexWebSocket.label" }) + if (enabled) expect(checkbox).not.toBeChecked() + else expect(checkbox).toBeChecked() + expect(configuration.openAiCodexUseWebSocket).toBe(enabled) + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + fireEvent.click(screen.getByTestId("save-button")) + expect(postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { ...configuration, openAiCodexUseWebSocket: !enabled }, + }) + }, + ) + it("keeps OpenAI-compatible reasoning edits cached until Save despite a live state refresh", async () => { const configuration: ProviderSettings = { apiProvider: providerIdentifiers.openai, diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index ee2abf0e2b..26ed874817 100644 --- a/webview-ui/src/components/settings/providers/OpenAICodex.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -14,6 +14,7 @@ import { vscode } from "@src/utils/vscode" import { ModelPicker } from "../ModelPicker" import { OpenAICodexRateLimitDashboard } from "./OpenAICodexRateLimitDashboard" import { OpenAICodexSpeedSelector } from "./OpenAICodexSpeedSelector" +import { OpenAICodexWebSocketToggle } from "./OpenAICodexWebSocketToggle" interface OpenAICodexProps { apiConfiguration: ProviderSettings @@ -77,6 +78,10 @@ export const OpenAICodex: React.FC = ({ value={apiConfiguration[OPEN_AI_CODEX_SERVICE_TIER_KEY]} onValueChange={(value) => setApiConfigurationField(OPEN_AI_CODEX_SERVICE_TIER_KEY, value)} /> + setApiConfigurationField("openAiCodexUseWebSocket", value)} + /> ) } diff --git a/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx b/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx new file mode 100644 index 0000000000..8e985774da --- /dev/null +++ b/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx @@ -0,0 +1,25 @@ +import { Checkbox } from "vscrui" +import { DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" + +interface OpenAICodexWebSocketToggleProps { + value?: boolean + onChange: (enabled: boolean) => void +} + +export function OpenAICodexWebSocketToggle({ value, onChange }: OpenAICodexWebSocketToggleProps) { + const { t } = useAppTranslation() + return ( +
+ onChange(checked === true)}> + {t("settings:openAiCodexWebSocket.label")} + +

+ {t("settings:openAiCodexWebSocket.description")} +

+
+ ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx index d73120ada3..4a82a1a4c8 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx @@ -17,6 +17,7 @@ vi.mock("@src/i18n/TranslationContext", () => ({ t: (key: string) => ({ "settings:openAiCodexSpeed.label": "Speed", + "settings:openAiCodexWebSocket.label": "Use WebSocket", "settings:openAiCodexSpeed.tooltip": "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", "settings:openAiCodexSpeed.standard": "Standard", @@ -95,3 +96,26 @@ describe("OpenAICodex speed selector", () => { expect(postMessage).not.toHaveBeenCalled() }) }) + +describe("OpenAICodex WebSocket preference", () => { + it.each([undefined, false, true])( + "renders saved preference %s and edits through the cached settings callback", + (enabled) => { + const setApiConfigurationField = vi.fn() + const configuration: ProviderSettings = { + apiProvider: providerIdentifiers.openaiCodex, + openAiCodexUseWebSocket: enabled, + } + render() + const checkbox = screen.getByRole("checkbox", { name: "Use WebSocket" }) + if (enabled) expect(checkbox).toBeChecked() + else expect(checkbox).not.toBeChecked() + fireEvent.click(checkbox) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("openAiCodexUseWebSocket", !enabled) + expect(configuration.openAiCodexUseWebSocket).toBe(enabled) + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "upsertApiConfiguration" }), + ) + }, + ) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx index 2061f00614..2030599d3e 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx @@ -6,9 +6,13 @@ import { OpenAiCodexServiceTier } from "@roo-code/types/model" import { TranslationContext } from "@src/i18n/TranslationContext" import { TooltipProvider } from "@src/components/ui/tooltip" import { OpenAICodexSpeedSelector } from "../OpenAICodexSpeedSelector" +import { OpenAICodexWebSocketToggle } from "../OpenAICodexWebSocketToggle" const translations: Record = { "settings:common.select": "Select", + "settings:openAiCodexWebSocket.label": "Use WebSocket", + "settings:openAiCodexWebSocket.description": + "Keeps the ChatGPT connection open between requests. Uses HTTP if a WebSocket connection cannot be established.", "settings:openAiCodexSpeed.label": "Speed", "settings:openAiCodexSpeed.tooltip": "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", @@ -25,7 +29,9 @@ export const OpenAICodexFixture = () => (
{}} /> + {}} /> {}} /> + {}} />
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx index a0fcafdf93..30d3912264 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx @@ -21,7 +21,9 @@ const themes = [ ] as const for (const theme of themes) { - test(`renders both OpenAI Codex speeds in the VS Code ${theme.name} theme`, async ({ mount }) => { + test(`renders OpenAI Codex speed and WebSocket preferences in the VS Code ${theme.name} theme`, async ({ + mount, + }) => { const component = mountedStory(await mount("openai-codex")) const selectors = component.getByTestId("openai-codex-service-tier") const comboboxes = component.getByRole("combobox", { name: "Speed" }) @@ -29,6 +31,10 @@ for (const theme of themes) { await expect(selectors).toHaveCount(2) await expect(comboboxes).toHaveCount(2) + const checkboxes = component.getByRole("checkbox", { name: "Use WebSocket" }) + await expect(checkboxes).toHaveCount(2) + await expect(checkboxes.first()).not.toBeChecked() + await expect(checkboxes.last()).toBeChecked() await selector.evaluate((element, { bodyClass, themeId }) => { const { document } = element.ownerDocument.defaultView! diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png index a168e8656ee47ef0fa9b624d78520e78d348ffdf..1c03f9c4275f234cab35ab0ca53722b069e6d7e9 100644 GIT binary patch literal 19690 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

7$w|w*3W*Les`@{}3D+@}u)nVz~MH5r{bp z(r+TTP8KT&E`cktLNpF*5xn{!Yi*{|SR9NCwgcs_WvtCLC))Ob7?vf00lWId>!A6# z>&llyM1qgafP@y!kt*9Kq^g9?%K_UP*rzQs1=;$MEi~0Scw=zJ_CC3F9m}rH=CsEA ziMm+#BU?*U7TR@h&I~>t{j$vIIGh6MnVdxXvi=7Ij zqQ5dk?K672l|%B4EFSM(QqjeJ+uNW_#-8;qlmM>>Ah$%C`{%tN5*>5gj(BI59cza0?z>X7Ddqf?IHyF9{xe@WGt``3O2lLLfK<2<{f#-JJvt zF1xe;s#B-V)w$UJzWQ%gb-i8Pz24PJp67WZwKY|U2_OU*7#PHAs){c$Ffdnu>qHP1 zV3S*J$uTg%7;1_zU9XITOkZc+NzCryts1&#(C3V-((qvP$1y~CwVgmJb3X)HdG;;T8C#mLZ|_M#;?)yv%MfM% z&47kxa`8jrv-&k{z7P_4L`z;5%s@}C#Hh+{snL7H2r2D#WZ`tk;R6~BRc4a$sjjd0 z?g%5kbdZ;Cz5c!b2oLX>)6&B1Y+6bRRnCghV{-Cfjq+`W`ELdJ`7sPqQ+I{kJNe=- z4D>I&t4Eh#B)0f^`YZKpM}{?h`l{T?rbPEAR#L_|kTg6+@74Cu29KY?S#~xyy{>tq z5lOkZxpy}g*>VB?=LfS@c7wrHLw$V}#wETNzeLm2$3JUb82^)+IyE|q>C!VS<9jaO zyciF$srct@>^H^_x$@6lm6a>IL`R(s=^PBl`8hrVk);{QwIK;#r^2n8ZM0|$4rlVO zL!(zW`rcorxK$rbe9?K&t^pCYQ@63%077bGX<%SrXlRH;HrlVE@F_ABf)kv?-;~w3 z7ngEjF)p!!pf)18#yQZkzP>({IQq8AMaSWRfrjI5x))DyCB?-r+d~QAF5@L7C5wxT z35y2@Z>MECySsh(Vxpq18;xO%RBnbPBsyQaSRp^x$d`M_>CXE;0vUVDFiG~P+Wpr; zeKZY!7&W17KSMWBadGjclVE?iyu{O|B#BB!Mn-CC_{vzTaiLGzdKIj!tQx5q<^1VG zU5A)VCGZ#*Nf+i{nsKmyX8&l`Pw$aF_=~%Ht8+qeRC}=<3WrBLo}^Ze($ptPa3w)U%6aY=k8-@ckPQ0GPTeoWM`zI85#Z;b9w zl~m2XO-V_qsi|QM|5?JtRZ*%stXd>-wyPL--<`Bz`a$%E+Ai(;k^%|$345Th2VUs0M1Aq~l)8?Rdr z8B-NOPy<3FqX-nWLrL)e+T>Mak#+(cfj~Gr3s5U>K}S(wSKHmgLr-yfKR-XJ=iBtKZ`D>M>K zprN6mAox`Q!kQev80blj0>e!M1>lU9mSr*W3YnH$Dl6TNaL}=e%?=q24M%nEi=0g< z>@P!r)A;$SVd3F~8VE8(jKP|h`ZEx8qf(xG6#-3$%NT!)jEnoVyGsVV5Q!1Id;OQ( z@(Y=0g{9cF0ri8v_Z<8t--yh@+bni=cD#PoZ_mxmX=i=>=yQHxw7qXDdX&d>SNQ$= z_wsV>N&VU-pMCdfvs%9!4;To_0_w&O2?+t>GN~D-|1(Dc4oAjh9kx8&cXxMx&hp`w zl4{7$--zVMO1Pk2^4`vzlcWGPpu?OF)#WdI{aITMg znBpTN>u-KFEc+fdeR$=A-_-xrsOpnec3kM#*cjl!b58zPzlXaE8a`vx@93zg{{DXL z#}LSO=hb!;3g_zDIdXb%doVrR_A7F4;!Ewazb{Zi)8+wwIy&#q4pHDWDd+d#C!h$E ztIOkcGSkd8wrR=u#WB2Dz2PdZgpOB7hc!2+ggWf?*ZLs*=<;6aBxsyu~@(4FLia~)#iudq9R7`jYJ9#&6%I);fy|@ z^p>M`!a@Po#{_X2jgJ1yeixQ*yp6yPYZqJx#9FQf%^Ez=B%X`zH2%$}LsCHLBUz-S z>3y&s-c-$Igi^4p2O6P1y1Tny)iJR)fx@A-aAIR21k<7U?K;x~=pt&vbF)7sVJPGg zY5TCgzW%E^XS6cr{=srfi^}Tl)oBDQXLpRBNy$e^7KfhiWCbsC5vCMUBhIj8OYu8=6)k`KBUnCmvE<9&%#C+pI^Aa5p2YUtA_`q@X=z#hqjKSD z@aFnDA-1!t3uc)q^H{RgyAnWHCSZ2Jj>waQ3q-5+u2}u<{Ge3nQzE$ z7{Pj-E0R3&v_Q;LpOj8nS-Jlfv}6td5Fb-f<8(=dW|0M{Tp#Li%AJAIut2P1tadA| zPmcvMME*{S5e4H&g*X*%7nd~?cy~QsAr@N@m|0qhkdl(J?5K#5=H%k)>lqZx<*W^k z?s3j#Mu9ctljZbBa)#h!h)X3Pu|JUqmBp*|XUX`c@|*kjFnBTt$g2nO8fvz-&M@Ge zC1}XqosAF6-W*_)N@I*VIXEc&B8rxoS^DO*9OnYhA69LB{`|S^s;yYS{nd8B{b{zn zy?tJGV5TRx6cklhfJVa*xL75tyLosqN=0)RFw7+LS~w23LYuKh@6YknI?_`b@1_@obtO5nj&e{F4@H%F(ZODGZ%8M&%+g%9miv3M#7 zFMK`K^O=s|cXdq-S`7T(9iu|P$`H-SheiA$&Tv*D1<1}`3ls_^FHocO6_WjEadwuA za``C;iXsZmpy$Qa8Q6mJoZ!*D2HLF`?#W|H%8e^}QYv4taT_xfj}1NgLlOk-BnGkI z_;3oJqQb)2>Md*MUqk{Br)FFQn5DW{Y9eXs&(cepn#_n|E$eP*kN*NDu#F9?57EqC zG{M9hc&}b?n+52Si1*_6@5$+D(t%i-j#Dp!UKcmFsu-yecZ-RavUf&+qliaXJI>-j z&-V54$rCOg!i*50iAjcHY5be4tvJ(+oE6ST>-Xbh<4F|jxgEi% zj;S$i9le*Prn56Vz-A{bR8>{M_o)9RSbEiN{AUP1$Gh9fi_ens{8iDzXwu~KyTZCB zC!giif2OsN_EeZx5BKTbTaonG?5A^YqE88mcxg}V6_0IefEgV4A5i)qQ2HNG`fmfJ z|AMIiG(9l+zo04C{r?X%_3^=hqD*lg6X9KH`@Zi(*>bv@n9%8(5T(gqab2-SV_*=; z{m}wO?$81r2Q!6*oD=wWczZbR=O-fea1d>isuOofwe~9rZb7P(5G{d%>quc6fRQRN ztoecd&S^!gMx=*${TdQw$uP!@Mm{BPtOCa08WxJ|1Vrd-iv!ss5|+c#S^^dW6vG%@ zTwLHh5M1NtiIhEc5cGq5l#Q2H-3W?0sN0JzuE!<>41Y?PPaa2I$u*H@n;;-5IrqWn z-LUj&%zk?DtAM-TemCd%%c>e00ImCaN>yEYdlW9$>Hqdw`mNWn%!T?t0;`@aH<{CS zw^zdfsbyP92QwAxnVw5z1|%DBfEm4+`bzsYlxeKPr?NSklu#F3K8-s<6cy2Zn9GwN^wfUN;H^UKn>lCrYR z#l_EygeM+umLKj#r%lI4M_1iBb}HxQ=ch|dIBvtmmXX4&tcs>73&_*K%W0qWSTX4U zJslkiT3U&}$5`LZ2ZIB!*HO+GzV)B8Rx|C9Th z!)$49a*l7)9jle?Zud-cpzx5)TH_M&)z9i_C+G7{4W$@z%>dnXS?`{loMaGpUIBRN z9z@Ps&!ACxBlbb8R9Ov3mlN2 zpVA%SH=xa*#NwPdGyyDEBwn*5my(xpJKxN`H>pHO;_AGPtzvRl3@@8;adAWR)@!C; zHT<43$qu;phD9(1KX_!6ytZd8j#F|b8mv+VtE?cy+VAfU10IGvn?vXoI3?$2XCrxY zzkYrDbFOCQ0j&^j5%uur{zc2f9o!5Xiy65+oc)2R)?uD~c)Kld3`PNje~BX-d1BCV zO=T)^j*%()m}nrCuY=%{Du2YCLUm?6UXEn7V+h?YcektcBH|5&>iLf};fLR40k9Hn zOG>x~SYN*E;@2W!TEl*610IjMs;WG%+tCVaN0e3BH0kg0Pq5&Mz`>S_rbAf7A5{Cg z%(VU`r<03fCfaDk3I$bDQ$v)-#1Qcs=<4Q-`X5J|Thz4NZt`pI0YU8%IzOqHt*G4I zLV%HZ@v_%D_1js`H8s!a_vK|qg-w9$9zCw2_geuM7Z8!lwjJHaDxzm>dpVBvKdE+5TpQfbD2RT ze^o6MkjhqBD7nSNXb1@1j9W?0F@b&WTmRQT(Pp6ja$ufVS$do0Jg8eRA>lrE5l3o*R zjJF#0!*7$vJV+z#OQ(T64c1}(o9WWCvv)hu({L~QI9UA@rZnV%jr%J~LeU0n%w z$;{z=+}!Q9vPE}$#by2VB89?51ipmvF~1(g#!!c?cL!h>?fF(I*vp88lPRZnFt2lX ztXVG;O?E%1eQwXbKjZkvmh#D+3fH^Ssq61KpXYNjFz zqQ!J3=vZE9pQ`~gy(rvsQ5Xa05uz{0uO)y?c}z4!IU9Q!6>Xd03$P*QkFE?m?4{$Y z=!Cy}32n+&UK&s0!rk3#s#`HsP=e;)-*LTV7zzNfHPQ_Q)M_v@m8V5~OT@_88lj93 z>PK@`ngk=B?5b#36pRMs9wyS|o+Lak3oV}nQQP1PxvUDGj4{f~*e*xoCI7}0a+Vif z+n2wyy~%ONR8cWW8?;+>tMXl>Uj{imdG5@-J#$b1OCpK&UyI~8D#N;n)x%RacAfS~vQZJ7F?kfL&0=!3l|2c7$*uEp|#jaFpaa z%YA_!@ik0dL>w`R6^oQd;L2Wikjiz6O5t5I(}D>pQ+aam!P;xr4Ci2j{a0e)@`{Rz zpzNUe;2}v-vd`bgZ#tsdC7zl$-=^7Txy2w16Ko#n+?l%r7yT(?V8K^X z2)Nrf_gtiATD@U5(RSM)2SdK>){%#QvcN3j8~lB|-XplghD-Cs(Zk~zbt5%=&62dh ztBp@GKGa74S@2=&?Vo`O7aS@!DD#N!c7C_9SP3Q$onbgmJF1^;a*U%^_>LtN1IO9P zqQ=k*-cH#Bx!FKVbp^V4C@+HIwC^=4#1z@YQ{O7rv*US@eQ{d+P9;;2Q7t@E+Zw=d z&jx`&aG)$C8;^78%W^a_k4e9-E|L9_Z~~_gzDbO{S0l^OX&6(LitW~C;zB-G?JNji;9)Xn$T`w_)iK6_jU87pemt`oxunh{WWMZ0}Y z)rUbC@icrOU<;aD7ZO0da%Ny7Cp7sA-j*hQ5C4^y?KX&>aPi8F($c)xoetzh?ZP zS{yM*7Ow4TqooEfkWukp81m=?q_Oh>^ZS>RNT&n(DXl`ZSz8Hsi8#;DF?6R;n%|?5 z+Gpuq$dX|PpLbzcdX~=`dl_5<=t7MqFrqj&>G2dF+*^ ziq2wD5Dy-Ximv@y3v=Mq${Rf?1e%E6tnKqjhfzmI+y+JApq;P~LL3GQqnnA^*s!^! z@Obxbh1sH+zJph|Or{0a6K1g39o$ z#@;z|bubK0)U1$~<_>5}bYNxB8Q7_W;H=S7i^`*cAtgZz1!yR)li2@KG#qwL1wyX3+zzV3 zep*g<|HvP*_g-tyHAjsaRW*0m2L;L3h;I=gARt~#ONl8%K)hN3{|MkA!CzvxPI4h2 z&>*D6gjL-$53&$46(*niVkqy8^(1hu{*tKtIxF=}jy0C1dXSEzq>0zVrJ>ZK@J$S& zPc!CptuBbC92AS9l*Jv=fW-^J8+(3i+Ma&oU-a5e+x9>{+PU`~w$i=k9%rmfzD^L6KnC@BuUe( z+M%GJ*6!IG4wZG-4Gm0K$cZJa9LS(SlXhW#LG_%eG$17gu zMujRGpTpYybggIE@0njVnLaKq?!D#TRAvLnWV;Lwn{aG~0R*#=>?~flqd!@^5jads zRYsCFR=lu8ysp+;Luue1tf$J-CTuX>B*c4yUBlt&Zp@?>#tjyGOOA3{Cn#oabePM0 z?niN`#SgtJ)l<26poRN)pbdpXgM%-Rm;G+fHh1WWGdS(C`S1NQ5hn`eGuSPZ6R6|^ z0t5BjS7BOvs*e_%IOq7yM{}D!&d;9rCQGCJQ<(I6F)Eb5$+Y=C{<4{Oy}Pj3|AdKZ z7pY`$`Aj_`12cWhYbv*V*YS)*gF97_JcomsQw*LPD~~_?#mQ>de4U-3)l?a{*e|PT z+u`*0JOnC@>J_?|dy`10s7xPnAy)UND@M}a@3W2YB{1o=ZfAQ%V^*fCAazIEj^@6K zUo6+=w8?%BP7Jz9%#B^KCKCRAx-6ewL$frIA6Kps91!NUuAB&#TY?%52kvZce5ErO z39K(3t1*0bxJ(l5X;On$zvL7KH>rR?D_CJ3X2op2u&^*VRH7O92`_sg%0bz1lxbI@Qpkc8(o6T{d-m780P&V zbq)-@M;jbBzkm4J*&j#FZ`3}0fcxco_Cbno3g3)QgI=+7ss8+XK%E)8@|qDwK=@$C&NqcjuCZS>+Nv`wu?Ywi9FRCny53LC!;C1jYP{^)7kfL^ViLB<`)b-1 zZ%k1wPPa0M%qYYpC3=H`+MMckev-h~LHIw%qgnJSGiv`T!#f)lMJ?B=-Mcwm=ZZU( z$yi;0Y;)Qj|Hhy4L;w|r=~v?`FcMbdR~%nHk=k)4iUn#PVf?&kNh`M&^{(->Eyt77 zPMNNz`QfiNcrwWsZ+2|vf2oyzWN_NrZxhvBTe8Cu`;$Bz4=r5(?YDgfCBU+WPqB~x zU09;z*Yj`z*N=demX`KJRi=awurEz`Z#9+4?X)$JB>OzwJf&>mAd|?raIPY5BAq&l(nkQ6es-ffP;e-3 zSUybmzDKy2ELFcgoDXf$$rD2Wmm+!i9Y-$7TwJA7v03Nkk5uz5M;X|$i&-yHy3)oF3kZ*_6zi!V< zJ&>XKD@aQV=k^?f*T+&($|m#qKGgemg|)q)A}Vq3c{!*}XySL;zrZ&tJ0ouMD|Xx+ zrwoDA%P!`1-WlB)%g4mP&+3hIK+gwt+`00YC30X4Vz@oFBdw+%ocJ#_}% zX7~N6a=Yf!5YR2^{a4T=xDyf*9v@#vEMs5vMBw;%FL?m?gg@Q*olvBZHCL>Z4~k8v z!7=y*)Y2-c(#zMxkCpFMxeZ&!t~rKm0kzrcbHB%__1=2s!}CoB8XpC@L}c-&Cf7RK zMMk~WX0Z3UJ0#OCHeC{p=lISqMtUf$mFR#PWek@A`H+~Z8bALt3A=V?I|?f;|Bq zX8Z^!SeL^&mxr5EcR~0}uyQk1MorGU0Re57Q`&vegybiSZp-{`M+>>afoIzzZLWt} zK1h9oIP@APW0J(HA_%WviR!mjs5&wSKM^B%z>J7R=4(IE~2 zrZq>qLQ(Z<%+XDm7}o(f^ItDG0Ynw89lH+4qT4f(fV{jSM5dQdXYY_KJ2ZJ1oM|Rl zv07^J0@rS6d%V&%L_$RL+=!D*rq|4~i<{e2`>tv+`5g-m35QXv2?>|)>1MsY2Q;yO zkp-9OPa30OzpUP5dmSl-@SMk$@af$W#r(=m$|=~kb)72yX=-C(zuE~y;nE*V3Lbrh zYDxSyw}XQNvEKs=qs7o3o7tbgWtu9X8B8!{7{Wi8^T1^ydG>``W_C8uc#oS^hzYnI zySR0}%CEJk|WzbKc1vI`+bjAYE2j`_aOS=#%t)Il~TY|2ylpT}kbW zR1*{88HPb=&m<-;Zo$&yyx8Q%l>Itdvy9he?+u*a!zo;$18B(~?ALVaY(H66eeIlS zoBQcM;yeG#MnG2=K+bsktB-u8!7n_41Z^`N)_?X-D{m3mNI|3zMo8?*nvu(oIJ zQ%3*{`Y71>sy}qnDvsKopTM&mPGd9c{(^qPUl~y46c`w&U1M%=+U?cL0V>cR3XZHg z5)I(=xW9$&Qj^=Ux~>O|x{ISDtM2aKH5_PRDa-{3+7F{bA3GCsZdx)3WkO%$zkR#6 zeoH|oAh6^NZ3V9sLNokKr~ZS0-!oCIn>~L`&U*mxnB;bLCPDWT64_|YYU7vsk^x;R?orKOc2 zCKm9nw_R*RroLMTC#w0iXPZNy zP`&RD>%hbA=w+yt(g6J$G}+yC0JyrX-nXH|zN5r`Pq*9O)k^P(CPAsgA>)n$&_=+d z0xXJBrBLp;_5MdA$_gth9BAOsiVwOJ%?2cKDn$w=!e`OEN9KTb+S@bx|Noh8pGYvG zhpyvs%k`fOPDNr#Ny+IlO{ep%;pdw^!5=ye0m8%Kl&ft|cl&_4L=$i;mneTz$l@{i z^RP@BZq8}D(BA^Mc}M3KC?Ch|Kjr26e^!SGd0ltdVhDLTtfuGTS&B@lq8W|g71vGGQUN|EQ)!EA+I z8))>jV3!CwRKUE@9n-j=?}NL~>fPo3t)Tr%dr%Pc;gJyC zPt)PMwFoA|K&O337{rJ3oM+XLIoHFvq$EWJst|N?3AB2*F1g(ku zURSBoWc(G)XNU;vM^fjYozK@=Np_BIzQ*TR0PHS??+iX45=sc5(#xiUJHj$2nE0Xv zR@id-N7Qfzzn81=n=vuC^UcA~H-@2xGG4j?uIroasgIi}dZ0Z4HWtrfr%uB z{|a}?s>-m3jErn+B&ULZ5MT`0F=SeAJbuSh0#;SG?0a8hF@d%QR*0C1n;hD)3|SO^ z+O;d>H6Gunr_UKE-_EWs*(6$VzAsF+CRs$ShGG`;zZhs%$@3n@Wtozg^qzh+9qmn( z;|n;%3-dJK{1W6=1ACun!5ate=u;n>oT?6ip7$wCuN}BD+GrpgYHwfP`s>l6&EE-d z_kSH3Wa>bN2mQugh)<_qix_gT+C;XM-+2cO1rgcOhgAX9n~FfcYcG~eJRBMZ#sP&j zT=$xJr4YJSxPz|IPb=#^&DMWw0ZsV4b%2K8 z{<+CQ=%$@ETXS3${DA-o0I&#ZeQnK|1oG3!=Ol>`IB!-dLF`67t0Vw;Sq-<0D9LAl z$QS2?u0zy8B-4a2P_Ze0Q4GY?z}WcQ!})Qq1~Qw=0dl(IcLFs-)ha&;tPVkHyLWeh zL@rpnGQZKAlP=T>ord^te9;b!>(pcFHnY{@Ru`Ph-YVZ@uo4qbR{&7GCx+B)@w~LD zTMU5E02wwhG0n^Dl^&gp6>gHXTDxUuu zR7g%{&2q#&z>SA&$r&pNX8TVWq2xE*X*d7oB4GY;i#?dN$x9d=7x5F8v>305<^>#1?~9h`J8qx$ZLm*IxNjV( zbWxBMjge$$k-M=aUdI{Ppjb&U|Jea6GoLjr5a5|tKq-@03GK+VF<$Kfk76!D|73^} z@_nt6L)6}FC0k+&Ppf&E&?{~ARnK)ZHZ`&+x+shSkCXD?foxHIGflwI}=5U`%zaF z!KLaI0ER2N^Xob=p-JDz$HyBQ8BM<423=<1dg@+HlB8*Nmx1%A@$Ue~OQ0y23?>6u zRhxdb;qrKQ2?)Ge@6#jTad(pxt@fJ(Nq`TP>oin@1^~v{je6(Zai^`JG3`E&HZXTn zadY1;c^z-wfGJ`_$?iH6%x7Vk)Y$YIvCo6a3`i&_64mG1BhQ^kOl4qD0Nn*_tlPu7 z<$5K6*q8H_wunGz0o-|erL>||tIjrS$#LYhGa%_FD;=PJUHhh|J!wPv{S-}N(5TeE zIa$412ZZ$eW<5szhmPhawii8SzV7&w+v2y#X)mB*9G#B{7D*&l*(iYS?X=M^toO1S z{G#ccb_$+5T!6Iz;3KV6%n`_L;%Ww+_JY-U187HJA-3mV^IYk zM*C7Q9V$S>0BX{s?w`}d+>FlmHk{!K;1at$C2YBFfCr(LIh+2;#3Ll+_j~fbNFlmKt5?T(Jlbw{!e{d@N6oi5sOP_Bvp(9jGPt zKIzC4kI44iqe)WGiGcowWvgVUyr-p zqLNP+5eB!TUqu16_KG7D=K^}b5YT@*-foiUwR!`_$|)(h*5Rr@H~!O15hd}|_G5_7 z4!Z&8$iw#j$frGZH_Whx;5Gyr1KbnckEsYiCggG4kouF!O%pyk z`avdUjZAtWG42601nT3OX)r-M>;oW)hm~xQ2b>8^WQiL7kT6{SWYsBbyI>Hapb~^d z(KK==i7_q*#A%4L4J_X)DA-ttITo0ncF_%>qB%t_uyrJ#g9jB{BJRd-zXHLAaI}_+ z+bcfDZX(s9TCSC(YeR!bPtrRG1dG>L?l-HUhet)oXDB%m)T-1 z^X^AcfZk;ulvvIY{+=Q}SrnC4r=Gbdg3!oU4fK(gD%7i+v3$v@tJLOkYu~$F>P5$c z%TDXmObjU*^B|r6sw->z2;axM-PB#H9Pcv(`M7fg3R6g$Qh*DwojfjkqqW1v`i+On zZ8-+L%Gu6}iU@hPCHM-TA>~cDHqHyp6w$YfEBV zysmL6wD$hCoM~e_RDqn6SR@FYGzK@6Jt}i&17?2i&>ym#q6X-MT7r`krRq$yum+t% zR6*v$vjpmzg-ANG+y%GTbk^_qNNXT?nEA0~Gk^6w1pXEXZbw!cIWSlA?pgeu%ORQy zZ9hlow$SnJ41gPl?ZS-ipNFC`AfysH6ger20kS$49Kv0JdD4TUH~Iz7!hCqh=`y4$ zxnIeEt>fw(Js42KQDuvLKq~W))BNW!^CPDx&`39>nJ6Wbed=g5JW-w<*yBvC8D%Lw z3!ev>+W5S0n6P%dq!W+BT| zxT!5o%d(5Wd^67erhaLfxm z=U~SxWUp|#Q{t-<3qla(rKI4IkU)dUHshL;T>bDtqn=&0vDvpMi?@hl4j zc54Y0cAkU|OQ>F4N%WsmqfyjC{)K+5wBvUy?C+e@AwPYY%5M`7-c>Dz`X=`ZT_j?I z00m9{uO{9{h9V_xT$<-=zh_B3%5GIN$rEg)e4Xf5d6Cm94svf3S$?p7_d;an`msCq zc~sM+`5ktH7@5WB(8|tj4F&De`34>A9mHy(r3LYVIvzI9>xf@KK`6&&(0W&~lHHrB z)EgPED^FL%V5v?|{(8yM2J(gJZwNxwraAI__5I3#kbgNpnD$Y;nugz`?DgMbr7o6N z>|A^|1S`tjMu9U*smc8vKD*K)n&HDHEYUd7L1o|+mDvCL%sw-~+ENHvKu zM0zkUnr3vseUNSaolQ2B1e0;xInBFGRVH?b>Ox)%0A6F}DCP+>YQTz+@Jo9|zPS+x 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 diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 2e4ff9298d..abdda9a994 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1179,6 +1179,10 @@ "limitMaxTokensDescription": "Limit the maximum number of tokens in the response", "maxOutputTokensLabel": "Max output tokens", "maxTokensGenerateDescription": "Maximum tokens to generate in response", + "openAiCodexWebSocket": { + "label": "Use WebSocket", + "description": "Keeps the ChatGPT connection open between requests. Uses HTTP if a WebSocket connection cannot be established." + }, "openAiCodexSpeed": { "label": "Speed", "tooltip": "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 177aaa870e..690d714ee4 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Ограничить максимальное количество токенов в ответе", "maxOutputTokensLabel": "Максимальные выходные токены", "maxTokensGenerateDescription": "Максимальные токены для генерации в ответе", + "openAiCodexWebSocket": { + "label": "Использовать WebSocket", + "description": "Сохраняет соединение с ChatGPT между запросами. Если WebSocket-соединение установить не удалось, используется HTTP." + }, "openAiCodexSpeed": { "label": "Скорость", "tooltip": "Быстрый режим использует приоритетную обработку Codex, обеспечивая примерно 1,5-кратную скорость и расходуя больше квоты подписки.", From f8f0bddc763656cc25272311c6b7308394de1e20 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 19:18:38 +0300 Subject: [PATCH 02/10] refactor(codex): extract cached response type and remove architecture doc --- docs/architecture/codex-websocket.md | 56 ------------------- .../codex-websocket/CachedCodexResponse.ts | 7 +++ .../CodexWebSocketContinuation.ts | 3 +- 3 files changed, 9 insertions(+), 57 deletions(-) delete mode 100644 docs/architecture/codex-websocket.md create mode 100644 src/api/providers/codex-websocket/CachedCodexResponse.ts diff --git a/docs/architecture/codex-websocket.md b/docs/architecture/codex-websocket.md deleted file mode 100644 index 04c233fac3..0000000000 --- a/docs/architecture/codex-websocket.md +++ /dev/null @@ -1,56 +0,0 @@ -# Codex WebSocket transport - -The `openAiCodexUseWebSocket` provider preference enables WebSocket streaming for -OpenAI Codex authenticated with a ChatGPT subscription. It defaults to `false`. -The Settings view buffers the checkbox in the selected provider profile until -Save. Provider schemas, profile storage, import/export, and the existing -`apiConfiguration` webview round trip carry the value without a separate global -setting or environment variable. - -## Responsibilities - -- `CodexWebSocketTransport` translates requests into `response.create`, emits the - ordinary Responses events, and enforces one active request. The provider's existing event processor handles - text, reasoning, tools, and usage for both HTTP and WebSocket streams. -- `CodexWebSocketRequestScope` combines caller cancellation with the request's - inactivity deadline and releases its timer and abort listener when disposed. -- `CodexWebSocketResponse` tracks output, completion, and the one permitted - cache-miss recovery independently of socket IO. -- `CodexWebSocketConnection` owns authentication, the socket, and connection - lifetime. It rotates before one hour, reconnects when headers change, closes - after two idle minutes, and throttles failed upgrades for one minute. New - credentials bypass that cooldown. -- `CodexWebSocketContinuation` retains the cached response and compares request - settings and history prefixes to decide whether continuation is safe. Real edits, - compaction, or an explicit reset require full context. -- `CodexWebSocketItemSnapshot` normalizes history items and retains only hashes. - Response-only metadata does not cause false mismatches, and diagnostics identify - changed fields without retaining messages or encrypted payloads. - `PreparedCodexRequest` defines the shared request contract in a separate file. - -The subscription endpoint is `wss://chatgpt.com/backend-api/codex/responses` and -uses the Codex `responses_websockets=2026-02-06` beta header and Responses Lite -metadata. This is distinct from the public API-key Responses endpoint. - -## Recovery rules - -An HTTP upgrade failure allows the provider's existing HTTP fallback. After a -request is sent, a disconnect or protocol error must not automatically replay it: -the server may already have accepted it. A `previous_response_not_found` error -permits exactly one full-context resend, only before any response event arrives. -Failed or incomplete responses preserve the server error details. Stop closes -the connection and clears continuation; the next request starts with full context. - -Reasoning summaries and encrypted reasoning are saved together in conversation -history. The encrypted state is replayed to the provider; the visible summary is -kept for display. This preservation applies to HTTP providers too. - -## Verification - -Loopback WebSocket tests cover connection reuse, incremental input, history -changes, cancellation, timeout, rotation, idle cleanup, cache recovery, upgrade -fallback and refreshed credentials. Provider integration tests cover routing and -preventing ambiguous HTTP replays. Settings tests cover cached editing, Save, -profile persistence, import/export and the host-to-webview round trip for true, -false and unset values. The Codex settings gallery covers both checkbox states in -light and dark themes. diff --git a/src/api/providers/codex-websocket/CachedCodexResponse.ts b/src/api/providers/codex-websocket/CachedCodexResponse.ts new file mode 100644 index 0000000000..76188fef6c --- /dev/null +++ b/src/api/providers/codex-websocket/CachedCodexResponse.ts @@ -0,0 +1,7 @@ +import type { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" + +export interface CachedCodexResponse { + id: string + settings: string + input: CodexWebSocketItemSnapshot[] +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts b/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts index 95dc921b68..583929f542 100644 --- a/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts +++ b/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts @@ -1,10 +1,11 @@ +import type { CachedCodexResponse } from "./CachedCodexResponse" import { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" import type { PreparedCodexRequest } from "./PreparedCodexRequest" import { fingerprint, asJsonObject, type JsonObject } from "./protocol" /** Compares the server output with the history Zoo can reconstruct, retaining only hashes. */ export class CodexWebSocketContinuation { - private cached?: { id: string; settings: string; input: CodexWebSocketItemSnapshot[] } + private cached?: CachedCodexResponse reset(): void { this.cached = undefined From a080ebfef839f9d356a362f45a61b0cf92076e90 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 19:43:05 +0300 Subject: [PATCH 03/10] refactor(codex): scope WebSocket resource initialization and disposal --- src/api/providers/CodexWebSocketTransport.ts | 52 ++-- .../__tests__/CodexWebSocketTransport.spec.ts | 41 ++- .../CodexWebSocketConnection.ts | 51 ++-- .../CodexWebSocketConnectionScope.ts | 39 +++ .../codex-websocket/CodexWebSocketRequest.ts | 87 ++++++ .../CodexWebSocketRequestScope.ts | 67 +++-- .../codex-websocket/CodexWebSocketSocket.ts | 67 +++++ .../__tests__/CodexWebSocketScopes.spec.ts | 272 ++++++++++++++++++ 8 files changed, 607 insertions(+), 69 deletions(-) create mode 100644 src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketRequest.ts create mode 100644 src/api/providers/codex-websocket/CodexWebSocketSocket.ts create mode 100644 src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts diff --git a/src/api/providers/CodexWebSocketTransport.ts b/src/api/providers/CodexWebSocketTransport.ts index 0bd8586bef..cb4c7fa940 100644 --- a/src/api/providers/CodexWebSocketTransport.ts +++ b/src/api/providers/CodexWebSocketTransport.ts @@ -1,4 +1,3 @@ -import { on } from "node:events" import type WebSocket from "ws" import { CodexWebSocketConnection } from "./codex-websocket/CodexWebSocketConnection" @@ -20,6 +19,7 @@ const CODEX_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses" /** Task-local Responses transport. Connection and history management are independent of provider event handling. */ export class CodexWebSocketTransport { private busy = false + private requestScope?: CodexWebSocketRequestScope constructor( private readonly connection: CodexWebSocketConnection, @@ -35,59 +35,59 @@ export class CodexWebSocketTransport { resetContinuation(): void { this.continuation.reset() } - close(): void { - this.connection.close() + async dispose(): Promise { + const scope = this.requestScope + this.requestScope = undefined + this.connection.dispose() + await scope?.dispose() } async *stream(body: unknown, options: CodexWebSocketOptions): AsyncGenerator { if (this.busy) throw new Error("Concurrent requests on a Codex WebSocket are not supported") options.signal.throwIfAborted() - const scope = new CodexWebSocketRequestScope(options, () => this.close()) + const scope = new CodexWebSocketRequestScope(options, () => { + void this.dispose() + }) let response: CodexWebSocketResponse | undefined this.busy = true + this.requestScope = scope try { - const socket = await this.connection.acquire({ ...options, signal: scope.signal }) + await scope.init(this.connection) const prepared = this.continuation.prepare(body) response = new CodexWebSocketResponse(this.continuation, prepared) - yield* this.readResponse(socket, prepared, options.headers, scope, response) + yield* this.readResponse(prepared, options.headers, scope, response) } catch (error) { if (scope.signal.aborted) throw scope.signal.reason throw error } finally { - scope.dispose() + await scope.dispose() + if (this.requestScope === scope) this.requestScope = undefined this.busy = false - if (!response?.completed || scope.signal.aborted) this.close() + if (!response?.completed || scope.signal.aborted) await this.dispose() else this.connection.release() } } private async *readResponse( - socket: WebSocket, prepared: PreparedCodexRequest, headers: Record, scope: CodexWebSocketRequestScope, response: CodexWebSocketResponse, ): AsyncGenerator { - // Register before sending so a fast reply cannot be lost. - const events = on(socket, "message", { signal: scope.signal, close: ["close"] }) - try { + scope.refreshTimeout() + this.send(scope.socket, prepared, headers, prepared.fullContextReason === undefined) + for await (const [data] of scope.events) { scope.refreshTimeout() - this.send(socket, prepared, headers, prepared.fullContextReason === undefined) - for await (const [data] of events) { - scope.refreshTimeout() - const event = parseResponseEvent(String(data)) - if (response.accept(event) === "retry") { - this.send(socket, prepared, headers, false, "server cache miss") - continue - } - if (response.completed) scope.clearTimeout() - yield event - if (response.completed) return + const event = parseResponseEvent(String(data)) + if (response.accept(event) === "retry") { + this.send(scope.socket, prepared, headers, false, "server cache miss") + continue } - throw new Error("Codex WebSocket closed before response completed; retry the request") - } finally { - await events.return?.() + if (response.completed) scope.clearTimeout() + yield event + if (response.completed) return } + throw new Error("Codex WebSocket closed before response completed; retry the request") } private send( diff --git a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts index 349c1af09b..a15efda327 100644 --- a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts +++ b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts @@ -53,7 +53,7 @@ describe("CodexWebSocketTransport", () => { afterEach(async () => { vi.restoreAllMocks() - transport?.close() + await transport?.dispose() nock.disableNetConnect() for (const socket of server.clients) socket.terminate() await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) @@ -346,6 +346,45 @@ describe("CodexWebSocketTransport", () => { expect(requests[1].previous_response_id).toBeUndefined() }) + it("disposes an active stream and reconnects with full context on the next request", async () => { + reply = (_request, socket) => + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "Partial" })) + const stream = transport.stream(body(), options()) + await stream.next() + await transport.dispose() + await expect(stream.next()).rejects.toThrow("closed before response completed") + reply = (_request, socket) => complete(socket) + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }) + + it("disposes a completed idle connection and reconnects without stale continuation", async () => { + await collectStream(transport.stream(body(), options())) + await transport.dispose() + await transport.dispose() + await collectStream(transport.stream(body(), options())) + expect(connections).toBe(2) + expect(requests[1].previous_response_id).toBeUndefined() + }) + + it("disposes request deadlines while the consumer is paused on a partial response", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }) + reply = (_request, socket) => + socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "Partial" })) + const stream = transport.stream(body(), options()) + try { + await stream.next() + expect(vi.getTimerCount()).toBe(1) + await transport.dispose() + expect(vi.getTimerCount()).toBe(0) + await expect(stream.next()).rejects.toThrow("closed before response completed") + } finally { + await stream.return(undefined) + vi.useRealTimers() + } + }) + it("bounds a silent response with a timeout", async () => { reply = () => {} await expect(collectStream(transport.stream(body(), { ...options(), timeoutMs: 100 }))).rejects.toThrow() diff --git a/src/api/providers/codex-websocket/CodexWebSocketConnection.ts b/src/api/providers/codex-websocket/CodexWebSocketConnection.ts index ea9eba8fef..1b608086aa 100644 --- a/src/api/providers/codex-websocket/CodexWebSocketConnection.ts +++ b/src/api/providers/codex-websocket/CodexWebSocketConnection.ts @@ -1,17 +1,16 @@ -import { once } from "node:events" import WebSocket from "ws" +import { CodexWebSocketConnectionScope } from "./CodexWebSocketConnectionScope" import { CodexWebSocketUnavailableError } from "./CodexWebSocketUnavailableError" import { fingerprint, type CodexWebSocketOptions } from "./protocol" const MAX_CONNECTION_AGE_MS = 55 * 60_000 const IDLE_CONNECTION_TIMEOUT_MS = 120_000 -const HANDSHAKE_TIMEOUT_MS = 10_000 const UPGRADE_RETRY_DELAY_MS = 60_000 /** Owns a single authenticated connection and bounds its lifetime between requests. */ export class CodexWebSocketConnection { - private socket?: WebSocket + private scope?: CodexWebSocketConnectionScope private key?: string private unavailableKey?: string private unavailableUntil = 0 @@ -26,33 +25,33 @@ export class CodexWebSocketConnection { async acquire(options: CodexWebSocketOptions): Promise { options.signal.throwIfAborted() clearTimeout(this.idleTimer) + this.idleTimer = undefined const key = fingerprint(options.headers) if (this.unavailableKey === key && Date.now() < this.unavailableUntil) { throw new CodexWebSocketUnavailableError("Codex WebSocket unavailable; using HTTP") } if ( this.key === key && - this.socket?.readyState === WebSocket.OPEN && + this.scope?.socket.readyState === WebSocket.OPEN && Date.now() - this.connectedAt < MAX_CONNECTION_AGE_MS ) { - return this.socket + return this.scope.socket } - this.close() - const socket = new WebSocket(this.url, { - headers: { ...options.headers, "OpenAI-Beta": "responses_websockets=2026-02-06" }, - handshakeTimeout: Math.min(options.timeoutMs, HANDSHAKE_TIMEOUT_MS), - }) - this.socket = socket - // Idle errors must never become uncaught EventEmitter errors; stale sockets cannot clear new state. - socket.on("error", () => { - if (this.socket === socket) this.resetContinuation() - }) - socket.on("close", () => { - if (this.socket === socket) this.close() - }) + this.dispose() + const scope = new CodexWebSocketConnectionScope( + this.url, + () => { + if (this.scope === scope) this.resetContinuation() + }, + () => { + if (this.scope === scope) this.dispose() + }, + ) + this.scope = scope try { - await once(socket, "open", { signal: options.signal }) + await scope.init(options) } catch (error) { + if (this.scope === scope) this.dispose() options.signal.throwIfAborted() this.unavailableKey = key this.unavailableUntil = Date.now() + UPGRADE_RETRY_DELAY_MS @@ -63,21 +62,23 @@ export class CodexWebSocketConnection { this.unavailableKey = undefined this.connectedAt = Date.now() console.info("[Codex WebSocket] Connected") - return socket + return scope.socket } release(): void { + if (!this.scope) return clearTimeout(this.idleTimer) - this.idleTimer = setTimeout(() => this.close(), IDLE_CONNECTION_TIMEOUT_MS) + this.idleTimer = setTimeout(() => this.dispose(), IDLE_CONNECTION_TIMEOUT_MS) this.idleTimer.unref() } - close(): void { + dispose(): void { clearTimeout(this.idleTimer) - const socket = this.socket - this.socket = undefined + this.idleTimer = undefined + const scope = this.scope + this.scope = undefined this.key = undefined this.resetContinuation() - socket?.terminate() + scope?.dispose() } } diff --git a/src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts b/src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts new file mode 100644 index 0000000000..dc1b8bb06a --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts @@ -0,0 +1,39 @@ +import type WebSocket from "ws" + +import { CodexWebSocketSocket } from "./CodexWebSocketSocket" +import type { CodexWebSocketOptions } from "./protocol" + +/** Assembles and owns the services for one authenticated socket. */ +export class CodexWebSocketConnectionScope { + private socketService?: CodexWebSocketSocket + + constructor( + private readonly url: string, + private readonly onError: () => void, + private readonly onClose: () => void, + ) {} + + get socket(): WebSocket { + if (!this.socketService) throw new Error("Codex WebSocket connection scope is not initialized") + return this.socketService.socket + } + + async init(options: CodexWebSocketOptions): Promise { + if (this.socketService) throw new Error("Codex WebSocket connection scope is already initialized") + const socketService = new CodexWebSocketSocket(this.url, this.onError, this.onClose) + this.socketService = socketService + try { + await socketService.init(options) + if (this.socketService !== socketService) throw new Error("Codex WebSocket connection scope was disposed") + } catch (error) { + if (this.socketService === socketService) this.dispose() + throw error + } + } + + dispose(): void { + const socketService = this.socketService + this.socketService = undefined + socketService?.dispose() + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketRequest.ts b/src/api/providers/codex-websocket/CodexWebSocketRequest.ts new file mode 100644 index 0000000000..5eb39d7084 --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketRequest.ts @@ -0,0 +1,87 @@ +import { on } from "node:events" +import type WebSocket from "ws" + +import type { CodexWebSocketConnection } from "./CodexWebSocketConnection" +import type { CodexWebSocketOptions } from "./protocol" + +/** Owns cancellation, the inactivity timer, and response subscriptions for a single request. */ +export class CodexWebSocketRequest { + private controller?: AbortController + private requestSignal?: AbortSignal + private _socket?: WebSocket + private _events?: AsyncIterableIterator + private timeout?: NodeJS.Timeout + private initialized = false + private initializing = false + + constructor( + private readonly options: CodexWebSocketOptions, + private readonly onAbort: () => void, + ) {} + + get signal(): AbortSignal { + return this.requestSignal ?? this.options.signal + } + + get socket(): WebSocket { + if (!this._socket) throw new Error("Codex WebSocket request scope is not initialized") + return this._socket + } + + get events(): AsyncIterableIterator { + if (!this._events) throw new Error("Codex WebSocket request scope is not initialized") + return this._events + } + + async init(connection: CodexWebSocketConnection): Promise { + if (this.initialized || this.initializing) { + throw new Error("Codex WebSocket request scope is already initialized") + } + this.options.signal.throwIfAborted() + this.initialized = true + this.initializing = true + this.controller = new AbortController() + this.requestSignal = AbortSignal.any([this.options.signal, this.controller.signal]) + this.signal.addEventListener("abort", this.onAbort, { once: true }) + try { + this._socket = await connection.acquire({ ...this.options, signal: this.signal }) + this.signal.throwIfAborted() + // Register before sending so a fast reply cannot be lost. + this._events = on(this._socket, "message", { signal: this.signal, close: ["close"] }) + } catch (error) { + await this.disposeResources() + throw error + } finally { + this.initializing = false + } + } + + refreshTimeout(): void { + if (!this._events) throw new Error("Codex WebSocket request scope is not initialized") + this.clearTimeout() + this.timeout = setTimeout( + () => this.controller?.abort(new Error("Codex WebSocket stream timed out")), + this.options.timeoutMs, + ) + } + + clearTimeout(): void { + clearTimeout(this.timeout) + this.timeout = undefined + } + + async dispose(): Promise { + if (this.initializing) this.controller?.abort(new Error("Codex WebSocket request scope was disposed")) + await this.disposeResources() + } + + private async disposeResources(): Promise { + this.clearTimeout() + this.signal.removeEventListener("abort", this.onAbort) + const events = this._events + this._events = undefined + this._socket = undefined + this.initialized = false + await events?.return?.() + } +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts b/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts index 9f0cb537cc..47f5e2b7c7 100644 --- a/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts +++ b/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts @@ -1,34 +1,67 @@ +import type WebSocket from "ws" + +import type { CodexWebSocketConnection } from "./CodexWebSocketConnection" +import { CodexWebSocketRequest } from "./CodexWebSocketRequest" import type { CodexWebSocketOptions } from "./protocol" -/** Owns cancellation and the inactivity timer for a single request. */ +/** Assembles and owns request-local services without owning the reusable connection. */ export class CodexWebSocketRequestScope { - readonly signal: AbortSignal - private readonly controller = new AbortController() - private timeout?: NodeJS.Timeout + private request?: CodexWebSocketRequest + private requestSignal?: AbortSignal + private initializing = false constructor( private readonly options: CodexWebSocketOptions, private readonly onAbort: () => void, - ) { - this.signal = AbortSignal.any([options.signal, this.controller.signal]) - this.signal.addEventListener("abort", onAbort, { once: true }) + ) {} + + get signal(): AbortSignal { + return this.request?.signal ?? this.requestSignal ?? this.options.signal + } + + get socket(): WebSocket { + return this.getRequest().socket + } + + get events(): AsyncIterableIterator { + return this.getRequest().events + } + + async init(connection: CodexWebSocketConnection): Promise { + if (this.request || this.initializing) { + throw new Error("Codex WebSocket request scope is already initialized") + } + const request = new CodexWebSocketRequest(this.options, this.onAbort) + this.request = request + this.initializing = true + try { + await request.init(connection) + if (this.request !== request) throw new Error("Codex WebSocket request scope was disposed") + } catch (error) { + if (this.request === request) await this.dispose() + throw error + } finally { + this.initializing = false + } } refreshTimeout(): void { - this.clearTimeout() - this.timeout = setTimeout( - () => this.controller.abort(new Error("Codex WebSocket stream timed out")), - this.options.timeoutMs, - ) + this.getRequest().refreshTimeout() } clearTimeout(): void { - clearTimeout(this.timeout) - this.timeout = undefined + this.request?.clearTimeout() + } + + async dispose(): Promise { + const request = this.request + this.request = undefined + if (request) this.requestSignal = request.signal + await request?.dispose() } - dispose(): void { - this.clearTimeout() - this.signal.removeEventListener("abort", this.onAbort) + private getRequest(): CodexWebSocketRequest { + if (this.initializing || !this.request) throw new Error("Codex WebSocket request scope is not initialized") + return this.request } } diff --git a/src/api/providers/codex-websocket/CodexWebSocketSocket.ts b/src/api/providers/codex-websocket/CodexWebSocketSocket.ts new file mode 100644 index 0000000000..8f94b9ba01 --- /dev/null +++ b/src/api/providers/codex-websocket/CodexWebSocketSocket.ts @@ -0,0 +1,67 @@ +import { once } from "node:events" +import WebSocket from "ws" + +import type { CodexWebSocketOptions } from "./protocol" + +const HANDSHAKE_TIMEOUT_MS = 10_000 + +/** Owns one socket and its subscriptions, from initialization through terminal close. */ +export class CodexWebSocketSocket { + private _socket?: WebSocket + private disposeSocket?: () => void + + constructor( + private readonly url: string, + private readonly onError: () => void, + private readonly onClose: () => void, + ) {} + + get socket(): WebSocket { + if (!this._socket) throw new Error("Codex WebSocket connection scope is not initialized") + return this._socket + } + + async init(options: CodexWebSocketOptions): Promise { + if (this._socket) throw new Error("Codex WebSocket connection scope is already initialized") + options.signal.throwIfAborted() + const socket = new WebSocket(this.url, { + headers: { ...options.headers, "OpenAI-Beta": "responses_websockets=2026-02-06" }, + handshakeTimeout: Math.min(options.timeoutMs, HANDSHAKE_TIMEOUT_MS), + }) + this._socket = socket + const onError = () => { + if (this._socket === socket) this.onError() + } + const onClose = () => { + if (this._socket === socket) this.onClose() + } + socket.on("error", onError) + socket.on("close", onClose) + this.disposeSocket = () => { + socket.off("close", onClose) + if (socket.readyState === WebSocket.CLOSED) { + socket.off("error", onError) + } else { + // terminate() can emit an asynchronous error during an upgrade. Keep the inert + // error handler until terminal close, then remove it as well. + socket.once("close", () => socket.off("error", onError)) + socket.terminate() + } + } + try { + await once(socket, "open", { signal: options.signal }) + options.signal.throwIfAborted() + if (this._socket !== socket) throw new Error("Codex WebSocket connection scope was disposed") + } catch (error) { + if (this._socket === socket) this.dispose() + throw error + } + } + + dispose(): void { + const disposeSocket = this.disposeSocket + this._socket = undefined + this.disposeSocket = undefined + disposeSocket?.() + } +} diff --git a/src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts b/src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts new file mode 100644 index 0000000000..91d2491853 --- /dev/null +++ b/src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts @@ -0,0 +1,272 @@ +import WebSocket from "ws" + +import { CodexWebSocketConnection } from "../CodexWebSocketConnection" +import { CodexWebSocketConnectionScope } from "../CodexWebSocketConnectionScope" +import { CodexWebSocketRequestScope } from "../CodexWebSocketRequestScope" +import { CodexWebSocketUnavailableError } from "../CodexWebSocketUnavailableError" +import type { CodexWebSocketOptions } from "../protocol" + +const { sockets } = vi.hoisted(() => ({ sockets: [] as WebSocket[] })) + +vi.mock("ws", async () => { + const { EventEmitter } = await import("node:events") + class MockWebSocket extends EventEmitter { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + readyState = MockWebSocket.CONNECTING + + constructor() { + super() + // The mock implements the socket surface used by these scopes, not all of ws. + sockets.push(this as unknown as WebSocket) + } + + override emit(event: string | symbol, ...args: unknown[]): boolean { + if (event === "open") this.readyState = MockWebSocket.OPEN + if (event === "close") this.readyState = MockWebSocket.CLOSED + return super.emit(event, ...args) + } + + terminate = vi.fn(() => { + const connecting = this.readyState === MockWebSocket.CONNECTING + this.readyState = MockWebSocket.CLOSING + queueMicrotask(() => { + if (connecting) this.emit("error", new Error("WebSocket closed before the connection was established")) + this.emit("close") + }) + }) + } + return { default: MockWebSocket } +}) + +describe("Codex WebSocket scopes", () => { + const resources: { dispose(): void | Promise }[] = [] + let controller: AbortController + let options: CodexWebSocketOptions + let reset = vi.fn<() => void>() + let connection: CodexWebSocketConnection + + const own = }>(resource: T): T => { + resources.push(resource) + return resource + } + const lastSocket = (): WebSocket => { + const socket = sockets.at(-1) + if (!socket) throw new Error("No test socket was created") + return socket + } + const expectNoListeners = (socket: WebSocket) => { + expect(socket.eventNames()).toEqual([]) + } + + beforeEach(() => { + sockets.length = 0 + controller = new AbortController() + options = { headers: { Authorization: "Bearer test-token" }, signal: controller.signal, timeoutMs: 2_000 } + reset = vi.fn() + connection = own(new CodexWebSocketConnection("ws://test/responses", reset)) + }) + + afterEach(async () => { + for (const resource of resources.splice(0).reverse()) await resource.dispose() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("does not create sockets, subscriptions, or timers in constructors", async () => { + vi.useFakeTimers() + const addListener = vi.spyOn(AbortSignal.prototype, "addEventListener") + const onAbort = vi.fn() + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn())) + const request = own(new CodexWebSocketRequestScope(options, onAbort)) + expect(sockets).toHaveLength(0) + expect(addListener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(() => scope.socket).toThrow("not initialized") + expect(() => request.socket).toThrow("not initialized") + expect(() => request.events).toThrow("not initialized") + expect(() => request.refreshTimeout()).toThrow("not initialized") + scope.dispose() + await request.dispose() + controller.abort() + expect(onAbort).not.toHaveBeenCalled() + }) + + it("creates the connection on init and removes its subscriptions on disposal", async () => { + const onError = vi.fn() + const onClose = vi.fn() + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", onError, onClose)) + const initialized = scope.init(options) + const socket = lastSocket() + socket.emit("open") + await initialized + expect(scope.socket).toBe(socket) + await expect(scope.init(options)).rejects.toThrow("already initialized") + socket.emit("error", new Error("Idle error")) + expect(onError).toHaveBeenCalledOnce() + scope.dispose() + scope.dispose() + expect(socket.terminate).toHaveBeenCalledOnce() + expect(() => scope.socket).toThrow("not initialized") + await Promise.resolve() + expect(onClose).not.toHaveBeenCalled() + expectNoListeners(socket) + }) + + it("safely disposes an in-flight upgrade without retaining listeners", async () => { + const onError = vi.fn() + const onClose = vi.fn() + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", onError, onClose)) + const initialized = expect(scope.init(options)).rejects.toThrow("before the connection was established") + const socket = lastSocket() + scope.dispose() + await initialized + expect(onError).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expectNoListeners(socket) + }) + + it("removes subscriptions from an already-closed socket", async () => { + const onClose = vi.fn() + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), onClose)) + const initialized = scope.init(options) + const socket = lastSocket() + socket.emit("open") + await initialized + socket.emit("close") + expect(onClose).toHaveBeenCalledOnce() + scope.dispose() + expect(socket.terminate).not.toHaveBeenCalled() + expectNoListeners(socket) + }) + + it("can initialize again after disposal without stale callbacks affecting the new socket", async () => { + const onError = vi.fn() + const onClose = vi.fn() + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", onError, onClose)) + const firstInit = scope.init(options) + const first = lastSocket() + first.emit("open") + await firstInit + scope.dispose() + const nextInit = scope.init(options) + const next = lastSocket() + first.emit("error", new Error("Stale error")) + next.emit("open") + await nextInit + expectNoListeners(first) + expect(onError).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(scope.socket).toBe(next) + }) + + it("does not open a connection for an already-aborted request", async () => { + controller.abort(new Error("Stopped")) + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn())) + const request = own(new CodexWebSocketRequestScope(options, vi.fn())) + await expect(scope.init(options)).rejects.toThrow("Stopped") + await expect(request.init(connection)).rejects.toThrow("Stopped") + expect(sockets).toHaveLength(0) + }) + + it("owns request subscriptions and deadlines without disposing the reusable connection", async () => { + vi.useFakeTimers() + const onAbort = vi.fn() + const request = own(new CodexWebSocketRequestScope(options, onAbort)) + const initialized = request.init(connection) + const socket = lastSocket() + socket.emit("open") + await initialized + expect(request.socket).toBe(socket) + expect(socket.listenerCount("message")).toBe(1) + await expect(request.init(connection)).rejects.toThrow("already initialized") + request.refreshTimeout() + request.refreshTimeout() + expect(vi.getTimerCount()).toBe(1) + await request.dispose() + await request.dispose() + expect(vi.getTimerCount()).toBe(0) + expect(socket.listenerCount("message")).toBe(0) + expect(socket.listenerCount("error")).toBe(1) + expect(socket.listenerCount("close")).toBe(1) + expect(socket.terminate).not.toHaveBeenCalled() + controller.abort() + expect(onAbort).not.toHaveBeenCalled() + }) + + it("can reinitialize a disposed request on the same connection without duplicate listeners", async () => { + const request = own(new CodexWebSocketRequestScope(options, () => connection.dispose())) + const initialized = request.init(connection) + const socket = lastSocket() + socket.emit("open") + await initialized + await request.dispose() + await request.init(connection) + expect(sockets).toHaveLength(1) + expect(socket.listenerCount("message")).toBe(1) + const event = request.events.next() + const reason = new Error("Stopped") + controller.abort(reason) + await expect(event).rejects.toThrow() + expect(request.signal.reason).toBe(reason) + await request.dispose() + expectNoListeners(socket) + }) + + it("cleans up a rejected upgrade and preserves the safe HTTP fallback error", async () => { + const onAbort = vi.fn() + const request = own(new CodexWebSocketRequestScope(options, onAbort)) + const initialized = expect(request.init(connection)).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) + const socket = lastSocket() + socket.emit("error", new Error("Upgrade rejected")) + await initialized + expect(request.signal.aborted).toBe(false) + expectNoListeners(socket) + controller.abort() + expect(onAbort).not.toHaveBeenCalled() + }) + + it("cancels and cleans up a request disposed during initialization", async () => { + const request = own(new CodexWebSocketRequestScope(options, () => connection.dispose())) + const initialized = expect(request.init(connection)).rejects.toThrow() + const socket = lastSocket() + await request.dispose() + await initialized + expectNoListeners(socket) + expect(() => request.socket).toThrow("not initialized") + expect(() => request.events).toThrow("not initialized") + }) + + it("does not become initialized if disposed after acquiring a socket but before init resolves", async () => { + const socket = new WebSocket("ws://test/responses") + socket.emit("open") + vi.spyOn(connection, "acquire").mockResolvedValue(socket) + const request = own(new CodexWebSocketRequestScope(options, vi.fn())) + const initialized = expect(request.init(connection)).rejects.toThrow("scope was disposed") + await Promise.resolve() + await request.dispose() + await initialized + expectNoListeners(socket) + expect(() => request.socket).toThrow("not initialized") + expect(() => request.events).toThrow("not initialized") + }) + + it("clears an idle connection timer on disposal and does not schedule one without a socket", async () => { + vi.useFakeTimers() + connection.release() + expect(vi.getTimerCount()).toBe(0) + const acquired = connection.acquire(options) + const socket = lastSocket() + socket.emit("open") + await acquired + connection.release() + connection.release() + expect(vi.getTimerCount()).toBe(1) + connection.dispose() + expect(vi.getTimerCount()).toBe(0) + await Promise.resolve() + expectNoListeners(socket) + }) +}) From b8be26bb9354db6e8ecfa43bb4e611a5ea2a7b8f Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 20:46:42 +0300 Subject: [PATCH 04/10] refactor(codex): organize transport layers and scoped state holders --- src/api/providers/CodexWebSocketTransport.ts | 72 ++++--- .../__tests__/CodexWebSocketTransport.spec.ts | 13 +- .../providers/__tests__/openai-codex.spec.ts | 11 ++ .../codex-websocket/CachedCodexResponse.ts | 7 - .../CodexWebSocketConnection.ts | 84 -------- .../CodexWebSocketConnectionScope.ts | 39 ---- .../codex-websocket/CodexWebSocketRequest.ts | 87 --------- .../CodexWebSocketRequestScope.ts | 67 ------- .../codex-websocket/CodexWebSocketResponse.ts | 45 ----- .../CodexWebSocketResponseLocalDataSource.ts | 18 ++ ...exWebSocketResponseLocalDataSource.spec.ts | 42 ++++ .../CodexWebSocketSocketRemoteDataSource.ts} | 4 +- .../CodexWebSocketUnavailableError.ts | 0 .../CodexWebSocketConnectionManager.ts | 94 +++++++++ .../managers/CodexWebSocketRequestManager.ts | 96 ++++++++++ .../managers/CodexWebSocketResponseManager.ts | 64 +++++++ .../CodexWebSocketConnectionManager.spec.ts | 180 ++++++++++++++++++ .../CodexWebSocketRequestManager.spec.ts | 122 ++++++++++++ .../CodexWebSocketResponseManager.spec.ts | 81 ++++++++ .../models/CachedCodexResponse.ts | 7 + .../CodexWebSocketConnectionStateModel.ts | 18 ++ .../CodexWebSocketItemSnapshotModel.ts} | 13 +- .../models/CodexWebSocketRequestStateModel.ts | 13 ++ .../CodexWebSocketResponseStateModel.ts | 9 + .../{ => models}/PreparedCodexRequest.ts | 4 +- .../codex-websocket/models/protocol.ts | 8 + .../CodexWebSocketContinuationRepository.ts} | 33 ++-- ...dexWebSocketContinuationRepository.spec.ts | 82 ++++++++ .../scopes/CodexWebSocketConnectionScope.ts | 45 +++++ .../scopes/CodexWebSocketRequestScope.ts | 71 +++++++ .../scopes/CodexWebSocketTransportScope.ts | 46 +++++ .../__tests__/CodexWebSocketScopes.spec.ts | 159 ++++++++++++---- .../CodexWebSocketConnectionStateHolder.ts | 64 +++++++ .../CodexWebSocketRequestStateHolder.ts | 59 ++++++ .../CodexWebSocketResponseStateHolder.ts | 60 ++++++ .../state-holders/StateHolder.ts | 16 ++ .../CodexWebSocketStateHolders.spec.ts | 128 +++++++++++++ .../codex-websocket/{ => utils}/protocol.ts | 9 +- src/api/providers/openai-codex.ts | 12 +- 39 files changed, 1541 insertions(+), 441 deletions(-) delete mode 100644 src/api/providers/codex-websocket/CachedCodexResponse.ts delete mode 100644 src/api/providers/codex-websocket/CodexWebSocketConnection.ts delete mode 100644 src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts delete mode 100644 src/api/providers/codex-websocket/CodexWebSocketRequest.ts delete mode 100644 src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts delete mode 100644 src/api/providers/codex-websocket/CodexWebSocketResponse.ts create mode 100644 src/api/providers/codex-websocket/data/local/CodexWebSocketResponseLocalDataSource.ts create mode 100644 src/api/providers/codex-websocket/data/local/__tests__/CodexWebSocketResponseLocalDataSource.spec.ts rename src/api/providers/codex-websocket/{CodexWebSocketSocket.ts => data/remote/CodexWebSocketSocketRemoteDataSource.ts} (94%) rename src/api/providers/codex-websocket/{ => errors}/CodexWebSocketUnavailableError.ts (100%) create mode 100644 src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts create mode 100644 src/api/providers/codex-websocket/managers/CodexWebSocketRequestManager.ts create mode 100644 src/api/providers/codex-websocket/managers/CodexWebSocketResponseManager.ts create mode 100644 src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts create mode 100644 src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts create mode 100644 src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketResponseManager.spec.ts create mode 100644 src/api/providers/codex-websocket/models/CachedCodexResponse.ts create mode 100644 src/api/providers/codex-websocket/models/CodexWebSocketConnectionStateModel.ts rename src/api/providers/codex-websocket/{CodexWebSocketItemSnapshot.ts => models/CodexWebSocketItemSnapshotModel.ts} (79%) create mode 100644 src/api/providers/codex-websocket/models/CodexWebSocketRequestStateModel.ts create mode 100644 src/api/providers/codex-websocket/models/CodexWebSocketResponseStateModel.ts rename src/api/providers/codex-websocket/{ => models}/PreparedCodexRequest.ts (61%) create mode 100644 src/api/providers/codex-websocket/models/protocol.ts rename src/api/providers/codex-websocket/{CodexWebSocketContinuation.ts => repositories/CodexWebSocketContinuationRepository.ts} (67%) create mode 100644 src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts create mode 100644 src/api/providers/codex-websocket/scopes/CodexWebSocketConnectionScope.ts create mode 100644 src/api/providers/codex-websocket/scopes/CodexWebSocketRequestScope.ts create mode 100644 src/api/providers/codex-websocket/scopes/CodexWebSocketTransportScope.ts rename src/api/providers/codex-websocket/{ => scopes}/__tests__/CodexWebSocketScopes.spec.ts (54%) create mode 100644 src/api/providers/codex-websocket/state-holders/CodexWebSocketConnectionStateHolder.ts create mode 100644 src/api/providers/codex-websocket/state-holders/CodexWebSocketRequestStateHolder.ts create mode 100644 src/api/providers/codex-websocket/state-holders/CodexWebSocketResponseStateHolder.ts create mode 100644 src/api/providers/codex-websocket/state-holders/StateHolder.ts create mode 100644 src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts rename src/api/providers/codex-websocket/{ => utils}/protocol.ts (83%) diff --git a/src/api/providers/CodexWebSocketTransport.ts b/src/api/providers/CodexWebSocketTransport.ts index cb4c7fa940..e2dbf95935 100644 --- a/src/api/providers/CodexWebSocketTransport.ts +++ b/src/api/providers/CodexWebSocketTransport.ts @@ -1,20 +1,15 @@ import type WebSocket from "ws" -import { CodexWebSocketConnection } from "./codex-websocket/CodexWebSocketConnection" -import { CodexWebSocketContinuation } from "./codex-websocket/CodexWebSocketContinuation" -import type { PreparedCodexRequest } from "./codex-websocket/PreparedCodexRequest" -import { - asJsonObject, - parseResponseEvent, - type CodexResponseEvent, - type CodexWebSocketOptions, -} from "./codex-websocket/protocol" -import { CodexWebSocketRequestScope } from "./codex-websocket/CodexWebSocketRequestScope" -import { CodexWebSocketResponse } from "./codex-websocket/CodexWebSocketResponse" +import type { CodexWebSocketConnectionManager } from "./codex-websocket/managers/CodexWebSocketConnectionManager" +import type { CodexWebSocketRequestManager } from "./codex-websocket/managers/CodexWebSocketRequestManager" +import type { CodexWebSocketResponseManager } from "./codex-websocket/managers/CodexWebSocketResponseManager" +import type { PreparedCodexRequest } from "./codex-websocket/models/PreparedCodexRequest" +import type { CodexResponseEvent, CodexWebSocketOptions } from "./codex-websocket/models/protocol" +import type { CodexWebSocketContinuationRepository } from "./codex-websocket/repositories/CodexWebSocketContinuationRepository" +import type { CodexWebSocketRequestScope } from "./codex-websocket/scopes/CodexWebSocketRequestScope" +import { asJsonObject, parseResponseEvent } from "./codex-websocket/utils/protocol" -export { CodexWebSocketUnavailableError } from "./codex-websocket/CodexWebSocketUnavailableError" - -const CODEX_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses" +export { CodexWebSocketUnavailableError } from "./codex-websocket/errors/CodexWebSocketUnavailableError" /** Task-local Responses transport. Connection and history management are independent of provider event handling. */ export class CodexWebSocketTransport { @@ -22,16 +17,14 @@ export class CodexWebSocketTransport { private requestScope?: CodexWebSocketRequestScope constructor( - private readonly connection: CodexWebSocketConnection, - private readonly continuation: CodexWebSocketContinuation, + private readonly connection: CodexWebSocketConnectionManager, + private readonly continuation: CodexWebSocketContinuationRepository, + private readonly createRequestScope: ( + options: CodexWebSocketOptions, + onAbort: () => void, + ) => CodexWebSocketRequestScope, ) {} - static create(url = CODEX_WEBSOCKET_URL): CodexWebSocketTransport { - const continuation = new CodexWebSocketContinuation() - const connection = new CodexWebSocketConnection(url, () => continuation.reset()) - return new CodexWebSocketTransport(connection, continuation) - } - resetContinuation(): void { this.continuation.reset() } @@ -45,25 +38,28 @@ export class CodexWebSocketTransport { async *stream(body: unknown, options: CodexWebSocketOptions): AsyncGenerator { if (this.busy) throw new Error("Concurrent requests on a Codex WebSocket are not supported") options.signal.throwIfAborted() - const scope = new CodexWebSocketRequestScope(options, () => { + const scope = this.createRequestScope(options, () => { void this.dispose() }) - let response: CodexWebSocketResponse | undefined + let request: CodexWebSocketRequestManager | undefined + let response: CodexWebSocketResponseManager | undefined this.busy = true this.requestScope = scope try { - await scope.init(this.connection) - const prepared = this.continuation.prepare(body) - response = new CodexWebSocketResponse(this.continuation, prepared) - yield* this.readResponse(prepared, options.headers, scope, response) + await scope.init(this.connection, this.continuation, body) + request = scope.requestManager + response = scope.responseManager + const prepared = response.preparedRequest + yield* this.readResponse(prepared, options.headers, request, response) } catch (error) { - if (scope.signal.aborted) throw scope.signal.reason + if (request?.signal.aborted) throw request.signal.reason + options.signal.throwIfAborted() throw error } finally { await scope.dispose() if (this.requestScope === scope) this.requestScope = undefined this.busy = false - if (!response?.completed || scope.signal.aborted) await this.dispose() + if (!response?.completed || request?.signal.aborted) await this.dispose() else this.connection.release() } } @@ -71,19 +67,19 @@ export class CodexWebSocketTransport { private async *readResponse( prepared: PreparedCodexRequest, headers: Record, - scope: CodexWebSocketRequestScope, - response: CodexWebSocketResponse, + request: CodexWebSocketRequestManager, + response: CodexWebSocketResponseManager, ): AsyncGenerator { - scope.refreshTimeout() - this.send(scope.socket, prepared, headers, prepared.fullContextReason === undefined) - for await (const [data] of scope.events) { - scope.refreshTimeout() + request.refreshTimeout() + this.send(request.socket, prepared, headers, prepared.fullContextReason === undefined) + for await (const [data] of request.events) { + request.refreshTimeout() const event = parseResponseEvent(String(data)) if (response.accept(event) === "retry") { - this.send(scope.socket, prepared, headers, false, "server cache miss") + this.send(request.socket, prepared, headers, false, "server cache miss") continue } - if (response.completed) scope.clearTimeout() + if (response.completed) request.clearTimeout() yield event if (response.completed) return } diff --git a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts index a15efda327..eee762dfff 100644 --- a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts +++ b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts @@ -2,6 +2,7 @@ import { once } from "node:events" import { WebSocketServer, type WebSocket } from "ws" import nock from "nock" import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "../CodexWebSocketTransport" +import { CodexWebSocketTransportScope } from "../codex-websocket/scopes/CodexWebSocketTransportScope" import { collectStream } from "../../../test-utils/stream" type Request = Record @@ -9,6 +10,7 @@ type Request = Record describe("CodexWebSocketTransport", () => { let server: WebSocketServer let transport: CodexWebSocketTransport + let transportScope: CodexWebSocketTransportScope let requests: Request[] let connections: number let reply: (request: Request, socket: WebSocket) => void @@ -40,7 +42,9 @@ describe("CodexWebSocketTransport", () => { await once(server, "listening") const address = server.address() if (typeof address === "string" || address === null) throw new Error("Missing test server address") - transport = CodexWebSocketTransport.create(`ws://127.0.0.1:${address.port}/responses`) + transportScope = new CodexWebSocketTransportScope(`ws://127.0.0.1:${address.port}/responses`) + transportScope.init() + transport = transportScope.transport server.on("connection", (socket) => { connections++ socket.on("message", (data) => { @@ -53,7 +57,7 @@ describe("CodexWebSocketTransport", () => { afterEach(async () => { vi.restoreAllMocks() - await transport?.dispose() + await transportScope?.dispose() nock.disableNetConnect() for (const socket of server.clients) socket.terminate() await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) @@ -351,8 +355,11 @@ describe("CodexWebSocketTransport", () => { socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "Partial" })) const stream = transport.stream(body(), options()) await stream.next() - await transport.dispose() + await transportScope.dispose() + expect(() => transportScope.transport).toThrow("not initialized") await expect(stream.next()).rejects.toThrow("closed before response completed") + transportScope.init() + transport = transportScope.transport reply = (_request, socket) => complete(socket) await collectStream(transport.stream(body(), options())) expect(connections).toBe(2) diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 74c5dc8fe9..5dd4f96ff8 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -12,6 +12,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, SERVICE_TIER_KEY } from "@roo-code/types" import { OpenAiCodexHandler, transformResponsesLiteBody } from "../openai-codex" import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "../CodexWebSocketTransport" +import { CodexWebSocketTransportScope } from "../codex-websocket/scopes/CodexWebSocketTransportScope" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -40,6 +41,16 @@ describe("OpenAiCodexHandler WebSocket transport", () => { vitest.unstubAllGlobals() }) + it("initializes the DI scope on the first request rather than in the provider constructor", async () => { + const init = vitest.spyOn(CodexWebSocketTransportScope.prototype, "init") + vitest.spyOn(CodexWebSocketTransport.prototype, "stream").mockImplementation(() => createCompletedStream()) + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: true }) + expect(init).not.toHaveBeenCalled() + await collectStream(handler.createMessage("System", [])) + await collectStream(handler.createMessage("System", [])) + expect(init).toHaveBeenCalledOnce() + }) + it("routes Lite requests through the transport and returns existing usage chunks", async () => { const stream = vitest .spyOn(CodexWebSocketTransport.prototype, "stream") diff --git a/src/api/providers/codex-websocket/CachedCodexResponse.ts b/src/api/providers/codex-websocket/CachedCodexResponse.ts deleted file mode 100644 index 76188fef6c..0000000000 --- a/src/api/providers/codex-websocket/CachedCodexResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" - -export interface CachedCodexResponse { - id: string - settings: string - input: CodexWebSocketItemSnapshot[] -} diff --git a/src/api/providers/codex-websocket/CodexWebSocketConnection.ts b/src/api/providers/codex-websocket/CodexWebSocketConnection.ts deleted file mode 100644 index 1b608086aa..0000000000 --- a/src/api/providers/codex-websocket/CodexWebSocketConnection.ts +++ /dev/null @@ -1,84 +0,0 @@ -import WebSocket from "ws" - -import { CodexWebSocketConnectionScope } from "./CodexWebSocketConnectionScope" -import { CodexWebSocketUnavailableError } from "./CodexWebSocketUnavailableError" -import { fingerprint, type CodexWebSocketOptions } from "./protocol" - -const MAX_CONNECTION_AGE_MS = 55 * 60_000 -const IDLE_CONNECTION_TIMEOUT_MS = 120_000 -const UPGRADE_RETRY_DELAY_MS = 60_000 - -/** Owns a single authenticated connection and bounds its lifetime between requests. */ -export class CodexWebSocketConnection { - private scope?: CodexWebSocketConnectionScope - private key?: string - private unavailableKey?: string - private unavailableUntil = 0 - private connectedAt = 0 - private idleTimer?: NodeJS.Timeout - - constructor( - private readonly url: string, - private readonly resetContinuation: () => void, - ) {} - - async acquire(options: CodexWebSocketOptions): Promise { - options.signal.throwIfAborted() - clearTimeout(this.idleTimer) - this.idleTimer = undefined - const key = fingerprint(options.headers) - if (this.unavailableKey === key && Date.now() < this.unavailableUntil) { - throw new CodexWebSocketUnavailableError("Codex WebSocket unavailable; using HTTP") - } - if ( - this.key === key && - this.scope?.socket.readyState === WebSocket.OPEN && - Date.now() - this.connectedAt < MAX_CONNECTION_AGE_MS - ) { - return this.scope.socket - } - this.dispose() - const scope = new CodexWebSocketConnectionScope( - this.url, - () => { - if (this.scope === scope) this.resetContinuation() - }, - () => { - if (this.scope === scope) this.dispose() - }, - ) - this.scope = scope - try { - await scope.init(options) - } catch (error) { - if (this.scope === scope) this.dispose() - options.signal.throwIfAborted() - this.unavailableKey = key - this.unavailableUntil = Date.now() + UPGRADE_RETRY_DELAY_MS - console.warn("[Codex WebSocket] Upgrade failed; falling back to HTTP") - throw new CodexWebSocketUnavailableError("Codex WebSocket upgrade failed", { cause: error }) - } - this.key = key - this.unavailableKey = undefined - this.connectedAt = Date.now() - console.info("[Codex WebSocket] Connected") - return scope.socket - } - - release(): void { - if (!this.scope) return - clearTimeout(this.idleTimer) - this.idleTimer = setTimeout(() => this.dispose(), IDLE_CONNECTION_TIMEOUT_MS) - this.idleTimer.unref() - } - - dispose(): void { - clearTimeout(this.idleTimer) - this.idleTimer = undefined - const scope = this.scope - this.scope = undefined - this.key = undefined - this.resetContinuation() - scope?.dispose() - } -} diff --git a/src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts b/src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts deleted file mode 100644 index dc1b8bb06a..0000000000 --- a/src/api/providers/codex-websocket/CodexWebSocketConnectionScope.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type WebSocket from "ws" - -import { CodexWebSocketSocket } from "./CodexWebSocketSocket" -import type { CodexWebSocketOptions } from "./protocol" - -/** Assembles and owns the services for one authenticated socket. */ -export class CodexWebSocketConnectionScope { - private socketService?: CodexWebSocketSocket - - constructor( - private readonly url: string, - private readonly onError: () => void, - private readonly onClose: () => void, - ) {} - - get socket(): WebSocket { - if (!this.socketService) throw new Error("Codex WebSocket connection scope is not initialized") - return this.socketService.socket - } - - async init(options: CodexWebSocketOptions): Promise { - if (this.socketService) throw new Error("Codex WebSocket connection scope is already initialized") - const socketService = new CodexWebSocketSocket(this.url, this.onError, this.onClose) - this.socketService = socketService - try { - await socketService.init(options) - if (this.socketService !== socketService) throw new Error("Codex WebSocket connection scope was disposed") - } catch (error) { - if (this.socketService === socketService) this.dispose() - throw error - } - } - - dispose(): void { - const socketService = this.socketService - this.socketService = undefined - socketService?.dispose() - } -} diff --git a/src/api/providers/codex-websocket/CodexWebSocketRequest.ts b/src/api/providers/codex-websocket/CodexWebSocketRequest.ts deleted file mode 100644 index 5eb39d7084..0000000000 --- a/src/api/providers/codex-websocket/CodexWebSocketRequest.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { on } from "node:events" -import type WebSocket from "ws" - -import type { CodexWebSocketConnection } from "./CodexWebSocketConnection" -import type { CodexWebSocketOptions } from "./protocol" - -/** Owns cancellation, the inactivity timer, and response subscriptions for a single request. */ -export class CodexWebSocketRequest { - private controller?: AbortController - private requestSignal?: AbortSignal - private _socket?: WebSocket - private _events?: AsyncIterableIterator - private timeout?: NodeJS.Timeout - private initialized = false - private initializing = false - - constructor( - private readonly options: CodexWebSocketOptions, - private readonly onAbort: () => void, - ) {} - - get signal(): AbortSignal { - return this.requestSignal ?? this.options.signal - } - - get socket(): WebSocket { - if (!this._socket) throw new Error("Codex WebSocket request scope is not initialized") - return this._socket - } - - get events(): AsyncIterableIterator { - if (!this._events) throw new Error("Codex WebSocket request scope is not initialized") - return this._events - } - - async init(connection: CodexWebSocketConnection): Promise { - if (this.initialized || this.initializing) { - throw new Error("Codex WebSocket request scope is already initialized") - } - this.options.signal.throwIfAborted() - this.initialized = true - this.initializing = true - this.controller = new AbortController() - this.requestSignal = AbortSignal.any([this.options.signal, this.controller.signal]) - this.signal.addEventListener("abort", this.onAbort, { once: true }) - try { - this._socket = await connection.acquire({ ...this.options, signal: this.signal }) - this.signal.throwIfAborted() - // Register before sending so a fast reply cannot be lost. - this._events = on(this._socket, "message", { signal: this.signal, close: ["close"] }) - } catch (error) { - await this.disposeResources() - throw error - } finally { - this.initializing = false - } - } - - refreshTimeout(): void { - if (!this._events) throw new Error("Codex WebSocket request scope is not initialized") - this.clearTimeout() - this.timeout = setTimeout( - () => this.controller?.abort(new Error("Codex WebSocket stream timed out")), - this.options.timeoutMs, - ) - } - - clearTimeout(): void { - clearTimeout(this.timeout) - this.timeout = undefined - } - - async dispose(): Promise { - if (this.initializing) this.controller?.abort(new Error("Codex WebSocket request scope was disposed")) - await this.disposeResources() - } - - private async disposeResources(): Promise { - this.clearTimeout() - this.signal.removeEventListener("abort", this.onAbort) - const events = this._events - this._events = undefined - this._socket = undefined - this.initialized = false - await events?.return?.() - } -} diff --git a/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts b/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts deleted file mode 100644 index 47f5e2b7c7..0000000000 --- a/src/api/providers/codex-websocket/CodexWebSocketRequestScope.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type WebSocket from "ws" - -import type { CodexWebSocketConnection } from "./CodexWebSocketConnection" -import { CodexWebSocketRequest } from "./CodexWebSocketRequest" -import type { CodexWebSocketOptions } from "./protocol" - -/** Assembles and owns request-local services without owning the reusable connection. */ -export class CodexWebSocketRequestScope { - private request?: CodexWebSocketRequest - private requestSignal?: AbortSignal - private initializing = false - - constructor( - private readonly options: CodexWebSocketOptions, - private readonly onAbort: () => void, - ) {} - - get signal(): AbortSignal { - return this.request?.signal ?? this.requestSignal ?? this.options.signal - } - - get socket(): WebSocket { - return this.getRequest().socket - } - - get events(): AsyncIterableIterator { - return this.getRequest().events - } - - async init(connection: CodexWebSocketConnection): Promise { - if (this.request || this.initializing) { - throw new Error("Codex WebSocket request scope is already initialized") - } - const request = new CodexWebSocketRequest(this.options, this.onAbort) - this.request = request - this.initializing = true - try { - await request.init(connection) - if (this.request !== request) throw new Error("Codex WebSocket request scope was disposed") - } catch (error) { - if (this.request === request) await this.dispose() - throw error - } finally { - this.initializing = false - } - } - - refreshTimeout(): void { - this.getRequest().refreshTimeout() - } - - clearTimeout(): void { - this.request?.clearTimeout() - } - - async dispose(): Promise { - const request = this.request - this.request = undefined - if (request) this.requestSignal = request.signal - await request?.dispose() - } - - private getRequest(): CodexWebSocketRequest { - if (this.initializing || !this.request) throw new Error("Codex WebSocket request scope is not initialized") - return this.request - } -} diff --git a/src/api/providers/codex-websocket/CodexWebSocketResponse.ts b/src/api/providers/codex-websocket/CodexWebSocketResponse.ts deleted file mode 100644 index 8e5ae2b97d..0000000000 --- a/src/api/providers/codex-websocket/CodexWebSocketResponse.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CodexWebSocketContinuation } from "./CodexWebSocketContinuation" -import type { PreparedCodexRequest } from "./PreparedCodexRequest" -import { asJsonObject, responseError, type CodexResponseEvent } from "./protocol" - -/** Tracks response completion and the one safe cache-miss recovery, independently of socket IO. */ -export class CodexWebSocketResponse { - private received = false - private recovered = false - private readonly output: unknown[] = [] - private finished = false - - constructor( - private readonly continuation: CodexWebSocketContinuation, - private readonly prepared: PreparedCodexRequest, - ) {} - - get completed(): boolean { - return this.finished - } - - accept(event: CodexResponseEvent): "emit" | "retry" { - if (event.type === "error") return this.handleError(event) - if (event.type === "response.failed" || event.type === "response.incomplete") throw responseError(event) - this.received = true - if (event.type === "response.output_item.done") this.output.push(event.item) - if (event.type === "response.completed" || event.type === "response.done") { - this.continuation.record(this.prepared, asJsonObject(event.response), this.output) - this.finished = true - } - return "emit" - } - - private handleError(event: CodexResponseEvent): "retry" { - const error = asJsonObject(event.error) - const canRecover = - error.code === "previous_response_not_found" && - this.prepared.fullContextReason === undefined && - !this.received && - !this.recovered - if (!canRecover) throw responseError(event) - this.recovered = true - this.continuation.reset() - return "retry" - } -} diff --git a/src/api/providers/codex-websocket/data/local/CodexWebSocketResponseLocalDataSource.ts b/src/api/providers/codex-websocket/data/local/CodexWebSocketResponseLocalDataSource.ts new file mode 100644 index 0000000000..e7801a17a5 --- /dev/null +++ b/src/api/providers/codex-websocket/data/local/CodexWebSocketResponseLocalDataSource.ts @@ -0,0 +1,18 @@ +import type { CachedCodexResponse } from "../../models/CachedCodexResponse" + +/** Task-local, in-memory continuation cache. Stores snapshots only, never response content. */ +export class CodexWebSocketResponseLocalDataSource { + private response?: CachedCodexResponse + + read(): CachedCodexResponse | undefined { + return this.response + } + + write(response: CachedCodexResponse | undefined): void { + this.response = response + } + + clear(): void { + this.response = undefined + } +} diff --git a/src/api/providers/codex-websocket/data/local/__tests__/CodexWebSocketResponseLocalDataSource.spec.ts b/src/api/providers/codex-websocket/data/local/__tests__/CodexWebSocketResponseLocalDataSource.spec.ts new file mode 100644 index 0000000000..3bfdb7e696 --- /dev/null +++ b/src/api/providers/codex-websocket/data/local/__tests__/CodexWebSocketResponseLocalDataSource.spec.ts @@ -0,0 +1,42 @@ +import { CodexWebSocketResponseLocalDataSource } from "../CodexWebSocketResponseLocalDataSource" +import type { CachedCodexResponse } from "../../../models/CachedCodexResponse" + +describe("CodexWebSocketResponseLocalDataSource", () => { + const response = (): CachedCodexResponse => ({ id: "resp_1", settings: "settings-hash", input: [] }) + + it("starts empty and retains the latest response snapshot", () => { + const store = new CodexWebSocketResponseLocalDataSource() + expect(store.read()).toBeUndefined() + const cached = response() + store.write(cached) + expect(store.read()).toBe(cached) + const next = { ...response(), id: "resp_2" } + store.write(next) + expect(store.read()).toBe(next) + }) + + it("clears cached state idempotently", () => { + const store = new CodexWebSocketResponseLocalDataSource() + store.write(response()) + store.clear() + store.clear() + expect(store.read()).toBeUndefined() + }) + + it("replaces a cached response with an absent response", () => { + const store = new CodexWebSocketResponseLocalDataSource() + store.write(response()) + store.write(undefined) + expect(store.read()).toBeUndefined() + }) + + it("isolates state between task-local stores", () => { + const first = new CodexWebSocketResponseLocalDataSource() + const second = new CodexWebSocketResponseLocalDataSource() + first.write(response()) + expect(second.read()).toBeUndefined() + second.write({ ...response(), id: "resp_2" }) + first.clear() + expect(second.read()?.id).toBe("resp_2") + }) +}) diff --git a/src/api/providers/codex-websocket/CodexWebSocketSocket.ts b/src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts similarity index 94% rename from src/api/providers/codex-websocket/CodexWebSocketSocket.ts rename to src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts index 8f94b9ba01..96491e66df 100644 --- a/src/api/providers/codex-websocket/CodexWebSocketSocket.ts +++ b/src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts @@ -1,12 +1,12 @@ import { once } from "node:events" import WebSocket from "ws" -import type { CodexWebSocketOptions } from "./protocol" +import type { CodexWebSocketOptions } from "../../models/protocol" const HANDSHAKE_TIMEOUT_MS = 10_000 /** Owns one socket and its subscriptions, from initialization through terminal close. */ -export class CodexWebSocketSocket { +export class CodexWebSocketSocketRemoteDataSource { private _socket?: WebSocket private disposeSocket?: () => void diff --git a/src/api/providers/codex-websocket/CodexWebSocketUnavailableError.ts b/src/api/providers/codex-websocket/errors/CodexWebSocketUnavailableError.ts similarity index 100% rename from src/api/providers/codex-websocket/CodexWebSocketUnavailableError.ts rename to src/api/providers/codex-websocket/errors/CodexWebSocketUnavailableError.ts diff --git a/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts b/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts new file mode 100644 index 0000000000..26a8f2dee0 --- /dev/null +++ b/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts @@ -0,0 +1,94 @@ +import WebSocket from "ws" + +import { CodexWebSocketConnectionScope } from "../scopes/CodexWebSocketConnectionScope" +import { CodexWebSocketUnavailableError } from "../errors/CodexWebSocketUnavailableError" +import type { CodexWebSocketConnectionStateHolder } from "../state-holders/CodexWebSocketConnectionStateHolder" +import type { CodexWebSocketOptions } from "../models/protocol" +import { fingerprint } from "../utils/protocol" + +const MAX_CONNECTION_AGE_MS = 55 * 60_000 +const IDLE_CONNECTION_TIMEOUT_MS = 120_000 +const UPGRADE_RETRY_DELAY_MS = 60_000 + +/** Owns a single authenticated connection and bounds its lifetime between requests. */ +export class CodexWebSocketConnectionManager { + constructor( + private readonly url: string, + private readonly resetContinuation: () => void, + private readonly stateHolder: CodexWebSocketConnectionStateHolder, + ) {} + + private get state() { + return this.stateHolder.state + } + + async acquire(options: CodexWebSocketOptions): Promise { + options.signal.throwIfAborted() + const key = fingerprint(options.headers) + const state = this.state + if (state.status === "connecting") + throw new Error("Concurrent Codex WebSocket connection attempts are not supported") + if (state.status === "unavailable" && state.key === key && Date.now() < state.retryAt) { + throw new CodexWebSocketUnavailableError("Codex WebSocket unavailable; using HTTP") + } + if ( + (state.status === "active" || state.status === "idle") && + state.key === key && + state.scope.socketRemoteDataSource.socket.readyState === WebSocket.OPEN && + Date.now() - state.connectedAt < MAX_CONNECTION_AGE_MS + ) { + if (state.status === "idle") clearTimeout(state.idleTimer) + this.stateHolder.activate(state) + return state.scope.socketRemoteDataSource.socket + } + this.dispose() + const scope = new CodexWebSocketConnectionScope( + this.url, + () => { + if (this.stateHolder.ownsScope(scope)) this.resetContinuation() + }, + () => { + if (!this.stateHolder.ownsScope(scope)) return + // A connecting attempt publishes its own outcome after init settles. + if (this.state.status === "connecting") scope.dispose() + else this.dispose() + }, + ) + const attempt = this.stateHolder.beginConnection(key, scope) + try { + await scope.init(options) + options.signal.throwIfAborted() + if (!this.stateHolder.isCurrent(attempt)) throw new Error("Codex WebSocket connection attempt was disposed") + } catch (error) { + const current = this.stateHolder.isCurrent(attempt) + if (current) this.dispose() + options.signal.throwIfAborted() + if (!current) throw error + this.stateHolder.markUnavailable(key, Date.now() + UPGRADE_RETRY_DELAY_MS) + console.warn("[Codex WebSocket] Upgrade failed; falling back to HTTP") + throw new CodexWebSocketUnavailableError("Codex WebSocket upgrade failed", { cause: error }) + } + this.stateHolder.connect(attempt, Date.now()) + console.info("[Codex WebSocket] Connected") + return scope.socketRemoteDataSource.socket + } + + release(): void { + const state = this.state + if (state.status !== "active" && state.status !== "idle") return + if (state.status === "idle") clearTimeout(state.idleTimer) + const idleTimer = setTimeout(() => { + if (this.stateHolder.isCurrent(idleState)) this.dispose() + }, IDLE_CONNECTION_TIMEOUT_MS) + const idleState = this.stateHolder.idle(state, idleTimer) + idleTimer.unref() + } + + dispose(): void { + // Request cleanup must not erase the safe-upgrade-failure cooldown. + const state = this.stateHolder.disconnect() + if (state.status === "idle") clearTimeout(state.idleTimer) + this.resetContinuation() + if ("scope" in state) state.scope.dispose() + } +} diff --git a/src/api/providers/codex-websocket/managers/CodexWebSocketRequestManager.ts b/src/api/providers/codex-websocket/managers/CodexWebSocketRequestManager.ts new file mode 100644 index 0000000000..f854109bbd --- /dev/null +++ b/src/api/providers/codex-websocket/managers/CodexWebSocketRequestManager.ts @@ -0,0 +1,96 @@ +import { on } from "node:events" +import type WebSocket from "ws" + +import type { CodexWebSocketConnectionManager } from "./CodexWebSocketConnectionManager" +import type { CodexWebSocketRequestStateModel } from "../models/CodexWebSocketRequestStateModel" +import type { CodexWebSocketRequestStateHolder } from "../state-holders/CodexWebSocketRequestStateHolder" +import type { CodexWebSocketOptions } from "../models/protocol" + +type RequestState = CodexWebSocketRequestStateModel, NodeJS.Timeout> + +/** Owns cancellation, the inactivity timer, and response subscriptions for a single request. */ +export class CodexWebSocketRequestManager { + constructor( + private readonly options: CodexWebSocketOptions, + private readonly onAbort: () => void, + private readonly stateHolder: CodexWebSocketRequestStateHolder, + ) {} + + private get state(): RequestState { + return this.stateHolder.state + } + + get signal(): AbortSignal { + return "signal" in this.state ? this.state.signal : this.options.signal + } + + get socket(): WebSocket { + if (this.state.status !== "active") throw new Error("Codex WebSocket request scope is not initialized") + return this.state.socket + } + + get events(): AsyncIterableIterator { + if (this.state.status !== "active") throw new Error("Codex WebSocket request scope is not initialized") + return this.state.events + } + + async init(connection: CodexWebSocketConnectionManager): Promise { + if (this.state.status === "active" || this.state.status === "initializing") { + throw new Error("Codex WebSocket request scope is already initialized") + } + this.options.signal.throwIfAborted() + const controller = new AbortController() + const signal = AbortSignal.any([this.options.signal, controller.signal]) + const initializing = this.stateHolder.beginInitialization(controller, signal) + initializing.signal.addEventListener("abort", this.onAbort, { once: true }) + try { + const socket = await connection.acquire({ ...this.options, signal: initializing.signal }) + initializing.signal.throwIfAborted() + if (!this.stateHolder.isCurrent(initializing)) throw new Error("Codex WebSocket request scope was disposed") + // Register before sending so a fast reply cannot be lost. + const events = on(socket, "message", { signal: initializing.signal, close: ["close"] }) + this.stateHolder.activate(initializing, socket, events) + } catch (error) { + if (this.stateHolder.isCurrent(initializing)) { + this.stateHolder.dispose() + await this.disposeResources(initializing) + } + initializing.signal.throwIfAborted() + throw error + } + } + + refreshTimeout(): void { + const state = this.state + if (state.status !== "active") throw new Error("Codex WebSocket request scope is not initialized") + clearTimeout(state.timeout) + const timeout = setTimeout(() => { + if (this.stateHolder.isCurrent(timedState)) + state.controller.abort(new Error("Codex WebSocket stream timed out")) + }, this.options.timeoutMs) + const timedState = this.stateHolder.setDeadline(state, timeout) + } + + clearTimeout(): void { + const state = this.state + if (state.status !== "active") return + clearTimeout(state.timeout) + this.stateHolder.clearDeadline(state) + } + + async dispose(): Promise { + const state = this.stateHolder.dispose() + if (state.status !== "initializing" && state.status !== "active") return + if (state.status === "initializing") + state.controller.abort(new Error("Codex WebSocket request scope was disposed")) + await this.disposeResources(state) + } + + private async disposeResources(state: Extract): Promise { + state.signal.removeEventListener("abort", this.onAbort) + if (state.status === "active") { + clearTimeout(state.timeout) + await state.events.return?.() + } + } +} diff --git a/src/api/providers/codex-websocket/managers/CodexWebSocketResponseManager.ts b/src/api/providers/codex-websocket/managers/CodexWebSocketResponseManager.ts new file mode 100644 index 0000000000..a7c5b8b880 --- /dev/null +++ b/src/api/providers/codex-websocket/managers/CodexWebSocketResponseManager.ts @@ -0,0 +1,64 @@ +import { CodexWebSocketContinuationRepository } from "../repositories/CodexWebSocketContinuationRepository" +import type { PreparedCodexRequest } from "../models/PreparedCodexRequest" +import type { CodexWebSocketResponseStateHolder } from "../state-holders/CodexWebSocketResponseStateHolder" +import type { CodexResponseEvent } from "../models/protocol" +import { asJsonObject, responseError } from "../utils/protocol" + +/** Tracks response completion and the one safe cache-miss recovery, independently of socket IO. */ +export class CodexWebSocketResponseManager { + constructor( + private readonly continuation: CodexWebSocketContinuationRepository, + private readonly stateHolder: CodexWebSocketResponseStateHolder, + ) {} + + init(body: unknown): void { + if (this.stateHolder.state.status !== "uninitialized") { + throw new Error("Codex WebSocket response manager is already initialized") + } + this.stateHolder.initialize(this.continuation.prepare(body)) + } + + get preparedRequest(): PreparedCodexRequest { + return this.stateHolder.preparedRequest + } + + get completed(): boolean { + return this.stateHolder.state.status === "completed" + } + + accept(event: CodexResponseEvent): "emit" | "retry" { + this.stateHolder.assertAcceptingEvents() + switch (event.type) { + case "error": + return this.handleError(event) + case "response.failed": + case "response.incomplete": + throw responseError(event) + case "response.output_item.done": + this.stateHolder.appendOutputItem(event.item) + break + case "response.completed": + case "response.done": + this.completeResponse(event.response) + break + default: + this.stateHolder.startStreaming() + } + return "emit" + } + + private completeResponse(response: unknown): void { + const prepared = this.preparedRequest + this.continuation.record(prepared, asJsonObject(response), [...this.stateHolder.streamedOutput]) + this.stateHolder.complete() + } + + private handleError(event: CodexResponseEvent): "retry" { + const error = asJsonObject(event.error) + const canRecover = error.code === "previous_response_not_found" && this.stateHolder.canRecoverCacheMiss + if (!canRecover) throw responseError(event) + this.stateHolder.beginRecovery() + this.continuation.reset() + return "retry" + } +} diff --git a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts new file mode 100644 index 0000000000..494c76b7d0 --- /dev/null +++ b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts @@ -0,0 +1,180 @@ +import WebSocket from "ws" + +import { CodexWebSocketConnectionManager } from "../CodexWebSocketConnectionManager" +import { CodexWebSocketUnavailableError } from "../../errors/CodexWebSocketUnavailableError" +import { CodexWebSocketConnectionStateHolder } from "../../state-holders/CodexWebSocketConnectionStateHolder" +import type { CodexWebSocketOptions } from "../../models/protocol" + +interface TestScope { + socketRemoteDataSource: { socket: { readyState: number } } + init: ReturnType Promise>> + dispose: ReturnType void>> + open(): void + fail(error: Error): void + onError(): void + onClose(): void +} + +const { scopes } = vi.hoisted(() => ({ scopes: [] as TestScope[] })) + +vi.mock("../../scopes/CodexWebSocketConnectionScope", () => { + class TestConnectionScope implements TestScope { + readonly socketRemoteDataSource = { socket: { readyState: WebSocket.CONNECTING as number } } + private resolveInit?: () => void + private rejectInit?: (error: Error) => void + + constructor( + _url: string, + readonly onError: () => void, + readonly onClose: () => void, + ) { + scopes.push(this) + } + + init = vi.fn( + (_options: CodexWebSocketOptions) => + new Promise((resolve, reject) => { + this.resolveInit = resolve + this.rejectInit = reject + }), + ) + dispose = vi.fn(() => { + this.socketRemoteDataSource.socket.readyState = WebSocket.CLOSED + }) + open(): void { + this.socketRemoteDataSource.socket.readyState = WebSocket.OPEN + this.resolveInit?.() + } + fail(error: Error): void { + this.rejectInit?.(error) + } + } + return { CodexWebSocketConnectionScope: TestConnectionScope } +}) + +describe("CodexWebSocketConnectionManager state transitions", () => { + let manager: CodexWebSocketConnectionManager + let reset = vi.fn<() => void>() + const options = (): CodexWebSocketOptions => ({ + headers: { Authorization: "Bearer test-token" }, + signal: new AbortController().signal, + timeoutMs: 2_000, + }) + const lastScope = (): TestScope => { + const scope = scopes.at(-1) + if (!scope) throw new Error("No test connection scope was created") + return scope + } + + beforeEach(() => { + vi.useFakeTimers() + scopes.length = 0 + reset = vi.fn() + manager = new CodexWebSocketConnectionManager( + "ws://test/responses", + reset, + new CodexWebSocketConnectionStateHolder(), + ) + }) + + afterEach(() => { + manager.dispose() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("moves from connecting to active to idle and back without retaining the idle timer", async () => { + const acquired = manager.acquire(options()) + const scope = lastScope() + manager.release() + expect(vi.getTimerCount()).toBe(0) + scope.open() + await acquired + manager.release() + expect(vi.getTimerCount()).toBe(1) + await expect(manager.acquire(options())).resolves.toBe(scope.socketRemoteDataSource.socket) + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(120_000) + expect(scope.dispose).not.toHaveBeenCalled() + expect(scopes).toHaveLength(1) + }) + + it("rejects overlapping upgrades without disturbing the current attempt", async () => { + const acquired = manager.acquire(options()) + const scope = lastScope() + await expect(manager.acquire(options())).rejects.toThrow("Concurrent") + expect(scopes).toHaveLength(1) + expect(scope.dispose).not.toHaveBeenCalled() + scope.open() + await expect(acquired).resolves.toBe(scope.socketRemoteDataSource.socket) + }) + + it("preserves upgrade cooldown through cleanup without retaining a socket or timer", async () => { + const failed = expect(manager.acquire(options())).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) + const scope = lastScope() + scope.fail(new Error("Upgrade rejected")) + await failed + manager.dispose() + manager.release() + await expect(manager.acquire(options())).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) + expect(scope.dispose).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + expect(scopes).toHaveLength(1) + await vi.advanceTimersByTimeAsync(60_000) + const retried = manager.acquire(options()) + lastScope().open() + await retried + expect(scopes).toHaveLength(2) + }) + + it.each(["success", "failure"])( + "ignores late upgrade %s after disposal and replacement", + async (outcome: string) => { + const first = manager.acquire(options()) + const rejected = expect(first).rejects.toThrow() + const staleScope = lastScope() + manager.dispose() + const second = manager.acquire(options()) + const currentScope = lastScope() + currentScope.open() + await second + if (outcome === "success") staleScope.open() + else staleScope.fail(new Error("Late upgrade failure")) + await rejected + const resets = reset.mock.calls.length + staleScope.onError() + staleScope.onClose() + expect(reset).toHaveBeenCalledTimes(resets) + await expect(manager.acquire(options())).resolves.toBe(currentScope.socketRemoteDataSource.socket) + expect(currentScope.dispose).not.toHaveBeenCalled() + expect(scopes).toHaveLength(2) + }, + ) + + it("does not turn disposal of an in-flight upgrade into an HTTP fallback cooldown", async () => { + const failed = expect(manager.acquire(options())).rejects.not.toBeInstanceOf(CodexWebSocketUnavailableError) + const staleScope = lastScope() + manager.dispose() + staleScope.fail(new Error("Disposed upgrade")) + await failed + const next = manager.acquire(options()) + lastScope().open() + await next + expect(scopes).toHaveLength(2) + }) + + it("ignores a stale idle-timer callback after reacquiring the connection", async () => { + const acquired = manager.acquire(options()) + const scope = lastScope() + scope.open() + await acquired + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + manager.release() + const callback = setTimeoutSpy.mock.calls.at(-1)?.[0] + if (typeof callback !== "function") throw new Error("No idle timeout callback was registered") + await manager.acquire(options()) + callback() + expect(scope.dispose).not.toHaveBeenCalled() + await expect(manager.acquire(options())).resolves.toBe(scope.socketRemoteDataSource.socket) + }) +}) diff --git a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts new file mode 100644 index 0000000000..d6c6cf9a23 --- /dev/null +++ b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts @@ -0,0 +1,122 @@ +import WebSocket from "ws" + +import { CodexWebSocketConnectionManager } from "../CodexWebSocketConnectionManager" +import { CodexWebSocketRequestManager } from "../CodexWebSocketRequestManager" +import { CodexWebSocketUnavailableError } from "../../errors/CodexWebSocketUnavailableError" +import { CodexWebSocketConnectionStateHolder } from "../../state-holders/CodexWebSocketConnectionStateHolder" +import { CodexWebSocketRequestStateHolder } from "../../state-holders/CodexWebSocketRequestStateHolder" + +vi.mock("ws", async () => { + const { EventEmitter } = await import("node:events") + return { + default: class TestSocket extends EventEmitter { + constructor(_url: string) { + super() + } + }, + } +}) + +describe("CodexWebSocketRequestManager state transitions", () => { + let manager: CodexWebSocketRequestManager + let connection: CodexWebSocketConnectionManager + let socket: WebSocket + let onAbort = vi.fn<() => void>() + + beforeEach(() => { + vi.useFakeTimers() + onAbort = vi.fn() + socket = new WebSocket("ws://test/responses") + connection = new CodexWebSocketConnectionManager( + "ws://test/responses", + vi.fn(), + new CodexWebSocketConnectionStateHolder(), + ) + vi.spyOn(connection, "acquire").mockResolvedValue(socket) + manager = new CodexWebSocketRequestManager( + { headers: {}, signal: new AbortController().signal, timeoutMs: 2_000 }, + onAbort, + new CodexWebSocketRequestStateHolder(), + ) + }) + + afterEach(async () => { + await manager.dispose() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it("makes socket, subscriptions, and deadline unavailable together after disposal", async () => { + await manager.init(connection) + manager.refreshTimeout() + expect(manager.socket).toBe(socket) + expect(socket.listenerCount("message")).toBe(1) + expect(vi.getTimerCount()).toBe(1) + await manager.dispose() + await manager.dispose() + expect(() => manager.socket).toThrow("not initialized") + expect(() => manager.events).toThrow("not initialized") + expect(() => manager.refreshTimeout()).toThrow("not initialized") + expect(socket.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) + + it("does not convert a failed upgrade into cancellation during cleanup", async () => { + const error = new CodexWebSocketUnavailableError("Upgrade rejected") + vi.mocked(connection.acquire).mockRejectedValueOnce(error) + await expect(manager.init(connection)).rejects.toBe(error) + expect(manager.signal.aborted).toBe(false) + expect(onAbort).not.toHaveBeenCalled() + await manager.init(connection) + expect(manager.socket).toBe(socket) + }) + + it("ignores stale request initialization after disposal and reinitialization", async () => { + let resolveFirst: ((value: WebSocket) => void) | undefined + vi.mocked(connection.acquire).mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)), + ) + const first = expect(manager.init(connection)).rejects.toThrow("scope was disposed") + await manager.dispose() + await manager.init(connection) + const currentSignal = manager.signal + const staleSocket = new WebSocket("ws://stale/responses") + if (!resolveFirst) throw new Error("First acquisition was not started") + resolveFirst(staleSocket) + await first + expect(manager.socket).toBe(socket) + expect(manager.signal).toBe(currentSignal) + expect(currentSignal.aborted).toBe(false) + expect(socket.listenerCount("message")).toBe(1) + expect(staleSocket.listenerCount("message")).toBe(0) + }) + + it.each(["refresh", "clear", "dispose"])( + "ignores a stale deadline callback after %s", + async (operation: string) => { + await manager.init(connection) + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout") + manager.refreshTimeout() + const callback = setTimeoutSpy.mock.calls.at(-1)?.[0] + if (typeof callback !== "function") throw new Error("No deadline callback was registered") + if (operation === "refresh") manager.refreshTimeout() + else if (operation === "clear") manager.clearTimeout() + else await manager.dispose() + callback() + expect(manager.signal.aborted).toBe(false) + expect(onAbort).not.toHaveBeenCalled() + }, + ) + + it("preserves timeout cancellation as the outcome of a disposed request", async () => { + await manager.init(connection) + manager.refreshTimeout() + await vi.advanceTimersByTimeAsync(2_000) + expect(onAbort).toHaveBeenCalledOnce() + const signal = manager.signal + expect(signal.reason).toEqual(new Error("Codex WebSocket stream timed out")) + await manager.dispose() + expect(manager.signal).toBe(signal) + expect(socket.eventNames()).toEqual([]) + }) +}) diff --git a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketResponseManager.spec.ts b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketResponseManager.spec.ts new file mode 100644 index 0000000000..e37d1f9820 --- /dev/null +++ b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketResponseManager.spec.ts @@ -0,0 +1,81 @@ +import { CodexWebSocketResponseManager } from "../CodexWebSocketResponseManager" +import { CodexWebSocketResponseLocalDataSource } from "../../data/local/CodexWebSocketResponseLocalDataSource" +import { CodexWebSocketContinuationRepository } from "../../repositories/CodexWebSocketContinuationRepository" +import { CodexWebSocketResponseStateHolder } from "../../state-holders/CodexWebSocketResponseStateHolder" +import type { CodexResponseEvent } from "../../models/protocol" + +describe("CodexWebSocketResponseManager state transitions", () => { + const body = { model: "test-model", input: [{ role: "user", content: "Hello" }] } + const cacheMiss: CodexResponseEvent = { type: "error", error: { code: "previous_response_not_found" } } + let repository: CodexWebSocketContinuationRepository + let manager: CodexWebSocketResponseManager + + beforeEach(() => { + repository = new CodexWebSocketContinuationRepository(new CodexWebSocketResponseLocalDataSource()) + manager = new CodexWebSocketResponseManager(repository, new CodexWebSocketResponseStateHolder()) + }) + + it("requires explicit initialization and rejects repeated initialization", () => { + const prepare = vi.spyOn(repository, "prepare") + expect(prepare).not.toHaveBeenCalled() + expect(() => manager.preparedRequest).toThrow("not initialized") + expect(() => manager.accept({ type: "response.created" })).toThrow("not initialized") + manager.init(body) + expect(prepare).toHaveBeenCalledOnce() + expect(manager.completed).toBe(false) + expect(() => manager.init(body)).toThrow("already initialized") + }) + + it.each(["response.completed", "response.done"])("records streamed items in order on %s", (type: string) => { + manager.init(body) + const prepared = manager.preparedRequest + const record = vi.spyOn(repository, "record") + const first = { role: "assistant", content: "First" } + const second = { role: "assistant", content: "Second" } + manager.accept({ type: "response.output_item.done", item: first }) + manager.accept({ type: "response.output_text.delta", delta: "Text" }) + manager.accept({ type: "response.output_item.done", item: second }) + expect(manager.completed).toBe(false) + const response = { id: "resp_1" } + expect(manager.accept({ type, response })).toBe("emit") + expect(record).toHaveBeenCalledWith(prepared, response, [first, second]) + expect(manager.completed).toBe(true) + expect(() => manager.accept({ type: "response.created" })).toThrow("already completed") + expect(record).toHaveBeenCalledOnce() + }) + + it("completes an empty response without entering streaming", () => { + manager.init(body) + const record = vi.spyOn(repository, "record") + manager.accept({ type: "response.completed", response: { id: "resp_1", output: [] } }) + expect(record).toHaveBeenCalledWith(manager.preparedRequest, { id: "resp_1", output: [] }, []) + expect(manager.completed).toBe(true) + }) + + it("allows cache-miss recovery once while awaiting the first event", () => { + repository.record(repository.prepare(body), { id: "resp_cached", output: [] }, []) + manager.init(body) + const reset = vi.spyOn(repository, "reset") + expect(manager.accept(cacheMiss)).toBe("retry") + expect(reset).toHaveBeenCalledOnce() + expect(() => manager.accept(cacheMiss)).toThrow("previous_response_not_found") + expect(reset).toHaveBeenCalledOnce() + }) + + it("rejects cache-miss recovery after streaming starts", () => { + repository.record(repository.prepare(body), { id: "resp_cached", output: [] }, []) + manager.init(body) + manager.accept({ type: "response.created" }) + const reset = vi.spyOn(repository, "reset") + expect(() => manager.accept(cacheMiss)).toThrow("previous_response_not_found") + expect(reset).not.toHaveBeenCalled() + }) + + it.each(["response.failed", "response.incomplete"])("does not record completion for %s", (type: string) => { + manager.init(body) + const record = vi.spyOn(repository, "record") + expect(() => manager.accept({ type, response: { error: { message: "Failed" } } })).toThrow("Failed") + expect(record).not.toHaveBeenCalled() + expect(manager.completed).toBe(false) + }) +}) diff --git a/src/api/providers/codex-websocket/models/CachedCodexResponse.ts b/src/api/providers/codex-websocket/models/CachedCodexResponse.ts new file mode 100644 index 0000000000..268090c9c5 --- /dev/null +++ b/src/api/providers/codex-websocket/models/CachedCodexResponse.ts @@ -0,0 +1,7 @@ +import type { CodexWebSocketItemSnapshotModel } from "./CodexWebSocketItemSnapshotModel" + +export interface CachedCodexResponse { + id: string + settings: string + input: CodexWebSocketItemSnapshotModel[] +} diff --git a/src/api/providers/codex-websocket/models/CodexWebSocketConnectionStateModel.ts b/src/api/providers/codex-websocket/models/CodexWebSocketConnectionStateModel.ts new file mode 100644 index 0000000000..89a656f418 --- /dev/null +++ b/src/api/providers/codex-websocket/models/CodexWebSocketConnectionStateModel.ts @@ -0,0 +1,18 @@ +/** Valid runtime states. Resource types are injected so the model does not depend on scopes or IO. */ +export type CodexWebSocketConnectionStateModel = + | { readonly status: "disconnected" } + | { readonly status: "connecting"; readonly key: string; readonly scope: TScope } + | { + readonly status: "active" + readonly key: string + readonly scope: TScope + readonly connectedAt: number + } + | { + readonly status: "idle" + readonly key: string + readonly scope: TScope + readonly connectedAt: number + readonly idleTimer: TTimer + } + | { readonly status: "unavailable"; readonly key: string; readonly retryAt: number } diff --git a/src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts b/src/api/providers/codex-websocket/models/CodexWebSocketItemSnapshotModel.ts similarity index 79% rename from src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts rename to src/api/providers/codex-websocket/models/CodexWebSocketItemSnapshotModel.ts index 7e0b1dd043..c2f06e9e46 100644 --- a/src/api/providers/codex-websocket/CodexWebSocketItemSnapshot.ts +++ b/src/api/providers/codex-websocket/models/CodexWebSocketItemSnapshotModel.ts @@ -1,23 +1,24 @@ -import { fingerprint, asJsonObject, type JsonObject } from "./protocol" +import type { JsonObject } from "./protocol" +import { fingerprint, asJsonObject } from "../utils/protocol" /** Retain hashes only; diagnostic logs must never expose messages, arguments or encrypted reasoning. */ -export class CodexWebSocketItemSnapshot { +export class CodexWebSocketItemSnapshotModel { private constructor( readonly hash: string, readonly type: string, private readonly fields: Record, ) {} - static create(value: unknown): CodexWebSocketItemSnapshot { - const item = CodexWebSocketItemSnapshot.normalize(value) - return new CodexWebSocketItemSnapshot( + static create(value: unknown): CodexWebSocketItemSnapshotModel { + const item = CodexWebSocketItemSnapshotModel.normalize(value) + return new CodexWebSocketItemSnapshotModel( fingerprint(item), String(item.type ?? "message"), Object.fromEntries(Object.entries(item).map(([key, field]) => [key, fingerprint(field)])), ) } - changedFields(other: CodexWebSocketItemSnapshot): string[] { + changedFields(other: CodexWebSocketItemSnapshotModel): string[] { return Object.keys({ ...this.fields, ...other.fields }).filter((key) => this.fields[key] !== other.fields[key]) } diff --git a/src/api/providers/codex-websocket/models/CodexWebSocketRequestStateModel.ts b/src/api/providers/codex-websocket/models/CodexWebSocketRequestStateModel.ts new file mode 100644 index 0000000000..b96666c021 --- /dev/null +++ b/src/api/providers/codex-websocket/models/CodexWebSocketRequestStateModel.ts @@ -0,0 +1,13 @@ +/** Request resources exist together; disposed requests retain only their cancellation outcome. */ +export type CodexWebSocketRequestStateModel = + | { readonly status: "uninitialized" } + | { readonly status: "initializing"; readonly controller: AbortController; readonly signal: AbortSignal } + | { + readonly status: "active" + readonly controller: AbortController + readonly signal: AbortSignal + readonly socket: TSocket + readonly events: TEvents + readonly timeout?: TTimer + } + | { readonly status: "disposed"; readonly signal: AbortSignal } diff --git a/src/api/providers/codex-websocket/models/CodexWebSocketResponseStateModel.ts b/src/api/providers/codex-websocket/models/CodexWebSocketResponseStateModel.ts new file mode 100644 index 0000000000..ac2b1d8af9 --- /dev/null +++ b/src/api/providers/codex-websocket/models/CodexWebSocketResponseStateModel.ts @@ -0,0 +1,9 @@ +import type { PreparedCodexRequest } from "./PreparedCodexRequest" + +/** Cache-miss recovery is possible only while awaiting the first response event. */ +export type CodexWebSocketResponseStateModel = + | { readonly status: "uninitialized" } + | { readonly status: "awaiting"; readonly prepared: PreparedCodexRequest } + | { readonly status: "recovering"; readonly prepared: PreparedCodexRequest } + | { readonly status: "streaming"; readonly prepared: PreparedCodexRequest; readonly output: readonly unknown[] } + | { readonly status: "completed"; readonly prepared: PreparedCodexRequest } diff --git a/src/api/providers/codex-websocket/PreparedCodexRequest.ts b/src/api/providers/codex-websocket/models/PreparedCodexRequest.ts similarity index 61% rename from src/api/providers/codex-websocket/PreparedCodexRequest.ts rename to src/api/providers/codex-websocket/models/PreparedCodexRequest.ts index 09c7822a90..09058e2dfd 100644 --- a/src/api/providers/codex-websocket/PreparedCodexRequest.ts +++ b/src/api/providers/codex-websocket/models/PreparedCodexRequest.ts @@ -1,10 +1,10 @@ -import type { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" +import type { CodexWebSocketItemSnapshotModel } from "./CodexWebSocketItemSnapshotModel" import type { JsonObject } from "./protocol" export interface PreparedCodexRequest { request: JsonObject input: unknown[] - snapshots: CodexWebSocketItemSnapshot[] + snapshots: CodexWebSocketItemSnapshotModel[] settings: string previousResponseId?: string offset: number diff --git a/src/api/providers/codex-websocket/models/protocol.ts b/src/api/providers/codex-websocket/models/protocol.ts new file mode 100644 index 0000000000..2bd8bc58b5 --- /dev/null +++ b/src/api/providers/codex-websocket/models/protocol.ts @@ -0,0 +1,8 @@ +export type JsonObject = Record +export type CodexResponseEvent = JsonObject & { type: string } + +export interface CodexWebSocketOptions { + headers: Record + signal: AbortSignal + timeoutMs: number +} diff --git a/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts b/src/api/providers/codex-websocket/repositories/CodexWebSocketContinuationRepository.ts similarity index 67% rename from src/api/providers/codex-websocket/CodexWebSocketContinuation.ts rename to src/api/providers/codex-websocket/repositories/CodexWebSocketContinuationRepository.ts index 583929f542..769a2740ed 100644 --- a/src/api/providers/codex-websocket/CodexWebSocketContinuation.ts +++ b/src/api/providers/codex-websocket/repositories/CodexWebSocketContinuationRepository.ts @@ -1,14 +1,15 @@ -import type { CachedCodexResponse } from "./CachedCodexResponse" -import { CodexWebSocketItemSnapshot } from "./CodexWebSocketItemSnapshot" -import type { PreparedCodexRequest } from "./PreparedCodexRequest" -import { fingerprint, asJsonObject, type JsonObject } from "./protocol" +import type { CodexWebSocketResponseLocalDataSource } from "../data/local/CodexWebSocketResponseLocalDataSource" +import { CodexWebSocketItemSnapshotModel } from "../models/CodexWebSocketItemSnapshotModel" +import type { PreparedCodexRequest } from "../models/PreparedCodexRequest" +import type { JsonObject } from "../models/protocol" +import { fingerprint, asJsonObject } from "../utils/protocol" /** Compares the server output with the history Zoo can reconstruct, retaining only hashes. */ -export class CodexWebSocketContinuation { - private cached?: CachedCodexResponse +export class CodexWebSocketContinuationRepository { + constructor(private readonly store: CodexWebSocketResponseLocalDataSource) {} reset(): void { - this.cached = undefined + this.store.clear() } prepare(body: unknown): PreparedCodexRequest { @@ -16,14 +17,15 @@ export class CodexWebSocketContinuation { if (!Array.isArray(request.input)) throw new Error("Codex WebSocket input must be an array") const { input: _input, ...settings } = request const settingsKey = fingerprint(settings) - const snapshots = request.input.map((item) => CodexWebSocketItemSnapshot.create(item)) + const snapshots = request.input.map((item) => CodexWebSocketItemSnapshotModel.create(item)) + const cached = this.store.read() return { request, input: request.input, snapshots, settings: settingsKey, - previousResponseId: this.cached?.id, - offset: this.cached?.input.length ?? 0, + previousResponseId: cached?.id, + offset: cached?.input.length ?? 0, fullContextReason: this.getFullContextReason(settingsKey, snapshots), } } @@ -36,21 +38,22 @@ export class CodexWebSocketContinuation { const item = asJsonObject(value) return item.type !== "reasoning" || Boolean(item.encrypted_content) }) - this.cached = + this.store.write( typeof response.id === "string" ? { id: response.id, settings: prepared.settings, input: [ ...prepared.snapshots, - ...replayable.map((item) => CodexWebSocketItemSnapshot.create(item)), + ...replayable.map((item) => CodexWebSocketItemSnapshotModel.create(item)), ], } - : undefined + : undefined, + ) } - private getFullContextReason(settings: string, input: CodexWebSocketItemSnapshot[]): string | undefined { - const previous = this.cached + private getFullContextReason(settings: string, input: CodexWebSocketItemSnapshotModel[]): string | undefined { + const previous = this.store.read() if (!previous) return "no cached response" if (previous.settings !== settings) return "request settings changed" if (previous.input.length > input.length) return "history shortened" diff --git a/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts b/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts new file mode 100644 index 0000000000..59fdca6b45 --- /dev/null +++ b/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts @@ -0,0 +1,82 @@ +import { CodexWebSocketContinuationRepository } from "../CodexWebSocketContinuationRepository" +import { CodexWebSocketResponseLocalDataSource } from "../../data/local/CodexWebSocketResponseLocalDataSource" + +describe("CodexWebSocketContinuationRepository", () => { + const body = (input: unknown[] = [{ role: "user", content: "private prompt" }]) => ({ + model: "test-model", + stream: true, + input, + }) + let store: CodexWebSocketResponseLocalDataSource + let repository: CodexWebSocketContinuationRepository + + beforeEach(() => { + store = new CodexWebSocketResponseLocalDataSource() + repository = new CodexWebSocketContinuationRepository(store) + }) + + it("only receives dependencies in its constructor", () => { + const read = vi.spyOn(store, "read") + const write = vi.spyOn(store, "write") + const clear = vi.spyOn(store, "clear") + new CodexWebSocketContinuationRepository(store) + expect(read).not.toHaveBeenCalled() + expect(write).not.toHaveBeenCalled() + expect(clear).not.toHaveBeenCalled() + }) + + it("uses the injected store to continue from a previously recorded response", () => { + const prepared = repository.prepare(body()) + expect(prepared.fullContextReason).toBe("no cached response") + repository.record(prepared, { id: "resp_1", output: [] }, []) + const nextInput = [...body().input, { role: "user", content: "Next" }] + const nextRepository = new CodexWebSocketContinuationRepository(store) + expect(nextRepository.prepare(body(nextInput))).toMatchObject({ + previousResponseId: "resp_1", + offset: 1, + fullContextReason: undefined, + }) + }) + + it("stores only hashes and replayable reasoning snapshots", () => { + const reasoning = { type: "reasoning", id: "rs_1", encrypted_content: "private encrypted reasoning" } + const message = { role: "assistant", content: [{ type: "output_text", text: "private answer" }] } + const output = [ + { type: "reasoning", summary: [{ type: "summary_text", text: "private unencrypted reasoning" }] }, + reasoning, + message, + ] + repository.record(repository.prepare(body()), { id: "resp_1", output }, []) + const cached = store.read() + expect(cached?.input).toHaveLength(3) + expect(cached?.input.map((item) => item.type)).toEqual(["message", "reasoning", "message"]) + expect(JSON.stringify(cached)).not.toContain("private") + expect(repository.prepare(body([...body().input, reasoning, message]))).toMatchObject({ + previousResponseId: "resp_1", + offset: 3, + fullContextReason: undefined, + }) + }) + + it("uses streamed output when the completed response omits output", () => { + const output = [{ role: "assistant", content: "Answer" }] + repository.record(repository.prepare(body()), { id: "resp_1" }, output) + expect(store.read()?.input).toHaveLength(2) + expect(repository.prepare(body([...body().input, ...output])).fullContextReason).toBeUndefined() + }) + + it("clears stored continuation when a response has no usable ID", () => { + const prepared = repository.prepare(body()) + repository.record(prepared, { id: "resp_1", output: [] }, []) + repository.record(prepared, { output: [] }, []) + expect(store.read()).toBeUndefined() + expect(repository.prepare(body()).fullContextReason).toBe("no cached response") + }) + + it("resets continuation by clearing the local store", () => { + repository.record(repository.prepare(body()), { id: "resp_1", output: [] }, []) + repository.reset() + expect(store.read()).toBeUndefined() + expect(repository.prepare(body()).previousResponseId).toBeUndefined() + }) +}) diff --git a/src/api/providers/codex-websocket/scopes/CodexWebSocketConnectionScope.ts b/src/api/providers/codex-websocket/scopes/CodexWebSocketConnectionScope.ts new file mode 100644 index 0000000000..6fd071219e --- /dev/null +++ b/src/api/providers/codex-websocket/scopes/CodexWebSocketConnectionScope.ts @@ -0,0 +1,45 @@ +import type * as vscode from "vscode" + +import { CodexWebSocketSocketRemoteDataSource } from "../data/remote/CodexWebSocketSocketRemoteDataSource" +import type { CodexWebSocketOptions } from "../models/protocol" + +/** Assembles and owns the services for one authenticated socket. */ +export class CodexWebSocketConnectionScope implements vscode.Disposable { + private _socketRemoteDataSource?: CodexWebSocketSocketRemoteDataSource + private initialized = false + + constructor( + private readonly url: string, + private readonly onError: () => void, + private readonly onClose: () => void, + ) {} + + get socketRemoteDataSource(): CodexWebSocketSocketRemoteDataSource { + if (!this.initialized || !this._socketRemoteDataSource) { + throw new Error("Codex WebSocket connection scope is not initialized") + } + return this._socketRemoteDataSource + } + + async init(options: CodexWebSocketOptions): Promise { + if (this._socketRemoteDataSource) throw new Error("Codex WebSocket connection scope is already initialized") + const socketRemoteDataSource = new CodexWebSocketSocketRemoteDataSource(this.url, this.onError, this.onClose) + this._socketRemoteDataSource = socketRemoteDataSource + try { + await socketRemoteDataSource.init(options) + if (this._socketRemoteDataSource !== socketRemoteDataSource) + throw new Error("Codex WebSocket connection scope was disposed") + this.initialized = true + } catch (error) { + if (this._socketRemoteDataSource === socketRemoteDataSource) this.dispose() + throw error + } + } + + dispose(): void { + const socketRemoteDataSource = this._socketRemoteDataSource + this._socketRemoteDataSource = undefined + this.initialized = false + socketRemoteDataSource?.dispose() + } +} diff --git a/src/api/providers/codex-websocket/scopes/CodexWebSocketRequestScope.ts b/src/api/providers/codex-websocket/scopes/CodexWebSocketRequestScope.ts new file mode 100644 index 0000000000..b2f8ae29b9 --- /dev/null +++ b/src/api/providers/codex-websocket/scopes/CodexWebSocketRequestScope.ts @@ -0,0 +1,71 @@ +import type * as vscode from "vscode" + +import type { CodexWebSocketConnectionManager } from "../managers/CodexWebSocketConnectionManager" +import { CodexWebSocketRequestManager } from "../managers/CodexWebSocketRequestManager" +import { CodexWebSocketResponseManager } from "../managers/CodexWebSocketResponseManager" +import type { CodexWebSocketOptions } from "../models/protocol" +import type { CodexWebSocketContinuationRepository } from "../repositories/CodexWebSocketContinuationRepository" +import { CodexWebSocketRequestStateHolder } from "../state-holders/CodexWebSocketRequestStateHolder" +import { CodexWebSocketResponseStateHolder } from "../state-holders/CodexWebSocketResponseStateHolder" + +/** Assembles and owns request-local services without owning the reusable connection. */ +export class CodexWebSocketRequestScope implements vscode.Disposable { + private _requestManager?: CodexWebSocketRequestManager + private _responseManager?: CodexWebSocketResponseManager + private initializing = false + private initialized = false + + constructor( + private readonly options: CodexWebSocketOptions, + private readonly onAbort: () => void, + ) {} + + get requestManager(): CodexWebSocketRequestManager { + if (!this.initialized || !this._requestManager) + throw new Error("Codex WebSocket request scope is not initialized") + return this._requestManager + } + + get responseManager(): CodexWebSocketResponseManager { + if (!this.initialized || !this._responseManager) { + throw new Error("Codex WebSocket request scope is not initialized") + } + return this._responseManager + } + + async init( + connection: CodexWebSocketConnectionManager, + continuation: CodexWebSocketContinuationRepository, + body: unknown, + ): Promise { + if (this._requestManager || this.initializing) { + throw new Error("Codex WebSocket request scope is already initialized") + } + const requestStateHolder = new CodexWebSocketRequestStateHolder() + const request = new CodexWebSocketRequestManager(this.options, this.onAbort, requestStateHolder) + this._requestManager = request + this.initializing = true + try { + await request.init(connection) + if (this._requestManager !== request) throw new Error("Codex WebSocket request scope was disposed") + const responseStateHolder = new CodexWebSocketResponseStateHolder() + const responseManager = new CodexWebSocketResponseManager(continuation, responseStateHolder) + responseManager.init(body) + this._responseManager = responseManager + this.initialized = true + } catch (error) { + if (this._requestManager === request) await this.dispose() + throw error + } finally { + this.initializing = false + } + } + + async dispose(): Promise { + const request = this._requestManager + this._requestManager = undefined + this._responseManager = undefined + this.initialized = false + await request?.dispose() + } +} diff --git a/src/api/providers/codex-websocket/scopes/CodexWebSocketTransportScope.ts b/src/api/providers/codex-websocket/scopes/CodexWebSocketTransportScope.ts new file mode 100644 index 0000000000..5744c600e4 --- /dev/null +++ b/src/api/providers/codex-websocket/scopes/CodexWebSocketTransportScope.ts @@ -0,0 +1,46 @@ +import type * as vscode from "vscode" + +import { CodexWebSocketTransport } from "../../CodexWebSocketTransport" +import { CodexWebSocketResponseLocalDataSource } from "../data/local/CodexWebSocketResponseLocalDataSource" +import { CodexWebSocketConnectionManager } from "../managers/CodexWebSocketConnectionManager" +import { CodexWebSocketContinuationRepository } from "../repositories/CodexWebSocketContinuationRepository" +import { CodexWebSocketConnectionStateHolder } from "../state-holders/CodexWebSocketConnectionStateHolder" +import { CodexWebSocketRequestScope } from "./CodexWebSocketRequestScope" + +const CODEX_WEBSOCKET_URL = "wss://chatgpt.com/backend-api/codex/responses" + +/** Assembles and owns the task-local WebSocket transport graph. */ +export class CodexWebSocketTransportScope implements vscode.Disposable { + private _transport?: CodexWebSocketTransport + + constructor(private readonly url = CODEX_WEBSOCKET_URL) {} + + get transport(): CodexWebSocketTransport { + if (!this._transport) throw new Error("Codex WebSocket transport scope is not initialized") + return this._transport + } + + /** Assembles services without opening a socket; authentication is supplied per request. */ + init(): void { + if (this._transport) throw new Error("Codex WebSocket transport scope is already initialized") + const responseLocalDataSource = new CodexWebSocketResponseLocalDataSource() + const continuationRepository = new CodexWebSocketContinuationRepository(responseLocalDataSource) + const connectionStateHolder = new CodexWebSocketConnectionStateHolder() + const connectionManager = new CodexWebSocketConnectionManager( + this.url, + () => continuationRepository.reset(), + connectionStateHolder, + ) + this._transport = new CodexWebSocketTransport( + connectionManager, + continuationRepository, + (options, onAbort) => new CodexWebSocketRequestScope(options, onAbort), + ) + } + + async dispose(): Promise { + const transport = this._transport + this._transport = undefined + await transport?.dispose() + } +} diff --git a/src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts b/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts similarity index 54% rename from src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts rename to src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts index 91d2491853..a0ad72aed5 100644 --- a/src/api/providers/codex-websocket/__tests__/CodexWebSocketScopes.spec.ts +++ b/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts @@ -1,10 +1,19 @@ import WebSocket from "ws" -import { CodexWebSocketConnection } from "../CodexWebSocketConnection" +import { CodexWebSocketTransport } from "../../../CodexWebSocketTransport" +import { CodexWebSocketResponseLocalDataSource } from "../../data/local/CodexWebSocketResponseLocalDataSource" +import { CodexWebSocketContinuationRepository } from "../../repositories/CodexWebSocketContinuationRepository" +import { CodexWebSocketConnectionManager } from "../../managers/CodexWebSocketConnectionManager" import { CodexWebSocketConnectionScope } from "../CodexWebSocketConnectionScope" import { CodexWebSocketRequestScope } from "../CodexWebSocketRequestScope" -import { CodexWebSocketUnavailableError } from "../CodexWebSocketUnavailableError" -import type { CodexWebSocketOptions } from "../protocol" +import { CodexWebSocketTransportScope } from "../CodexWebSocketTransportScope" +import { CodexWebSocketRequestManager } from "../../managers/CodexWebSocketRequestManager" +import { CodexWebSocketResponseManager } from "../../managers/CodexWebSocketResponseManager" +import { CodexWebSocketSocketRemoteDataSource } from "../../data/remote/CodexWebSocketSocketRemoteDataSource" +import { CodexWebSocketUnavailableError } from "../../errors/CodexWebSocketUnavailableError" +import type { CodexWebSocketOptions } from "../../models/protocol" +import { CodexWebSocketConnectionStateHolder } from "../../state-holders/CodexWebSocketConnectionStateHolder" +import { CodexWebSocketRequestStateHolder } from "../../state-holders/CodexWebSocketRequestStateHolder" const { sockets } = vi.hoisted(() => ({ sockets: [] as WebSocket[] })) @@ -46,7 +55,9 @@ describe("Codex WebSocket scopes", () => { let controller: AbortController let options: CodexWebSocketOptions let reset = vi.fn<() => void>() - let connection: CodexWebSocketConnection + let connection: CodexWebSocketConnectionManager + let repository: CodexWebSocketContinuationRepository + const body = { model: "test-model", input: [] } const own = }>(resource: T): T => { resources.push(resource) @@ -66,7 +77,14 @@ describe("Codex WebSocket scopes", () => { controller = new AbortController() options = { headers: { Authorization: "Bearer test-token" }, signal: controller.signal, timeoutMs: 2_000 } reset = vi.fn() - connection = own(new CodexWebSocketConnection("ws://test/responses", reset)) + connection = own( + new CodexWebSocketConnectionManager( + "ws://test/responses", + reset, + new CodexWebSocketConnectionStateHolder(), + ), + ) + repository = new CodexWebSocketContinuationRepository(new CodexWebSocketResponseLocalDataSource()) }) afterEach(async () => { @@ -80,20 +98,58 @@ describe("Codex WebSocket scopes", () => { const addListener = vi.spyOn(AbortSignal.prototype, "addEventListener") const onAbort = vi.fn() const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn())) - const request = own(new CodexWebSocketRequestScope(options, onAbort)) + const requestScope = own(new CodexWebSocketRequestScope(options, onAbort)) + const request = own(new CodexWebSocketRequestManager(options, onAbort, new CodexWebSocketRequestStateHolder())) + own(new CodexWebSocketSocketRemoteDataSource("ws://test/responses", vi.fn(), vi.fn())) expect(sockets).toHaveLength(0) expect(addListener).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) - expect(() => scope.socket).toThrow("not initialized") + expect(() => scope.socketRemoteDataSource).toThrow("not initialized") + expect(() => requestScope.requestManager).toThrow("not initialized") expect(() => request.socket).toThrow("not initialized") expect(() => request.events).toThrow("not initialized") expect(() => request.refreshTimeout()).toThrow("not initialized") scope.dispose() + await requestScope.dispose() await request.dispose() controller.abort() expect(onAbort).not.toHaveBeenCalled() }) + it("exposes only owned services and lifecycle methods on scopes", () => { + expect(Object.getOwnPropertyNames(CodexWebSocketConnectionScope.prototype).sort()).toEqual( + ["constructor", "socketRemoteDataSource", "init", "dispose"].sort(), + ) + expect(Object.getOwnPropertyNames(CodexWebSocketRequestScope.prototype).sort()).toEqual( + ["constructor", "requestManager", "responseManager", "init", "dispose"].sort(), + ) + expect(Object.getOwnPropertyNames(CodexWebSocketTransportScope.prototype).sort()).toEqual( + ["constructor", "transport", "init", "dispose"].sort(), + ) + }) + + it("assembles the transport only on root-scope initialization without creating runtime resources", async () => { + vi.useFakeTimers() + const addListener = vi.spyOn(AbortSignal.prototype, "addEventListener") + const scope = own(new CodexWebSocketTransportScope("ws://test/responses")) + expect(() => scope.transport).toThrow("not initialized") + await scope.dispose() + scope.init() + const transport = scope.transport + expect(transport).toBeInstanceOf(CodexWebSocketTransport) + expect(() => scope.init()).toThrow("already initialized") + expect(sockets).toHaveLength(0) + expect(addListener).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + const dispose = vi.spyOn(transport, "dispose") + await scope.dispose() + await scope.dispose() + expect(dispose).toHaveBeenCalledOnce() + expect(() => scope.transport).toThrow("not initialized") + scope.init() + expect(scope.transport).not.toBe(transport) + }) + it("creates the connection on init and removes its subscriptions on disposal", async () => { const onError = vi.fn() const onClose = vi.fn() @@ -102,14 +158,15 @@ describe("Codex WebSocket scopes", () => { const socket = lastSocket() socket.emit("open") await initialized - expect(scope.socket).toBe(socket) + expect(scope.socketRemoteDataSource).toBeInstanceOf(CodexWebSocketSocketRemoteDataSource) + expect(scope.socketRemoteDataSource.socket).toBe(socket) await expect(scope.init(options)).rejects.toThrow("already initialized") socket.emit("error", new Error("Idle error")) expect(onError).toHaveBeenCalledOnce() scope.dispose() scope.dispose() expect(socket.terminate).toHaveBeenCalledOnce() - expect(() => scope.socket).toThrow("not initialized") + expect(() => scope.socketRemoteDataSource).toThrow("not initialized") await Promise.resolve() expect(onClose).not.toHaveBeenCalled() expectNoListeners(socket) @@ -159,34 +216,42 @@ describe("Codex WebSocket scopes", () => { expectNoListeners(first) expect(onError).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() - expect(scope.socket).toBe(next) + expect(scope.socketRemoteDataSource.socket).toBe(next) }) it("does not open a connection for an already-aborted request", async () => { controller.abort(new Error("Stopped")) const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn())) - const request = own(new CodexWebSocketRequestScope(options, vi.fn())) + const requestScope = own(new CodexWebSocketRequestScope(options, vi.fn())) await expect(scope.init(options)).rejects.toThrow("Stopped") - await expect(request.init(connection)).rejects.toThrow("Stopped") + await expect(requestScope.init(connection, repository, body)).rejects.toThrow("Stopped") expect(sockets).toHaveLength(0) }) it("owns request subscriptions and deadlines without disposing the reusable connection", async () => { vi.useFakeTimers() const onAbort = vi.fn() - const request = own(new CodexWebSocketRequestScope(options, onAbort)) - const initialized = request.init(connection) + const requestScope = own(new CodexWebSocketRequestScope(options, onAbort)) + const initialized = requestScope.init(connection, repository, body) + expect(() => requestScope.requestManager).toThrow("not initialized") const socket = lastSocket() socket.emit("open") await initialized + const request = requestScope.requestManager + expect(request).toBeInstanceOf(CodexWebSocketRequestManager) + expect(requestScope.responseManager).toBeInstanceOf(CodexWebSocketResponseManager) + expect(requestScope.responseManager.preparedRequest.request).toBe(body) expect(request.socket).toBe(socket) expect(socket.listenerCount("message")).toBe(1) - await expect(request.init(connection)).rejects.toThrow("already initialized") + await expect(requestScope.init(connection, repository, body)).rejects.toThrow("already initialized") request.refreshTimeout() request.refreshTimeout() expect(vi.getTimerCount()).toBe(1) - await request.dispose() - await request.dispose() + await requestScope.dispose() + await requestScope.dispose() + expect(() => requestScope.requestManager).toThrow("not initialized") + expect(() => request.socket).toThrow("not initialized") + expect(() => request.events).toThrow("not initialized") expect(vi.getTimerCount()).toBe(0) expect(socket.listenerCount("message")).toBe(0) expect(socket.listenerCount("error")).toBe(1) @@ -197,13 +262,16 @@ describe("Codex WebSocket scopes", () => { }) it("can reinitialize a disposed request on the same connection without duplicate listeners", async () => { - const request = own(new CodexWebSocketRequestScope(options, () => connection.dispose())) - const initialized = request.init(connection) + const requestScope = own(new CodexWebSocketRequestScope(options, () => connection.dispose())) + const initialized = requestScope.init(connection, repository, body) const socket = lastSocket() socket.emit("open") await initialized - await request.dispose() - await request.init(connection) + const firstRequest = requestScope.requestManager + await requestScope.dispose() + await requestScope.init(connection, repository, body) + const request = requestScope.requestManager + expect(request).not.toBe(firstRequest) expect(sockets).toHaveLength(1) expect(socket.listenerCount("message")).toBe(1) const event = request.events.next() @@ -211,46 +279,67 @@ describe("Codex WebSocket scopes", () => { controller.abort(reason) await expect(event).rejects.toThrow() expect(request.signal.reason).toBe(reason) - await request.dispose() + await requestScope.dispose() expectNoListeners(socket) }) it("cleans up a rejected upgrade and preserves the safe HTTP fallback error", async () => { const onAbort = vi.fn() - const request = own(new CodexWebSocketRequestScope(options, onAbort)) - const initialized = expect(request.init(connection)).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) + const requestScope = own(new CodexWebSocketRequestScope(options, onAbort)) + const initialized = expect(requestScope.init(connection, repository, body)).rejects.toBeInstanceOf( + CodexWebSocketUnavailableError, + ) const socket = lastSocket() socket.emit("error", new Error("Upgrade rejected")) await initialized - expect(request.signal.aborted).toBe(false) + expect(() => requestScope.requestManager).toThrow("not initialized") + expect(onAbort).not.toHaveBeenCalled() expectNoListeners(socket) controller.abort() expect(onAbort).not.toHaveBeenCalled() }) + it("cleans up request subscriptions when response-manager initialization fails", async () => { + const onAbort = vi.fn() + const scope = own(new CodexWebSocketRequestScope(options, onAbort)) + const initialized = expect(scope.init(connection, repository, { input: "invalid" })).rejects.toThrow( + "input must be an array", + ) + const socket = lastSocket() + socket.emit("open") + await initialized + expect(() => scope.requestManager).toThrow("not initialized") + expect(() => scope.responseManager).toThrow("not initialized") + expect(socket.listenerCount("message")).toBe(0) + expect(socket.listenerCount("error")).toBe(1) + expect(socket.listenerCount("close")).toBe(1) + controller.abort() + expect(onAbort).not.toHaveBeenCalled() + }) + it("cancels and cleans up a request disposed during initialization", async () => { - const request = own(new CodexWebSocketRequestScope(options, () => connection.dispose())) - const initialized = expect(request.init(connection)).rejects.toThrow() + const requestScope = own(new CodexWebSocketRequestScope(options, () => connection.dispose())) + const initialized = expect(requestScope.init(connection, repository, body)).rejects.toThrow() const socket = lastSocket() - await request.dispose() + await requestScope.dispose() await initialized expectNoListeners(socket) - expect(() => request.socket).toThrow("not initialized") - expect(() => request.events).toThrow("not initialized") + expect(() => requestScope.requestManager).toThrow("not initialized") }) it("does not become initialized if disposed after acquiring a socket but before init resolves", async () => { const socket = new WebSocket("ws://test/responses") socket.emit("open") vi.spyOn(connection, "acquire").mockResolvedValue(socket) - const request = own(new CodexWebSocketRequestScope(options, vi.fn())) - const initialized = expect(request.init(connection)).rejects.toThrow("scope was disposed") + const requestScope = own(new CodexWebSocketRequestScope(options, vi.fn())) + const initialized = expect(requestScope.init(connection, repository, body)).rejects.toThrow( + "scope was disposed", + ) await Promise.resolve() - await request.dispose() + await requestScope.dispose() await initialized expectNoListeners(socket) - expect(() => request.socket).toThrow("not initialized") - expect(() => request.events).toThrow("not initialized") + expect(() => requestScope.requestManager).toThrow("not initialized") }) it("clears an idle connection timer on disposal and does not schedule one without a socket", async () => { diff --git a/src/api/providers/codex-websocket/state-holders/CodexWebSocketConnectionStateHolder.ts b/src/api/providers/codex-websocket/state-holders/CodexWebSocketConnectionStateHolder.ts new file mode 100644 index 0000000000..bb18f75419 --- /dev/null +++ b/src/api/providers/codex-websocket/state-holders/CodexWebSocketConnectionStateHolder.ts @@ -0,0 +1,64 @@ +import type { CodexWebSocketConnectionStateModel } from "../models/CodexWebSocketConnectionStateModel" +import type { CodexWebSocketConnectionScope } from "../scopes/CodexWebSocketConnectionScope" +import { StateHolder } from "./StateHolder" + +type ConnectionState = CodexWebSocketConnectionStateModel +type ConnectingState = Extract +type LiveState = Extract + +export class CodexWebSocketConnectionStateHolder extends StateHolder { + constructor() { + super({ status: "disconnected" }) + } + + beginConnection(key: string, scope: CodexWebSocketConnectionScope): ConnectingState { + if (this.state.status !== "disconnected" && this.state.status !== "unavailable") { + throw new Error("Codex WebSocket connection is already active") + } + const state = { status: "connecting", key, scope } as const + this.replaceState(state) + return state + } + + connect(attempt: ConnectingState, connectedAt: number): void { + this.ensureCurrent(attempt) + this.replaceState({ status: "active", key: attempt.key, scope: attempt.scope, connectedAt }) + } + + activate(state: LiveState): void { + this.ensureCurrent(state) + this.replaceState({ status: "active", key: state.key, scope: state.scope, connectedAt: state.connectedAt }) + } + + idle(state: LiveState, idleTimer: NodeJS.Timeout): Extract { + this.ensureCurrent(state) + const next = { + status: "idle", + key: state.key, + scope: state.scope, + connectedAt: state.connectedAt, + idleTimer, + } as const + this.replaceState(next) + return next + } + + markUnavailable(key: string, retryAt: number): void { + if (this.state.status !== "disconnected") throw new Error("Codex WebSocket connection must be disconnected") + this.replaceState({ status: "unavailable", key, retryAt }) + } + + disconnect(): ConnectionState { + const previous = this.state + if (previous.status !== "unavailable") this.replaceState({ status: "disconnected" }) + return previous + } + + ownsScope(scope: CodexWebSocketConnectionScope): boolean { + return "scope" in this.state && this.state.scope === scope + } + + private ensureCurrent(state: ConnectionState): void { + if (!this.isCurrent(state)) throw new Error("Codex WebSocket connection attempt was disposed") + } +} diff --git a/src/api/providers/codex-websocket/state-holders/CodexWebSocketRequestStateHolder.ts b/src/api/providers/codex-websocket/state-holders/CodexWebSocketRequestStateHolder.ts new file mode 100644 index 0000000000..ee328c24e0 --- /dev/null +++ b/src/api/providers/codex-websocket/state-holders/CodexWebSocketRequestStateHolder.ts @@ -0,0 +1,59 @@ +import type WebSocket from "ws" + +import type { CodexWebSocketRequestStateModel } from "../models/CodexWebSocketRequestStateModel" +import { StateHolder } from "./StateHolder" + +type RequestState = CodexWebSocketRequestStateModel, NodeJS.Timeout> +type InitializingState = Extract +type ActiveState = Extract + +export class CodexWebSocketRequestStateHolder extends StateHolder { + constructor() { + super({ status: "uninitialized" }) + } + + beginInitialization(controller: AbortController, signal: AbortSignal): InitializingState { + if (this.state.status === "active" || this.state.status === "initializing") { + throw new Error("Codex WebSocket request scope is already initialized") + } + const state = { status: "initializing", controller, signal } as const + this.replaceState(state) + return state + } + + activate(initializing: InitializingState, socket: WebSocket, events: AsyncIterableIterator): void { + this.ensureCurrent(initializing) + this.replaceState({ + status: "active", + controller: initializing.controller, + signal: initializing.signal, + socket, + events, + }) + } + + setDeadline(state: ActiveState, timeout: NodeJS.Timeout): ActiveState { + this.ensureCurrent(state) + const timed = { ...state, timeout } + this.replaceState(timed) + return timed + } + + clearDeadline(state: ActiveState): void { + this.ensureCurrent(state) + const { timeout: _timeout, ...active } = state + this.replaceState(active) + } + + dispose(): RequestState { + const previous = this.state + if (previous.status === "initializing" || previous.status === "active") { + this.replaceState({ status: "disposed", signal: previous.signal }) + } + return previous + } + + private ensureCurrent(state: RequestState): void { + if (!this.isCurrent(state)) throw new Error("Codex WebSocket request scope was disposed") + } +} diff --git a/src/api/providers/codex-websocket/state-holders/CodexWebSocketResponseStateHolder.ts b/src/api/providers/codex-websocket/state-holders/CodexWebSocketResponseStateHolder.ts new file mode 100644 index 0000000000..89f7adfdde --- /dev/null +++ b/src/api/providers/codex-websocket/state-holders/CodexWebSocketResponseStateHolder.ts @@ -0,0 +1,60 @@ +import type { CodexWebSocketResponseStateModel } from "../models/CodexWebSocketResponseStateModel" +import type { PreparedCodexRequest } from "../models/PreparedCodexRequest" +import { StateHolder } from "./StateHolder" + +export class CodexWebSocketResponseStateHolder extends StateHolder { + constructor() { + super({ status: "uninitialized" }) + } + + initialize(prepared: PreparedCodexRequest): void { + if (this.state.status !== "uninitialized") + throw new Error("Codex WebSocket response manager is already initialized") + this.replaceState({ status: "awaiting", prepared }) + } + + get preparedRequest(): PreparedCodexRequest { + if (this.state.status === "uninitialized") + throw new Error("Codex WebSocket response manager is not initialized") + return this.state.prepared + } + + get streamedOutput(): readonly unknown[] { + return this.state.status === "streaming" ? this.state.output : [] + } + + assertAcceptingEvents(): void { + if (this.state.status === "uninitialized") + throw new Error("Codex WebSocket response manager is not initialized") + if (this.state.status === "completed") throw new Error("Codex WebSocket response is already completed") + } + + appendOutputItem(item: unknown): void { + this.assertAcceptingEvents() + this.replaceState({ + status: "streaming", + prepared: this.preparedRequest, + output: [...this.streamedOutput, item], + }) + } + + startStreaming(): void { + this.assertAcceptingEvents() + if (this.state.status === "streaming") return + this.replaceState({ status: "streaming", prepared: this.preparedRequest, output: [] }) + } + + complete(): void { + this.assertAcceptingEvents() + this.replaceState({ status: "completed", prepared: this.preparedRequest }) + } + + get canRecoverCacheMiss(): boolean { + return this.state.status === "awaiting" && this.state.prepared.fullContextReason === undefined + } + + beginRecovery(): void { + if (!this.canRecoverCacheMiss) throw new Error("Codex WebSocket response cannot recover a cache miss") + this.replaceState({ status: "recovering", prepared: this.preparedRequest }) + } +} diff --git a/src/api/providers/codex-websocket/state-holders/StateHolder.ts b/src/api/providers/codex-websocket/state-holders/StateHolder.ts new file mode 100644 index 0000000000..0a948e142a --- /dev/null +++ b/src/api/providers/codex-websocket/state-holders/StateHolder.ts @@ -0,0 +1,16 @@ +/** Owns immutable state snapshots; subclasses expose domain-specific transitions. */ +export abstract class StateHolder { + protected constructor(private current: TState) {} + + get state(): TState { + return this.current + } + + isCurrent(snapshot: TState): boolean { + return this.current === snapshot + } + + protected replaceState(state: TState): void { + this.current = state + } +} diff --git a/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts b/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts new file mode 100644 index 0000000000..dfb3bd528e --- /dev/null +++ b/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts @@ -0,0 +1,128 @@ +import WebSocket from "ws" + +import { asyncStreamFrom } from "../../../../../test-utils/stream" +import { CodexWebSocketResponseLocalDataSource } from "../../data/local/CodexWebSocketResponseLocalDataSource" +import { CodexWebSocketContinuationRepository } from "../../repositories/CodexWebSocketContinuationRepository" +import { CodexWebSocketConnectionScope } from "../../scopes/CodexWebSocketConnectionScope" +import { CodexWebSocketConnectionStateHolder } from "../CodexWebSocketConnectionStateHolder" +import { CodexWebSocketRequestStateHolder } from "../CodexWebSocketRequestStateHolder" +import { CodexWebSocketResponseStateHolder } from "../CodexWebSocketResponseStateHolder" +import { StateHolder } from "../StateHolder" + +vi.mock("ws", async () => { + const { EventEmitter } = await import("node:events") + return { default: class TestSocket extends EventEmitter {} } +}) + +describe("Codex WebSocket state holders", () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + const scope = () => new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn()) + const prepared = () => + new CodexWebSocketContinuationRepository(new CodexWebSocketResponseLocalDataSource()).prepare({ + model: "test-model", + input: [], + }) + + it("uses the generic holder and replaces snapshots rather than mutating prior state", () => { + const holder = new CodexWebSocketConnectionStateHolder() + expect(holder).toBeInstanceOf(StateHolder) + const initial = holder.state + const attempt = holder.beginConnection("credentials-hash", scope()) + expect(holder.isCurrent(attempt)).toBe(true) + expect(holder.isCurrent(initial)).toBe(false) + holder.connect(attempt, 123) + expect(initial).toEqual({ status: "disconnected" }) + expect(attempt.status).toBe("connecting") + expect(holder.state).toMatchObject({ status: "active", key: "credentials-hash", connectedAt: 123 }) + }) + + it("associates idle resources only with the idle snapshot", () => { + const holder = new CodexWebSocketConnectionStateHolder() + const attempt = holder.beginConnection("key", scope()) + holder.connect(attempt, 123) + const active = holder.state + if (active.status !== "active") throw new Error("Expected an active connection") + const timer = setTimeout(() => {}, 1_000) + const idle = holder.idle(active, timer) + holder.activate(idle) + expect(active).not.toHaveProperty("idleTimer") + expect(idle.idleTimer).toBe(timer) + expect(holder.state).not.toHaveProperty("idleTimer") + expect(vi.getTimerCount()).toBe(1) + clearTimeout(timer) + }) + + it("rejects transitions from a replaced connection snapshot", () => { + const holder = new CodexWebSocketConnectionStateHolder() + const stale = holder.beginConnection("old", scope()) + holder.disconnect() + const current = holder.beginConnection("new", scope()) + expect(() => holder.connect(stale, 123)).toThrow("disposed") + expect(holder.state).toBe(current) + }) + + it("preserves credential cooldown without retaining connection resources", () => { + const holder = new CodexWebSocketConnectionStateHolder() + holder.markUnavailable("key", 60_000) + const unavailable = holder.state + holder.disconnect() + expect(holder.state).toBe(unavailable) + expect(holder.state).toEqual({ status: "unavailable", key: "key", retryAt: 60_000 }) + expect(vi.getTimerCount()).toBe(0) + }) + + it("changes request lifecycle state without aborting signals or owning subscriptions", () => { + const holder = new CodexWebSocketRequestStateHolder() + const controller = new AbortController() + const initializing = holder.beginInitialization(controller, controller.signal) + holder.dispose() + expect(holder.state).toEqual({ status: "disposed", signal: controller.signal }) + expect(controller.signal.aborted).toBe(false) + expect(initializing.status).toBe("initializing") + expect(vi.getTimerCount()).toBe(0) + }) + + it("replaces request deadline snapshots without clearing timers itself", () => { + const holder = new CodexWebSocketRequestStateHolder() + const controller = new AbortController() + const initializing = holder.beginInitialization(controller, controller.signal) + holder.activate(initializing, new WebSocket("ws://test"), asyncStreamFrom([])) + const active = holder.state + if (active.status !== "active") throw new Error("Expected an active request") + const timer = setTimeout(() => {}, 1_000) + const timed = holder.setDeadline(active, timer) + holder.clearDeadline(timed) + expect(active).not.toHaveProperty("timeout") + expect(timed.timeout).toBe(timer) + expect(holder.state).not.toHaveProperty("timeout") + expect(vi.getTimerCount()).toBe(1) + clearTimeout(timer) + }) + + it("keeps prior response output snapshots unchanged and makes completion terminal", () => { + const holder = new CodexWebSocketResponseStateHolder() + holder.initialize(prepared()) + holder.appendOutputItem("first") + const first = holder.state + holder.appendOutputItem("second") + expect(first).toMatchObject({ status: "streaming", output: ["first"] }) + expect(holder.streamedOutput).toEqual(["first", "second"]) + holder.complete() + expect(holder.state.status).toBe("completed") + expect(holder.state).not.toHaveProperty("output") + expect(() => holder.appendOutputItem("late")).toThrow("already completed") + }) + + it("permits recovery only in the initial awaiting state", () => { + const holder = new CodexWebSocketResponseStateHolder() + holder.initialize({ ...prepared(), fullContextReason: undefined, previousResponseId: "resp_1" }) + expect(holder.canRecoverCacheMiss).toBe(true) + holder.beginRecovery() + expect(holder.state.status).toBe("recovering") + expect(holder.canRecoverCacheMiss).toBe(false) + expect(() => holder.beginRecovery()).toThrow("cannot recover") + holder.startStreaming() + expect(holder.state.status).toBe("streaming") + }) +}) diff --git a/src/api/providers/codex-websocket/protocol.ts b/src/api/providers/codex-websocket/utils/protocol.ts similarity index 83% rename from src/api/providers/codex-websocket/protocol.ts rename to src/api/providers/codex-websocket/utils/protocol.ts index 56542e3665..73a635ed08 100644 --- a/src/api/providers/codex-websocket/protocol.ts +++ b/src/api/providers/codex-websocket/utils/protocol.ts @@ -1,14 +1,7 @@ import { createHash } from "node:crypto" import stringify from "safe-stable-stringify" -export type JsonObject = Record -export type CodexResponseEvent = JsonObject & { type: string } - -export interface CodexWebSocketOptions { - headers: Record - signal: AbortSignal - timeoutMs: number -} +import type { JsonObject, CodexResponseEvent } from "../models/protocol" export function asJsonObject(value: unknown): JsonObject { if (!value || typeof value !== "object" || Array.isArray(value)) { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 24c753983b..b5328351ce 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -25,7 +25,8 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" -import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "./CodexWebSocketTransport" +import { type CodexWebSocketTransport, CodexWebSocketUnavailableError } from "./CodexWebSocketTransport" +import { CodexWebSocketTransportScope } from "./codex-websocket/scopes/CodexWebSocketTransportScope" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -131,7 +132,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion protected options: ApiHandlerOptions private readonly providerName = "OpenAI Codex" private client?: OpenAI - private readonly webSocketTransport?: CodexWebSocketTransport + private readonly webSocketScope?: CodexWebSocketTransportScope + private webSocketTransport?: CodexWebSocketTransport // Complete response output array private lastResponseOutput: any[] | undefined // Last top-level response id @@ -185,7 +187,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion super() this.options = options if (options.openAiCodexUseWebSocket ?? DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET) { - this.webSocketTransport = CodexWebSocketTransport.create() + this.webSocketScope = new CodexWebSocketTransportScope() } // Generate a new session ID for standalone handler usage (fallback) this.sessionId = uuidv7() @@ -254,6 +256,10 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.sawTextDeltaInCurrentResponse = false this.sawSdkEventInCurrentResponse = false this.streamedToolCallIds.clear() + if (this.webSocketScope && !this.webSocketTransport) { + this.webSocketScope.init() + this.webSocketTransport = this.webSocketScope.transport + } if (metadata?.suppressPreviousResponseId) this.webSocketTransport?.resetContinuation() // Get access token from OAuth manager From 8c73834f92123b1045a27587d877373f558247f3 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 20:46:59 +0300 Subject: [PATCH 05/10] chore(debug): open yxpro platform in extension development host --- .vscode/launch.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 902b251dae..6a9174944c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,11 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceFolder}/src", "--disable-extensions"], + "args": [ + "/Users/gubin-dev/arcadia/flutter/pro/yxpro/yxpro_platform", + "--extensionDevelopmentPath=${workspaceFolder}/src", + "--disable-extensions" + ], "sourceMaps": true, "outFiles": ["${workspaceFolder}/src/dist/**/*.js"], "preLaunchTask": "${defaultBuildTask}", From 0106cccf4f224cc39a15c0189353394aea72db5d Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 20:57:32 +0300 Subject: [PATCH 06/10] refactor(codex): simplify socket initialization and connection acquisition --- .../CodexWebSocketSocketRemoteDataSource.ts | 62 ++++++---- .../CodexWebSocketConnectionManager.ts | 115 +++++++++++------- .../CodexWebSocketConnectionManager.spec.ts | 12 ++ .../__tests__/CodexWebSocketScopes.spec.ts | 14 +++ 4 files changed, 135 insertions(+), 68 deletions(-) diff --git a/src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts b/src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts index 96491e66df..d1ecf368be 100644 --- a/src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts +++ b/src/api/providers/codex-websocket/data/remote/CodexWebSocketSocketRemoteDataSource.ts @@ -24,11 +24,32 @@ export class CodexWebSocketSocketRemoteDataSource { async init(options: CodexWebSocketOptions): Promise { if (this._socket) throw new Error("Codex WebSocket connection scope is already initialized") options.signal.throwIfAborted() - const socket = new WebSocket(this.url, { + const socket = this.createSocket(options) + this._socket = socket + this.disposeSocket = this.registerHandlers(socket) + try { + await this.waitForOpen(socket, options.signal) + } catch (error) { + if (this._socket === socket) this.dispose() + throw error + } + } + + dispose(): void { + const disposeSocket = this.disposeSocket + this._socket = undefined + this.disposeSocket = undefined + disposeSocket?.() + } + + private createSocket(options: CodexWebSocketOptions): WebSocket { + return new WebSocket(this.url, { headers: { ...options.headers, "OpenAI-Beta": "responses_websockets=2026-02-06" }, handshakeTimeout: Math.min(options.timeoutMs, HANDSHAKE_TIMEOUT_MS), }) - this._socket = socket + } + + private registerHandlers(socket: WebSocket): () => void { const onError = () => { if (this._socket === socket) this.onError() } @@ -37,31 +58,26 @@ export class CodexWebSocketSocketRemoteDataSource { } socket.on("error", onError) socket.on("close", onClose) - this.disposeSocket = () => { + return () => { socket.off("close", onClose) - if (socket.readyState === WebSocket.CLOSED) { - socket.off("error", onError) - } else { - // terminate() can emit an asynchronous error during an upgrade. Keep the inert - // error handler until terminal close, then remove it as well. - socket.once("close", () => socket.off("error", onError)) - socket.terminate() - } + this.terminateSocket(socket, onError) } - try { - await once(socket, "open", { signal: options.signal }) - options.signal.throwIfAborted() - if (this._socket !== socket) throw new Error("Codex WebSocket connection scope was disposed") - } catch (error) { - if (this._socket === socket) this.dispose() - throw error + } + + private terminateSocket(socket: WebSocket, onError: () => void): void { + if (socket.readyState === WebSocket.CLOSED) { + socket.off("error", onError) + return } + // An upgrade terminated in flight can emit an asynchronous error. Keep its inert + // error handler until terminal close, then remove it as well. + socket.once("close", () => socket.off("error", onError)) + socket.terminate() } - dispose(): void { - const disposeSocket = this.disposeSocket - this._socket = undefined - this.disposeSocket = undefined - disposeSocket?.() + private async waitForOpen(socket: WebSocket, signal: AbortSignal): Promise { + await once(socket, "open", { signal }) + signal.throwIfAborted() + if (this._socket !== socket) throw new Error("Codex WebSocket connection scope was disposed") } } diff --git a/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts b/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts index 26a8f2dee0..15228e8945 100644 --- a/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts +++ b/src/api/providers/codex-websocket/managers/CodexWebSocketConnectionManager.ts @@ -10,6 +10,8 @@ const MAX_CONNECTION_AGE_MS = 55 * 60_000 const IDLE_CONNECTION_TIMEOUT_MS = 120_000 const UPGRADE_RETRY_DELAY_MS = 60_000 +type ConnectionAttempt = ReturnType + /** Owns a single authenticated connection and bounds its lifetime between requests. */ export class CodexWebSocketConnectionManager { constructor( @@ -25,70 +27,93 @@ export class CodexWebSocketConnectionManager { async acquire(options: CodexWebSocketOptions): Promise { options.signal.throwIfAborted() const key = fingerprint(options.headers) + this.assertAcquisitionAllowed(key) + const socket = this.tryReuseConnection(key) + if (socket) return socket + return this.openConnection(key, options) + } + + release(): void { const state = this.state - if (state.status === "connecting") + if (state.status !== "active" && state.status !== "idle") return + if (state.status === "idle") clearTimeout(state.idleTimer) + const idleTimer = setTimeout(() => { + if (this.stateHolder.isCurrent(idleState)) this.dispose() + }, IDLE_CONNECTION_TIMEOUT_MS) + const idleState = this.stateHolder.idle(state, idleTimer) + idleTimer.unref() + } + + dispose(): void { + // Request cleanup must not erase the safe-upgrade-failure cooldown. + const state = this.stateHolder.disconnect() + if (state.status === "idle") clearTimeout(state.idleTimer) + this.resetContinuation() + if ("scope" in state) state.scope.dispose() + } + + private assertAcquisitionAllowed(key: string): void { + const state = this.state + if (state.status === "connecting") { throw new Error("Concurrent Codex WebSocket connection attempts are not supported") + } if (state.status === "unavailable" && state.key === key && Date.now() < state.retryAt) { throw new CodexWebSocketUnavailableError("Codex WebSocket unavailable; using HTTP") } - if ( - (state.status === "active" || state.status === "idle") && - state.key === key && - state.scope.socketRemoteDataSource.socket.readyState === WebSocket.OPEN && - Date.now() - state.connectedAt < MAX_CONNECTION_AGE_MS - ) { - if (state.status === "idle") clearTimeout(state.idleTimer) - this.stateHolder.activate(state) - return state.scope.socketRemoteDataSource.socket - } + } + + private tryReuseConnection(key: string): WebSocket | undefined { + const state = this.state + if (state.status !== "active" && state.status !== "idle") return undefined + if (state.key !== key || Date.now() - state.connectedAt >= MAX_CONNECTION_AGE_MS) return undefined + const socket = state.scope.socketRemoteDataSource.socket + if (socket.readyState !== WebSocket.OPEN) return undefined + if (state.status === "idle") clearTimeout(state.idleTimer) + this.stateHolder.activate(state) + return socket + } + + private async openConnection(key: string, options: CodexWebSocketOptions): Promise { this.dispose() - const scope = new CodexWebSocketConnectionScope( - this.url, - () => { - if (this.stateHolder.ownsScope(scope)) this.resetContinuation() - }, - () => { - if (!this.stateHolder.ownsScope(scope)) return - // A connecting attempt publishes its own outcome after init settles. - if (this.state.status === "connecting") scope.dispose() - else this.dispose() - }, - ) + const scope = this.createConnectionScope() const attempt = this.stateHolder.beginConnection(key, scope) try { await scope.init(options) options.signal.throwIfAborted() if (!this.stateHolder.isCurrent(attempt)) throw new Error("Codex WebSocket connection attempt was disposed") } catch (error) { - const current = this.stateHolder.isCurrent(attempt) - if (current) this.dispose() - options.signal.throwIfAborted() - if (!current) throw error - this.stateHolder.markUnavailable(key, Date.now() + UPGRADE_RETRY_DELAY_MS) - console.warn("[Codex WebSocket] Upgrade failed; falling back to HTTP") - throw new CodexWebSocketUnavailableError("Codex WebSocket upgrade failed", { cause: error }) + this.handleUpgradeFailure(attempt, options.signal, error) } this.stateHolder.connect(attempt, Date.now()) console.info("[Codex WebSocket] Connected") return scope.socketRemoteDataSource.socket } - release(): void { - const state = this.state - if (state.status !== "active" && state.status !== "idle") return - if (state.status === "idle") clearTimeout(state.idleTimer) - const idleTimer = setTimeout(() => { - if (this.stateHolder.isCurrent(idleState)) this.dispose() - }, IDLE_CONNECTION_TIMEOUT_MS) - const idleState = this.stateHolder.idle(state, idleTimer) - idleTimer.unref() + private createConnectionScope(): CodexWebSocketConnectionScope { + const scope = new CodexWebSocketConnectionScope( + this.url, + () => { + if (this.stateHolder.ownsScope(scope)) this.resetContinuation() + }, + () => this.handleScopeClose(scope), + ) + return scope } - dispose(): void { - // Request cleanup must not erase the safe-upgrade-failure cooldown. - const state = this.stateHolder.disconnect() - if (state.status === "idle") clearTimeout(state.idleTimer) - this.resetContinuation() - if ("scope" in state) state.scope.dispose() + private handleScopeClose(scope: CodexWebSocketConnectionScope): void { + if (!this.stateHolder.ownsScope(scope)) return + // A connecting attempt publishes its own outcome after init settles. + if (this.state.status === "connecting") scope.dispose() + else this.dispose() + } + + private handleUpgradeFailure(attempt: ConnectionAttempt, signal: AbortSignal, error: unknown): never { + const current = this.stateHolder.isCurrent(attempt) + if (current) this.dispose() + signal.throwIfAborted() + if (!current) throw error + this.stateHolder.markUnavailable(attempt.key, Date.now() + UPGRADE_RETRY_DELAY_MS) + console.warn("[Codex WebSocket] Upgrade failed; falling back to HTTP") + throw new CodexWebSocketUnavailableError("Codex WebSocket upgrade failed", { cause: error }) } } diff --git a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts index 494c76b7d0..221a755f7c 100644 --- a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts +++ b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts @@ -127,6 +127,18 @@ describe("CodexWebSocketConnectionManager state transitions", () => { expect(scopes).toHaveLength(2) }) + it("preserves cancellation rather than reporting HTTP fallback during cooldown", async () => { + const failed = expect(manager.acquire(options())).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) + lastScope().fail(new Error("Upgrade rejected")) + await failed + const controller = new AbortController() + const reason = new Error("Stopped during cooldown") + controller.abort(reason) + await expect(manager.acquire({ ...options(), signal: controller.signal })).rejects.toBe(reason) + await expect(manager.acquire(options())).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) + expect(scopes).toHaveLength(1) + }) + it.each(["success", "failure"])( "ignores late upgrade %s after disposal and replacement", async (outcome: string) => { diff --git a/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts b/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts index a0ad72aed5..2dd5a677f0 100644 --- a/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts +++ b/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts @@ -185,6 +185,20 @@ describe("Codex WebSocket scopes", () => { expectNoListeners(socket) }) + it("cleans up cancellation after socket open but before initialization resolves", async () => { + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn())) + const initialized = scope.init(options) + const reason = new Error("Stopped after socket open") + const rejected = expect(initialized).rejects.toBe(reason) + const socket = lastSocket() + socket.emit("open") + controller.abort(reason) + await rejected + expect(socket.terminate).toHaveBeenCalledOnce() + expect(() => scope.socketRemoteDataSource).toThrow("not initialized") + expectNoListeners(socket) + }) + it("removes subscriptions from an already-closed socket", async () => { const onClose = vi.fn() const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), onClose)) From e8248faa21a8bf05955788443fba90ec1a4aea1a Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 21:21:28 +0300 Subject: [PATCH 07/10] fix(codex): complete WebSocket translations and regression coverage --- .../__tests__/CodexWebSocketTransport.spec.ts | 17 ++++++ .../CodexWebSocketConnectionManager.spec.ts | 14 +++++ .../CodexWebSocketRequestManager.spec.ts | 53 +++++++++++++++- .../CodexWebSocketItemSnapshotModel.spec.ts | 21 +++++++ ...dexWebSocketContinuationRepository.spec.ts | 9 +++ .../__tests__/CodexWebSocketScopes.spec.ts | 60 +++++++++++++++++++ .../CodexWebSocketStateHolders.spec.ts | 31 ++++++++++ .../utils/__tests__/protocol.spec.ts | 12 ++++ webview-ui/src/i18n/locales/ca/settings.json | 4 ++ webview-ui/src/i18n/locales/de/settings.json | 4 ++ webview-ui/src/i18n/locales/es/settings.json | 4 ++ webview-ui/src/i18n/locales/fr/settings.json | 4 ++ webview-ui/src/i18n/locales/hi/settings.json | 4 ++ webview-ui/src/i18n/locales/id/settings.json | 4 ++ webview-ui/src/i18n/locales/it/settings.json | 4 ++ webview-ui/src/i18n/locales/ja/settings.json | 4 ++ webview-ui/src/i18n/locales/ko/settings.json | 4 ++ webview-ui/src/i18n/locales/nl/settings.json | 4 ++ webview-ui/src/i18n/locales/pl/settings.json | 4 ++ .../src/i18n/locales/pt-BR/settings.json | 4 ++ webview-ui/src/i18n/locales/tr/settings.json | 4 ++ webview-ui/src/i18n/locales/vi/settings.json | 4 ++ .../src/i18n/locales/zh-CN/settings.json | 4 ++ .../src/i18n/locales/zh-TW/settings.json | 4 ++ 24 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/api/providers/codex-websocket/models/__tests__/CodexWebSocketItemSnapshotModel.spec.ts create mode 100644 src/api/providers/codex-websocket/utils/__tests__/protocol.spec.ts diff --git a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts index eee762dfff..96cbda1fd2 100644 --- a/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts +++ b/src/api/providers/__tests__/CodexWebSocketTransport.spec.ts @@ -89,6 +89,23 @@ describe("CodexWebSocketTransport", () => { expect(events[0]).toMatchObject({ type: "response.completed", response: { usage: { output_tokens: 2 } } }) }) + it("preserves caller metadata while adding Responses Lite transport metadata", async () => { + await collectStream( + transport.stream( + { ...body(), client_metadata: { trace_id: "trace-1", feature: "test" } }, + { + ...options(), + headers: { Authorization: "Bearer test-token", "x-openai-internal-codex-responses-lite": "true" }, + }, + ), + ) + expect(requests[0].client_metadata).toEqual({ + trace_id: "trace-1", + feature: "test", + ws_request_header_x_openai_internal_codex_responses_lite: "true", + }) + }) + it("reuses a socket and sends only new tool output after reconstructed reasoning and tool calls", async () => { const output = [ { diff --git a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts index 221a755f7c..eb6b653bb2 100644 --- a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts +++ b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketConnectionManager.spec.ts @@ -109,6 +109,20 @@ describe("CodexWebSocketConnectionManager state transitions", () => { await expect(acquired).resolves.toBe(scope.socketRemoteDataSource.socket) }) + it("replaces a socket that is no longer open even before its close callback runs", async () => { + const acquired = manager.acquire(options()) + const first = lastScope() + first.open() + await acquired + first.socketRemoteDataSource.socket.readyState = WebSocket.CLOSING + const next = manager.acquire(options()) + const current = lastScope() + current.open() + await expect(next).resolves.toBe(current.socketRemoteDataSource.socket) + expect(first.dispose).toHaveBeenCalledOnce() + expect(scopes).toHaveLength(2) + }) + it("preserves upgrade cooldown through cleanup without retaining a socket or timer", async () => { const failed = expect(manager.acquire(options())).rejects.toBeInstanceOf(CodexWebSocketUnavailableError) const scope = lastScope() diff --git a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts index d6c6cf9a23..1311bdfbab 100644 --- a/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts +++ b/src/api/providers/codex-websocket/managers/__tests__/CodexWebSocketRequestManager.spec.ts @@ -21,6 +21,7 @@ describe("CodexWebSocketRequestManager state transitions", () => { let manager: CodexWebSocketRequestManager let connection: CodexWebSocketConnectionManager let socket: WebSocket + let stateHolder: CodexWebSocketRequestStateHolder let onAbort = vi.fn<() => void>() beforeEach(() => { @@ -33,10 +34,11 @@ describe("CodexWebSocketRequestManager state transitions", () => { new CodexWebSocketConnectionStateHolder(), ) vi.spyOn(connection, "acquire").mockResolvedValue(socket) + stateHolder = new CodexWebSocketRequestStateHolder() manager = new CodexWebSocketRequestManager( { headers: {}, signal: new AbortController().signal, timeoutMs: 2_000 }, onAbort, - new CodexWebSocketRequestStateHolder(), + stateHolder, ) }) @@ -61,6 +63,43 @@ describe("CodexWebSocketRequestManager state transitions", () => { expect(vi.getTimerCount()).toBe(0) }) + it("reads the caller signal and clears deadlines safely before initialization and after disposal", async () => { + expect(manager.signal).toBeInstanceOf(AbortSignal) + expect(manager.signal.aborted).toBe(false) + manager.clearTimeout() + await manager.init(connection) + await manager.dispose() + manager.clearTimeout() + expect(vi.getTimerCount()).toBe(0) + }) + + it("rejects repeated initialization without disturbing an active request", async () => { + await manager.init(connection) + const signal = manager.signal + await expect(manager.init(connection)).rejects.toThrow("already initialized") + expect(manager.signal).toBe(signal) + expect(manager.socket).toBe(socket) + expect(socket.listenerCount("message")).toBe(1) + expect(connection.acquire).toHaveBeenCalledOnce() + }) + + it("rejects overlapping initialization while acquisition is still pending", async () => { + let resolve: ((value: WebSocket) => void) | undefined + vi.mocked(connection.acquire).mockImplementationOnce( + () => + new Promise((complete) => { + resolve = complete + }), + ) + const initialized = manager.init(connection) + await expect(manager.init(connection)).rejects.toThrow("already initialized") + if (!resolve) throw new Error("Acquisition was not started") + resolve(socket) + await initialized + expect(manager.socket).toBe(socket) + expect(connection.acquire).toHaveBeenCalledOnce() + }) + it("does not convert a failed upgrade into cancellation during cleanup", async () => { const error = new CodexWebSocketUnavailableError("Upgrade rejected") vi.mocked(connection.acquire).mockRejectedValueOnce(error) @@ -91,6 +130,18 @@ describe("CodexWebSocketRequestManager state transitions", () => { expect(staleSocket.listenerCount("message")).toBe(0) }) + it("rejects acquisition completing after the injected holder has discarded the attempt", async () => { + const initialized = expect(manager.init(connection)).rejects.toThrow("scope was disposed") + const abandoned = stateHolder.dispose() + await initialized + expect(socket.eventNames()).toEqual([]) + expect(() => manager.socket).toThrow("not initialized") + // Discarding state is not IO cleanup: the owner must release its abandoned listener. + if (abandoned.status !== "initializing") throw new Error("Expected an initializing request") + abandoned.signal.removeEventListener("abort", onAbort) + expect(abandoned.signal.aborted).toBe(false) + }) + it.each(["refresh", "clear", "dispose"])( "ignores a stale deadline callback after %s", async (operation: string) => { diff --git a/src/api/providers/codex-websocket/models/__tests__/CodexWebSocketItemSnapshotModel.spec.ts b/src/api/providers/codex-websocket/models/__tests__/CodexWebSocketItemSnapshotModel.spec.ts new file mode 100644 index 0000000000..24b15e28de --- /dev/null +++ b/src/api/providers/codex-websocket/models/__tests__/CodexWebSocketItemSnapshotModel.spec.ts @@ -0,0 +1,21 @@ +import { CodexWebSocketItemSnapshotModel } from "../CodexWebSocketItemSnapshotModel" + +describe("CodexWebSocketItemSnapshotModel", () => { + const call = (args: unknown) => ({ type: "function_call", call_id: "call_1", name: "read_file", arguments: args }) + + it("compares structured function arguments without requiring JSON strings", () => { + const structured = CodexWebSocketItemSnapshotModel.create(call({ path: "file.ts", start: 1 })) + const serialized = CodexWebSocketItemSnapshotModel.create(call('{"start":1,"path":"file.ts"}')) + expect(structured.hash).toBe(serialized.hash) + expect(structured.changedFields(serialized)).toEqual([]) + }) + + it("preserves malformed function arguments for exact comparison", () => { + const first = CodexWebSocketItemSnapshotModel.create(call("invalid JSON")) + const same = CodexWebSocketItemSnapshotModel.create(call("invalid JSON")) + const changed = CodexWebSocketItemSnapshotModel.create(call("different invalid JSON")) + expect(first.hash).toBe(same.hash) + expect(first.hash).not.toBe(changed.hash) + expect(first.changedFields(changed)).toEqual(["arguments"]) + }) +}) diff --git a/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts b/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts index 59fdca6b45..8bb9ca7b4f 100644 --- a/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts +++ b/src/api/providers/codex-websocket/repositories/__tests__/CodexWebSocketContinuationRepository.spec.ts @@ -79,4 +79,13 @@ describe("CodexWebSocketContinuationRepository", () => { expect(store.read()).toBeUndefined() expect(repository.prepare(body()).previousResponseId).toBeUndefined() }) + + it("requires full context when the reconstructed history is shortened", () => { + const input = [...body().input, { role: "user", content: "Follow-up" }] + repository.record(repository.prepare(body(input)), { id: "resp_1", output: [] }, []) + expect(repository.prepare(body())).toMatchObject({ + previousResponseId: "resp_1", + fullContextReason: "history shortened", + }) + }) }) diff --git a/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts b/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts index 2dd5a677f0..1320d283d4 100644 --- a/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts +++ b/src/api/providers/codex-websocket/scopes/__tests__/CodexWebSocketScopes.spec.ts @@ -172,6 +172,66 @@ describe("Codex WebSocket scopes", () => { expectNoListeners(socket) }) + it("guards direct socket access and initialization without duplicating handlers", async () => { + const source = own(new CodexWebSocketSocketRemoteDataSource("ws://test/responses", vi.fn(), vi.fn())) + expect(() => source.socket).toThrow("not initialized") + source.dispose() + const initialized = source.init(options) + const socket = lastSocket() + socket.emit("open") + await initialized + expect(source.socket).toBe(socket) + await expect(source.init(options)).rejects.toThrow("already initialized") + expect(socket.listenerCount("error")).toBe(1) + expect(socket.listenerCount("close")).toBe(1) + }) + + it("ignores stale socket-close dispatch after a replacement has initialized", async () => { + const onClose = vi.fn() + const source = own(new CodexWebSocketSocketRemoteDataSource("ws://test/responses", vi.fn(), onClose)) + const firstInit = source.init(options) + const first = lastSocket() + first.emit("open") + await firstInit + const staleClose = first.listeners("close")[0] + if (!staleClose) throw new Error("No socket-close handler was registered") + source.dispose() + const nextInit = source.init(options) + const next = lastSocket() + next.emit("open") + await nextInit + staleClose() + expect(onClose).not.toHaveBeenCalled() + expect(source.socket).toBe(next) + }) + + it("preserves a replacement socket when a previous handshake finishes after disposal", async () => { + const source = own(new CodexWebSocketSocketRemoteDataSource("ws://test/responses", vi.fn(), vi.fn())) + const firstInit = expect(source.init(options)).rejects.toThrow("scope was disposed") + const first = lastSocket() + first.emit("open") + source.dispose() + const nextInit = source.init(options) + const next = lastSocket() + next.emit("open") + await firstInit + await nextInit + expect(source.socket).toBe(next) + expectNoListeners(first) + }) + + it("does not let late service initialization dispose a replacement connection scope", async () => { + vi.spyOn(CodexWebSocketSocketRemoteDataSource.prototype, "init").mockResolvedValue() + const scope = own(new CodexWebSocketConnectionScope("ws://test/responses", vi.fn(), vi.fn())) + const firstInit = expect(scope.init(options)).rejects.toThrow("connection scope was disposed") + scope.dispose() + await scope.init(options) + const replacement = scope.socketRemoteDataSource + await firstInit + expect(scope.socketRemoteDataSource).toBe(replacement) + expect(sockets).toHaveLength(0) + }) + it("safely disposes an in-flight upgrade without retaining listeners", async () => { const onError = vi.fn() const onClose = vi.fn() diff --git a/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts b/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts index dfb3bd528e..c4b8c7a66e 100644 --- a/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts +++ b/src/api/providers/codex-websocket/state-holders/__tests__/CodexWebSocketStateHolders.spec.ts @@ -62,6 +62,14 @@ describe("Codex WebSocket state holders", () => { expect(holder.state).toBe(current) }) + it("rejects overlapping connection state and cooldown publication while connecting", () => { + const holder = new CodexWebSocketConnectionStateHolder() + const attempt = holder.beginConnection("key", scope()) + expect(() => holder.beginConnection("other", scope())).toThrow("already active") + expect(() => holder.markUnavailable("key", 60_000)).toThrow("must be disconnected") + expect(holder.state).toBe(attempt) + }) + it("preserves credential cooldown without retaining connection resources", () => { const holder = new CodexWebSocketConnectionStateHolder() holder.markUnavailable("key", 60_000) @@ -83,6 +91,21 @@ describe("Codex WebSocket state holders", () => { expect(vi.getTimerCount()).toBe(0) }) + it("rejects repeated initialization and activation from an obsolete request snapshot", () => { + const holder = new CodexWebSocketRequestStateHolder() + const controller = new AbortController() + const first = holder.beginInitialization(controller, controller.signal) + expect(() => holder.beginInitialization(controller, controller.signal)).toThrow("already initialized") + holder.dispose() + const current = holder.beginInitialization(controller, controller.signal) + const socket = new WebSocket("ws://test") + const events = asyncStreamFrom([]) + expect(() => holder.activate(first, socket, events)).toThrow("scope was disposed") + expect(holder.state).toBe(current) + holder.activate(current, socket, events) + expect(() => holder.beginInitialization(controller, controller.signal)).toThrow("already initialized") + }) + it("replaces request deadline snapshots without clearing timers itself", () => { const holder = new CodexWebSocketRequestStateHolder() const controller = new AbortController() @@ -114,6 +137,14 @@ describe("Codex WebSocket state holders", () => { expect(() => holder.appendOutputItem("late")).toThrow("already completed") }) + it("rejects repeated response initialization without replacing the prepared request", () => { + const holder = new CodexWebSocketResponseStateHolder() + holder.initialize(prepared()) + const initial = holder.state + expect(() => holder.initialize(prepared())).toThrow("already initialized") + expect(holder.state).toBe(initial) + }) + it("permits recovery only in the initial awaiting state", () => { const holder = new CodexWebSocketResponseStateHolder() holder.initialize({ ...prepared(), fullContextReason: undefined, previousResponseId: "resp_1" }) diff --git a/src/api/providers/codex-websocket/utils/__tests__/protocol.spec.ts b/src/api/providers/codex-websocket/utils/__tests__/protocol.spec.ts new file mode 100644 index 0000000000..e88696bd99 --- /dev/null +++ b/src/api/providers/codex-websocket/utils/__tests__/protocol.spec.ts @@ -0,0 +1,12 @@ +import { fingerprint } from "../protocol" + +describe("Codex WebSocket fingerprints", () => { + it("uses the null fingerprint when the input cannot be serialized", () => { + expect(fingerprint(undefined)).toBe(fingerprint(null)) + }) + + it("ignores object key ordering while distinguishing changed values", () => { + expect(fingerprint({ model: "test", stream: true })).toBe(fingerprint({ stream: true, model: "test" })) + expect(fingerprint({ model: "test" })).not.toBe(fingerprint({ model: "changed" })) + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 71cd18cecb..9484e259cc 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Limitar el nombre màxim de tokens en la resposta", "maxOutputTokensLabel": "Tokens màxims de sortida", "maxTokensGenerateDescription": "Tokens màxims a generar en la resposta", + "openAiCodexWebSocket": { + "label": "Utilitza WebSocket", + "description": "Manté oberta la connexió amb ChatGPT entre peticions. Utilitza HTTP si no es pot establir una connexió WebSocket." + }, "openAiCodexSpeed": { "label": "Velocitat", "tooltip": "El mode ràpid utilitza el processament prioritari de Codex per oferir aproximadament 1,5 vegades més velocitat i consumeix més quota de subscripció.", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 4403a19dbb..8cc033910e 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Begrenze die maximale Anzahl von Tokens in der Antwort", "maxOutputTokensLabel": "Maximale Ausgabe-Tokens", "maxTokensGenerateDescription": "Maximale Tokens, die in der Antwort generiert werden", + "openAiCodexWebSocket": { + "label": "WebSocket verwenden", + "description": "Hält die Verbindung zu ChatGPT zwischen Anfragen offen. Verwendet HTTP, wenn keine WebSocket-Verbindung hergestellt werden kann." + }, "openAiCodexSpeed": { "label": "Geschwindigkeit", "tooltip": "Der schnelle Modus nutzt die priorisierte Codex-Verarbeitung für etwa 1,5-fache Geschwindigkeit und verbraucht mehr von deinem Abonnementkontingent.", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 22788f89f9..f952d0be41 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Limitar el número máximo de tokens en la respuesta", "maxOutputTokensLabel": "Tokens máximos de salida", "maxTokensGenerateDescription": "Tokens máximos a generar en la respuesta", + "openAiCodexWebSocket": { + "label": "Usar WebSocket", + "description": "Mantiene abierta la conexión con ChatGPT entre solicitudes. Usa HTTP si no se puede establecer una conexión WebSocket." + }, "openAiCodexSpeed": { "label": "Velocidad", "tooltip": "El modo rápido usa el procesamiento prioritario de Codex para ofrecer aproximadamente 1,5 veces más velocidad y consume más cuota de suscripción.", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index f57562ebf2..f633874c0b 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Limiter le nombre maximum de tokens dans la réponse", "maxOutputTokensLabel": "Tokens de sortie maximum", "maxTokensGenerateDescription": "Tokens maximum à générer dans la réponse", + "openAiCodexWebSocket": { + "label": "Utiliser WebSocket", + "description": "Garde la connexion à ChatGPT ouverte entre les requêtes. Utilise HTTP si une connexion WebSocket ne peut pas être établie." + }, "openAiCodexSpeed": { "label": "Vitesse", "tooltip": "Le mode rapide utilise le traitement prioritaire de Codex pour une vitesse environ 1,5 fois supérieure et consomme davantage de quota d'abonnement.", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 52a29ff287..4e8b5ada5b 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "प्रतिक्रिया में टोकन की अधिकतम संख्या सीमित करें", "maxOutputTokensLabel": "अधिकतम आउटपुट टोकन", "maxTokensGenerateDescription": "प्रतिक्रिया में उत्पन्न करने के लिए अधिकतम टोकन", + "openAiCodexWebSocket": { + "label": "WebSocket का उपयोग करो", + "description": "अनुरोधों के बीच ChatGPT का कनेक्शन खुला रखता है। WebSocket कनेक्शन स्थापित न हो पाने पर HTTP का उपयोग करता है।" + }, "openAiCodexSpeed": { "label": "गति", "tooltip": "तेज़ मोड लगभग 1.5 गुना गति के लिए Codex की प्राथमिकता प्रोसेसिंग का उपयोग करता है और अधिक सदस्यता कोटा खर्च करता है।", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index e3ae8c18fc..1c4e8279f2 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Batasi jumlah maksimum token dalam respons", "maxOutputTokensLabel": "Token output maksimum", "maxTokensGenerateDescription": "Token maksimum untuk dihasilkan dalam respons", + "openAiCodexWebSocket": { + "label": "Gunakan WebSocket", + "description": "Menjaga koneksi ChatGPT tetap terbuka di antara permintaan. Menggunakan HTTP jika koneksi WebSocket tidak dapat dibuat." + }, "openAiCodexSpeed": { "label": "Kecepatan", "tooltip": "Mode Cepat menggunakan pemrosesan prioritas Codex untuk kecepatan sekitar 1,5x dan memakai lebih banyak kuota langganan.", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index c6ddf94a03..72463c3812 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Limita il numero massimo di token nella risposta", "maxOutputTokensLabel": "Token di output massimi", "maxTokensGenerateDescription": "Token massimi da generare nella risposta", + "openAiCodexWebSocket": { + "label": "Usa WebSocket", + "description": "Mantiene aperta la connessione a ChatGPT tra le richieste. Usa HTTP se non è possibile stabilire una connessione WebSocket." + }, "openAiCodexSpeed": { "label": "Velocità", "tooltip": "La modalità veloce usa l'elaborazione prioritaria di Codex per una velocità circa 1,5 volte superiore e consuma più quota dell'abbonamento.", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index e07b01c510..69e796ac7f 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "レスポンスの最大トークン数を制限する", "maxOutputTokensLabel": "最大出力トークン", "maxTokensGenerateDescription": "レスポンスで生成する最大トークン数", + "openAiCodexWebSocket": { + "label": "WebSocket を使用", + "description": "リクエスト間で ChatGPT への接続を維持する。WebSocket 接続を確立できない場合は HTTP を使用する。" + }, "openAiCodexSpeed": { "label": "速度", "tooltip": "高速モードは Codex の優先処理を使用して約1.5倍の速度を実現し、サブスクリプションの利用枠をより多く消費します。", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 49efb31701..5955e24983 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "응답에서 최대 토큰 수 제한", "maxOutputTokensLabel": "최대 출력 토큰", "maxTokensGenerateDescription": "응답에서 생성할 최대 토큰 수", + "openAiCodexWebSocket": { + "label": "WebSocket 사용", + "description": "요청 사이에도 ChatGPT 연결을 유지해요. WebSocket 연결을 설정할 수 없으면 HTTP를 사용해요." + }, "openAiCodexSpeed": { "label": "속도", "tooltip": "빠른 모드는 Codex 우선 처리를 사용하여 약 1.5배 빠른 속도를 제공하며 구독 할당량을 더 많이 사용합니다.", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 54e76a1a00..97c68a2b12 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Beperk het maximale aantal tokens in het antwoord", "maxOutputTokensLabel": "Maximale output tokens", "maxTokensGenerateDescription": "Maximale tokens om te genereren in het antwoord", + "openAiCodexWebSocket": { + "label": "WebSocket gebruiken", + "description": "Houdt de verbinding met ChatGPT open tussen verzoeken. Gebruikt HTTP als er geen WebSocket-verbinding kan worden gemaakt." + }, "openAiCodexSpeed": { "label": "Snelheid", "tooltip": "De snelle modus gebruikt prioriteitsverwerking van Codex voor ongeveer 1,5 keer hogere snelheid en verbruikt meer abonnementquotum.", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 411362c9b6..d9112df467 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Ogranicz maksymalną liczbę tokenów w odpowiedzi", "maxOutputTokensLabel": "Maksymalne tokeny wyjściowe", "maxTokensGenerateDescription": "Maksymalne tokeny do wygenerowania w odpowiedzi", + "openAiCodexWebSocket": { + "label": "Używaj WebSocket", + "description": "Utrzymuje połączenie z ChatGPT między żądaniami. Używa HTTP, jeśli nie można nawiązać połączenia WebSocket." + }, "openAiCodexSpeed": { "label": "Szybkość", "tooltip": "Tryb szybki korzysta z priorytetowego przetwarzania Codex, zapewniając około 1,5 raza większą szybkość i zużywając więcej limitu subskrypcji.", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index a65d642a68..03e195324e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Limitar o número máximo de tokens na resposta", "maxOutputTokensLabel": "Tokens máximos de saída", "maxTokensGenerateDescription": "Tokens máximos para gerar na resposta", + "openAiCodexWebSocket": { + "label": "Usar WebSocket", + "description": "Mantém a conexão com o ChatGPT aberta entre as solicitações. Usa HTTP se não for possível estabelecer uma conexão WebSocket." + }, "openAiCodexSpeed": { "label": "Velocidade", "tooltip": "O modo rápido usa o processamento prioritário do Codex para oferecer cerca de 1,5 vez mais velocidade e consome mais cota da assinatura.", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index a3bbceb764..2eda61c38c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Yanıttaki maksimum token sayısını sınırla", "maxOutputTokensLabel": "Maksimum çıktı tokenları", "maxTokensGenerateDescription": "Yanıtta oluşturulacak maksimum token sayısı", + "openAiCodexWebSocket": { + "label": "WebSocket kullan", + "description": "İstekler arasında ChatGPT bağlantısını açık tutar. WebSocket bağlantısı kurulamazsa HTTP kullanır." + }, "openAiCodexSpeed": { "label": "Hız", "tooltip": "Hızlı mod, yaklaşık 1,5 kat hız için Codex öncelikli işlemeyi kullanır ve daha fazla abonelik kotası tüketir.", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4f25fcceb6..cc30749afb 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "Giới hạn số lượng token tối đa trong phản hồi", "maxOutputTokensLabel": "Token đầu ra tối đa", "maxTokensGenerateDescription": "Token tối đa để tạo trong phản hồi", + "openAiCodexWebSocket": { + "label": "Dùng WebSocket", + "description": "Giữ kết nối với ChatGPT mở giữa các yêu cầu. Dùng HTTP nếu không thể thiết lập kết nối WebSocket." + }, "openAiCodexSpeed": { "label": "Tốc độ", "tooltip": "Chế độ Nhanh sử dụng xử lý ưu tiên của Codex để đạt tốc độ nhanh hơn khoảng 1,5 lần và tiêu tốn nhiều hạn mức đăng ký hơn.", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index fee64e4f54..2a6bf76b54 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -1099,6 +1099,10 @@ "limitMaxTokensDescription": "限制响应中的最大 Token 数量", "maxOutputTokensLabel": "最大输出 Token 数", "maxTokensGenerateDescription": "响应中生成的最大 Token 数", + "openAiCodexWebSocket": { + "label": "使用 WebSocket", + "description": "在请求之间保持与 ChatGPT 的连接。如果无法建立 WebSocket 连接,则使用 HTTP。" + }, "openAiCodexSpeed": { "label": "速度", "tooltip": "快速模式使用 Codex 优先处理,速度约为 1.5 倍,但会消耗更多订阅配额。", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index eefd6012cf..ee914e6a5f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1126,6 +1126,10 @@ "limitMaxTokensDescription": "限制回應中的最大 Token 數量", "maxOutputTokensLabel": "最大輸出 Token 數", "maxTokensGenerateDescription": "回應中產生的最大 Token 數", + "openAiCodexWebSocket": { + "label": "使用 WebSocket", + "description": "在請求之間保持與 ChatGPT 的連線。如果無法建立 WebSocket 連線,則使用 HTTP。" + }, "openAiCodexSpeed": { "label": "速度", "tooltip": "快速模式使用 Codex 優先處理,速度約為 1.5 倍,但會消耗更多訂閱配額。", From 22f024b8318935c0bad47539d9345039eb1f5f8b Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 21:24:52 +0300 Subject: [PATCH 08/10] chore(debug): remove local launch configuration from PR --- .vscode/launch.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6a9174944c..902b251dae 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,11 +10,7 @@ "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", - "args": [ - "/Users/gubin-dev/arcadia/flutter/pro/yxpro/yxpro_platform", - "--extensionDevelopmentPath=${workspaceFolder}/src", - "--disable-extensions" - ], + "args": ["--extensionDevelopmentPath=${workspaceFolder}/src", "--disable-extensions"], "sourceMaps": true, "outFiles": ["${workspaceFolder}/src/dist/**/*.js"], "preLaunchTask": "${defaultBuildTask}", From 53504f926741e860256630100ffe20c6e7a87974 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Thu, 8 Oct 2026 21:37:33 +0300 Subject: [PATCH 09/10] refactor(codex): move webview changes to a separate PR --- webview-ui/playwright/roo-code-types.ts | 1 - webview-ui/playwright/vite.config.ts | 1 - .../SettingsView.unsaved-changes.spec.tsx | 40 ------------------ .../settings/providers/OpenAICodex.tsx | 5 --- .../providers/OpenAICodexWebSocketToggle.tsx | 25 ----------- .../providers/__tests__/OpenAICodex.spec.tsx | 24 ----------- .../__tests__/OpenAICodex.visual.fixture.tsx | 6 --- .../__tests__/OpenAICodex.visual.tsx | 8 +--- ...penai-codex-speed-selector-states-dark.png | Bin 19690 -> 7318 bytes ...enai-codex-speed-selector-states-light.png | Bin 20202 -> 7347 bytes webview-ui/src/i18n/locales/ca/settings.json | 4 -- webview-ui/src/i18n/locales/de/settings.json | 4 -- webview-ui/src/i18n/locales/en/settings.json | 4 -- webview-ui/src/i18n/locales/es/settings.json | 4 -- webview-ui/src/i18n/locales/fr/settings.json | 4 -- webview-ui/src/i18n/locales/hi/settings.json | 4 -- webview-ui/src/i18n/locales/id/settings.json | 4 -- webview-ui/src/i18n/locales/it/settings.json | 4 -- webview-ui/src/i18n/locales/ja/settings.json | 4 -- webview-ui/src/i18n/locales/ko/settings.json | 4 -- webview-ui/src/i18n/locales/nl/settings.json | 4 -- webview-ui/src/i18n/locales/pl/settings.json | 4 -- .../src/i18n/locales/pt-BR/settings.json | 4 -- webview-ui/src/i18n/locales/ru/settings.json | 4 -- webview-ui/src/i18n/locales/tr/settings.json | 4 -- webview-ui/src/i18n/locales/vi/settings.json | 4 -- .../src/i18n/locales/zh-CN/settings.json | 4 -- .../src/i18n/locales/zh-TW/settings.json | 4 -- 28 files changed, 1 insertion(+), 181 deletions(-) delete mode 100644 webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx diff --git a/webview-ui/playwright/roo-code-types.ts b/webview-ui/playwright/roo-code-types.ts index 3748a0b09c..5d0c0bbf15 100644 --- a/webview-ui/playwright/roo-code-types.ts +++ b/webview-ui/playwright/roo-code-types.ts @@ -1,5 +1,4 @@ export { DEFAULT_MODES } from "../../packages/types/src/mode" -export { DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET } from "../../packages/types/src/provider-settings/openai-codex" export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES = false export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED = false diff --git a/webview-ui/playwright/vite.config.ts b/webview-ui/playwright/vite.config.ts index 01a0b4b548..6a25d792f5 100644 --- a/webview-ui/playwright/vite.config.ts +++ b/webview-ui/playwright/vite.config.ts @@ -13,7 +13,6 @@ const rooCodeTypesShimImporters = [ "/webview-ui/src/components/chat/CodeIndexPopover.tsx", "/webview-ui/src/components/chat/ModeSelector.tsx", "/webview-ui/src/components/settings/UISettings.tsx", - "/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx", ] export default defineConfig({ diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 1a02f5750a..575da5a833 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -8,7 +8,6 @@ import React from "react" import SettingsView from "../SettingsView" import { vscode } from "@src/utils/vscode" -import { OpenAICodexWebSocketToggle } from "../providers/OpenAICodexWebSocketToggle" const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => {}) @@ -685,45 +684,6 @@ describe("SettingsView - Unsaved Changes Detection", () => { }) }) - it.each([true, false, undefined])( - "buffers Codex WebSocket edits from %s until Save despite a live refresh", - async (enabled) => { - const configuration: ProviderSettings = { - apiProvider: providerIdentifiers.openaiCodex, - apiModelId: "gpt-5.6-sol", - openAiCodexUseWebSocket: enabled, - } - vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ - ...defaultExtensionState, - apiConfiguration: configuration, - }) - vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( - setApiConfigurationField("openAiCodexUseWebSocket", value)} - /> - )) - const view = renderWithExtensionState(, { queryClient }) - fireEvent.click(await screen.findByRole("checkbox", { name: "settings:openAiCodexWebSocket.label" })) - vi.mocked(useExtensionState, { partial: true }).mockReturnValue({ - ...defaultExtensionState, - apiConfiguration: { ...configuration }, - }) - view.rerender() - const checkbox = screen.getByRole("checkbox", { name: "settings:openAiCodexWebSocket.label" }) - if (enabled) expect(checkbox).not.toBeChecked() - else expect(checkbox).toBeChecked() - expect(configuration.openAiCodexUseWebSocket).toBe(enabled) - expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) - fireEvent.click(screen.getByTestId("save-button")) - expect(postMessage).toHaveBeenCalledWith({ - type: "upsertApiConfiguration", - text: "default", - apiConfiguration: { ...configuration, openAiCodexUseWebSocket: !enabled }, - }) - }, - ) - it("keeps OpenAI-compatible reasoning edits cached until Save despite a live state refresh", async () => { const configuration: ProviderSettings = { apiProvider: providerIdentifiers.openai, diff --git a/webview-ui/src/components/settings/providers/OpenAICodex.tsx b/webview-ui/src/components/settings/providers/OpenAICodex.tsx index 26ed874817..ee2abf0e2b 100644 --- a/webview-ui/src/components/settings/providers/OpenAICodex.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICodex.tsx @@ -14,7 +14,6 @@ import { vscode } from "@src/utils/vscode" import { ModelPicker } from "../ModelPicker" import { OpenAICodexRateLimitDashboard } from "./OpenAICodexRateLimitDashboard" import { OpenAICodexSpeedSelector } from "./OpenAICodexSpeedSelector" -import { OpenAICodexWebSocketToggle } from "./OpenAICodexWebSocketToggle" interface OpenAICodexProps { apiConfiguration: ProviderSettings @@ -78,10 +77,6 @@ export const OpenAICodex: React.FC = ({ value={apiConfiguration[OPEN_AI_CODEX_SERVICE_TIER_KEY]} onValueChange={(value) => setApiConfigurationField(OPEN_AI_CODEX_SERVICE_TIER_KEY, value)} /> - setApiConfigurationField("openAiCodexUseWebSocket", value)} - /> ) } diff --git a/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx b/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx deleted file mode 100644 index 8e985774da..0000000000 --- a/webview-ui/src/components/settings/providers/OpenAICodexWebSocketToggle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Checkbox } from "vscrui" -import { DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET } from "@roo-code/types" - -import { useAppTranslation } from "@src/i18n/TranslationContext" - -interface OpenAICodexWebSocketToggleProps { - value?: boolean - onChange: (enabled: boolean) => void -} - -export function OpenAICodexWebSocketToggle({ value, onChange }: OpenAICodexWebSocketToggleProps) { - const { t } = useAppTranslation() - return ( -

- ) -} diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx index 4a82a1a4c8..d73120ada3 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.spec.tsx @@ -17,7 +17,6 @@ vi.mock("@src/i18n/TranslationContext", () => ({ t: (key: string) => ({ "settings:openAiCodexSpeed.label": "Speed", - "settings:openAiCodexWebSocket.label": "Use WebSocket", "settings:openAiCodexSpeed.tooltip": "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", "settings:openAiCodexSpeed.standard": "Standard", @@ -96,26 +95,3 @@ describe("OpenAICodex speed selector", () => { expect(postMessage).not.toHaveBeenCalled() }) }) - -describe("OpenAICodex WebSocket preference", () => { - it.each([undefined, false, true])( - "renders saved preference %s and edits through the cached settings callback", - (enabled) => { - const setApiConfigurationField = vi.fn() - const configuration: ProviderSettings = { - apiProvider: providerIdentifiers.openaiCodex, - openAiCodexUseWebSocket: enabled, - } - render() - const checkbox = screen.getByRole("checkbox", { name: "Use WebSocket" }) - if (enabled) expect(checkbox).toBeChecked() - else expect(checkbox).not.toBeChecked() - fireEvent.click(checkbox) - expect(setApiConfigurationField).toHaveBeenLastCalledWith("openAiCodexUseWebSocket", !enabled) - expect(configuration.openAiCodexUseWebSocket).toBe(enabled) - expect(vscode.postMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "upsertApiConfiguration" }), - ) - }, - ) -}) diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx index 2030599d3e..2061f00614 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.fixture.tsx @@ -6,13 +6,9 @@ import { OpenAiCodexServiceTier } from "@roo-code/types/model" import { TranslationContext } from "@src/i18n/TranslationContext" import { TooltipProvider } from "@src/components/ui/tooltip" import { OpenAICodexSpeedSelector } from "../OpenAICodexSpeedSelector" -import { OpenAICodexWebSocketToggle } from "../OpenAICodexWebSocketToggle" const translations: Record = { "settings:common.select": "Select", - "settings:openAiCodexWebSocket.label": "Use WebSocket", - "settings:openAiCodexWebSocket.description": - "Keeps the ChatGPT connection open between requests. Uses HTTP if a WebSocket connection cannot be established.", "settings:openAiCodexSpeed.label": "Speed", "settings:openAiCodexSpeed.tooltip": "Fast uses Codex priority processing for about 1.5x speed and consumes more subscription quota.", @@ -29,9 +25,7 @@ export const OpenAICodexFixture = () => (
{}} /> - {}} /> {}} /> - {}} />
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx index 30d3912264..a0fcafdf93 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICodex.visual.tsx @@ -21,9 +21,7 @@ const themes = [ ] as const for (const theme of themes) { - test(`renders OpenAI Codex speed and WebSocket preferences in the VS Code ${theme.name} theme`, async ({ - mount, - }) => { + test(`renders both OpenAI Codex speeds in the VS Code ${theme.name} theme`, async ({ mount }) => { const component = mountedStory(await mount("openai-codex")) const selectors = component.getByTestId("openai-codex-service-tier") const comboboxes = component.getByRole("combobox", { name: "Speed" }) @@ -31,10 +29,6 @@ for (const theme of themes) { await expect(selectors).toHaveCount(2) await expect(comboboxes).toHaveCount(2) - const checkboxes = component.getByRole("checkbox", { name: "Use WebSocket" }) - await expect(checkboxes).toHaveCount(2) - await expect(checkboxes.first()).not.toBeChecked() - await expect(checkboxes.last()).toBeChecked() await selector.evaluate((element, { bodyClass, themeId }) => { const { document } = element.ownerDocument.defaultView! diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-codex-speed-selector-states-dark.png index 1c03f9c4275f234cab35ab0ca53722b069e6d7e9..a168e8656ee47ef0fa9b624d78520e78d348ffdf 100644 GIT binary patch literal 7318 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

7$w|w*3W*Les`@{}3D+@}u)nVz~MH5r{bp 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 literal 20202 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%0A6F}DCP+>YQTz+@Jo9|zPS+x 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 Date: Thu, 8 Oct 2026 21:50:49 +0300 Subject: [PATCH 10/10] refactor(codex): separate reasoning fix and provider activation from transport --- .../src/__tests__/provider-settings.test.ts | 20 ---- packages/types/src/provider-settings.ts | 1 - packages/types/src/provider-settings/index.ts | 2 +- .../src/provider-settings/openai-codex.ts | 5 - .../providers/__tests__/openai-codex.spec.ts | 107 ------------------ src/api/providers/openai-codex.ts | 39 ++----- .../__tests__/ProviderSettingsManager.spec.ts | 35 ------ .../__tests__/apiConversationHistory.spec.ts | 35 ------ src/core/task/apiConversationHistory.ts | 7 -- .../webview/__tests__/ClineProvider.spec.ts | 14 --- src/eslint-suppressions.json | 2 +- 11 files changed, 9 insertions(+), 258 deletions(-) diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index cb14b21275..c5a79002a4 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -138,26 +138,6 @@ describe("OpenAI Codex provider settings", () => { }) }) -describe("OpenAI Codex WebSocket preference", () => { - it.each([true, false, undefined])("round-trips %s through both provider schemas", (enabled) => { - const settings = { - apiProvider: providerIdentifiers.openaiCodex, - ...(enabled !== undefined ? { openAiCodexUseWebSocket: enabled } : {}), - } - expect(providerSettingsSchema.parse(settings)).toEqual(settings) - expect(providerSettingsSchemaDiscriminated.parse(JSON.parse(JSON.stringify(settings)))).toEqual(settings) - expect(PROVIDER_SETTINGS_KEYS).toContain("openAiCodexUseWebSocket") - }) - it.each(["true", 1, null])("rejects non-boolean value %s", (enabled) => { - expect( - providerSettingsSchema.safeParse({ - apiProvider: providerIdentifiers.openaiCodex, - openAiCodexUseWebSocket: enabled, - }).success, - ).toBe(false) - }) -}) - describe("getApiProtocol", () => { it("preserves API protocol wire values", () => { expect(ANTHROPIC_API_PROTOCOL).toBe("anthropic") diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0aeb4888dd..b243971cc3 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -5,7 +5,6 @@ import { API_PROVIDER_FIELD, SETTINGS_SHAPE_FIELD } from "./provider-settings/co export { DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS, OPEN_AI_CODEX_SERVICE_TIER_KEY, - DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET, parseOpenAiExtraBody, kimiCodeAuthMethodSchema, type KimiCodeAuthMethod, diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 814c367c45..5cdadb4b2c 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -36,7 +36,7 @@ import { basetenProviderDefinition } from "./baseten.js" import type { ProviderDefinition } from "./common.js" -export { OPEN_AI_CODEX_SERVICE_TIER_KEY, DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET } from "./openai-codex.js" +export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js" export { parseOpenAiExtraBody, DEFAULT_OPEN_AI_STRICT_TOOL_SCHEMAS } from "./openai.js" export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js" export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js" diff --git a/packages/types/src/provider-settings/openai-codex.ts b/packages/types/src/provider-settings/openai-codex.ts index 2c2edbfd5d..93117224ae 100644 --- a/packages/types/src/provider-settings/openai-codex.ts +++ b/packages/types/src/provider-settings/openai-codex.ts @@ -1,5 +1,3 @@ -import { z } from "zod" - import { providerIdentifiers } from "../provider-identifiers.js" import { openAiCodexServiceTierSchema } from "../model.js" import { @@ -9,8 +7,6 @@ import { createProviderDefinition, } from "./common.js" -export const DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET = false - export const OPEN_AI_CODEX_SERVICE_TIER_KEY = "openAiCodexServiceTier" export const openAiCodexProviderDefinition = createProviderDefinition({ @@ -19,7 +15,6 @@ export const openAiCodexProviderDefinition = createProviderDefinition({ getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), schema: { ...apiModelIdProviderModelShape, - openAiCodexUseWebSocket: z.boolean().optional(), // Codex "Fast" mode maps to the Responses API priority service tier. [OPEN_AI_CODEX_SERVICE_TIER_KEY]: openAiCodexServiceTierSchema.optional(), }, diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 5dd4f96ff8..35c1f5de60 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -11,8 +11,6 @@ vitest.mock("@roo-code/telemetry", () => ({ import { Anthropic } from "@anthropic-ai/sdk" import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, SERVICE_TIER_KEY } from "@roo-code/types" import { OpenAiCodexHandler, transformResponsesLiteBody } from "../openai-codex" -import { CodexWebSocketTransport, CodexWebSocketUnavailableError } from "../CodexWebSocketTransport" -import { CodexWebSocketTransportScope } from "../codex-websocket/scopes/CodexWebSocketTransportScope" import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -30,111 +28,6 @@ function createCompletedStream() { ]) } -describe("OpenAiCodexHandler WebSocket transport", () => { - beforeEach(() => { - vitest.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") - vitest.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") - }) - afterEach(() => { - vitest.restoreAllMocks() - vitest.unstubAllEnvs() - vitest.unstubAllGlobals() - }) - - it("initializes the DI scope on the first request rather than in the provider constructor", async () => { - const init = vitest.spyOn(CodexWebSocketTransportScope.prototype, "init") - vitest.spyOn(CodexWebSocketTransport.prototype, "stream").mockImplementation(() => createCompletedStream()) - const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: true }) - expect(init).not.toHaveBeenCalled() - await collectStream(handler.createMessage("System", [])) - await collectStream(handler.createMessage("System", [])) - expect(init).toHaveBeenCalledOnce() - }) - - it("routes Lite requests through the transport and returns existing usage chunks", async () => { - const stream = vitest - .spyOn(CodexWebSocketTransport.prototype, "stream") - .mockReturnValue(createCompletedStream()) - const reset = vitest.spyOn(CodexWebSocketTransport.prototype, "resetContinuation") - const handler = new OpenAiCodexHandler({ apiModelId: "gpt-6.1-sol", openAiCodexUseWebSocket: true }) - const chunks = await collectStream( - handler.createMessage("System", [], { - taskId: "task-1", - suppressPreviousResponseId: true, - }), - ) - expect(stream).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.arrayContaining([expect.objectContaining({ type: "additional_tools" })]), - }), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer test-token", - "ChatGPT-Account-Id": "acct_test", - session_id: "task-1", - }), - }), - ) - expect(reset).toHaveBeenCalledOnce() - expect(chunks).toContainEqual(expect.objectContaining({ type: "usage", inputTokens: 1, outputTokens: 1 })) - }) - - it.each([undefined, false])("keeps SDK streaming when WebSocket is %s", async (enabled) => { - vitest.stubEnv("ZOO_CODE_CODEX_WEBSOCKET", "1") - const stream = vitest.spyOn(CodexWebSocketTransport.prototype, "stream") - const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: enabled }) - const create = vitest.fn().mockResolvedValue(createCompletedStream()) - Reflect.set(handler, "client", { responses: { create } }) - await collectStream(handler.createMessage("System", [])) - expect(create).toHaveBeenCalledOnce() - expect(stream).not.toHaveBeenCalled() - }) - - it("uses the HTTP fallback for a failed upgrade", async () => { - vitest.spyOn(CodexWebSocketTransport.prototype, "stream").mockImplementation(() => { - throw new CodexWebSocketUnavailableError("Upgrade rejected") - }) - const fetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}}\n\n', - ), - ) - controller.close() - }, - }), - }) - vitest.stubGlobal("fetch", fetch) - await collectStream( - new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: true }).createMessage( - "System", - [], - ), - ) - expect(fetch).toHaveBeenCalledOnce() - }) - - it("does not replay an ambiguously accepted request over HTTP", async () => { - vitest.spyOn(CodexWebSocketTransport.prototype, "stream").mockImplementation(() => { - throw new Error("Connection closed after send") - }) - const fetch = vitest.fn() - vitest.stubGlobal("fetch", fetch) - await expect( - collectStream( - new OpenAiCodexHandler({ apiModelId: "gpt-5.6-sol", openAiCodexUseWebSocket: true }).createMessage( - "System", - [], - ), - ), - ).rejects.toThrow("Connection closed after send") - expect(fetch).not.toHaveBeenCalled() - }) -}) - describe("OpenAiCodexHandler.getModel", () => { it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini", "gpt-5.3-codex-spark"])( "should return specified model when a valid model id is provided: %s", diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index b5328351ce..ff8f4a24b0 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -6,7 +6,6 @@ import OpenAI from "openai" import { type ModelInfo, OPEN_AI_CODEX_SERVICE_TIER_KEY, - DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET, OpenAiCodexServiceTier, openAiCodexDefaultModelId, OpenAiCodexModelId, @@ -25,8 +24,6 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" -import { type CodexWebSocketTransport, CodexWebSocketUnavailableError } from "./CodexWebSocketTransport" -import { CodexWebSocketTransportScope } from "./codex-websocket/scopes/CodexWebSocketTransportScope" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { isMcpTool } from "../../utils/mcp-name" import { sanitizeOpenAiCallId } from "../../utils/tool-id" @@ -132,8 +129,6 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion protected options: ApiHandlerOptions private readonly providerName = "OpenAI Codex" private client?: OpenAI - private readonly webSocketScope?: CodexWebSocketTransportScope - private webSocketTransport?: CodexWebSocketTransport // Complete response output array private lastResponseOutput: any[] | undefined // Last top-level response id @@ -186,9 +181,6 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion constructor(options: ApiHandlerOptions) { super() this.options = options - if (options.openAiCodexUseWebSocket ?? DEFAULT_OPEN_AI_CODEX_USE_WEBSOCKET) { - this.webSocketScope = new CodexWebSocketTransportScope() - } // Generate a new session ID for standalone handler usage (fallback) this.sessionId = uuidv7() } @@ -256,11 +248,6 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.sawTextDeltaInCurrentResponse = false this.sawSdkEventInCurrentResponse = false this.streamedToolCallIds.clear() - if (this.webSocketScope && !this.webSocketTransport) { - this.webSocketScope.init() - this.webSocketTransport = this.webSocketScope.transport - } - if (metadata?.suppressPreviousResponseId) this.webSocketTransport?.resetContinuation() // Get access token from OAuth manager let accessToken = await openAiCodexOAuthManager.getAccessToken() @@ -504,20 +491,13 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion timeout: this.timeoutMs, }) - const sdkRequest: OpenAI.Responses.ResponseCreateParamsStreaming = requestBody - const stream = this.webSocketTransport - ? this.webSocketTransport.stream(requestBody, { - headers: { ...codexHeaders, Authorization: `Bearer ${accessToken}` }, - signal: this.abortController.signal, - timeoutMs: this.timeoutMs, - }) - : await client.responses.create(sdkRequest, { - signal: this.abortController.signal, - // If the SDK supports per-request overrides, ensure headers are present. - headers: codexHeaders, - }) - - if (typeof stream?.[Symbol.asyncIterator] !== "function") { + const stream = (await (client as any).responses.create(requestBody, { + signal: this.abortController.signal, + // If the SDK supports per-request overrides, ensure headers are present. + headers: codexHeaders, + })) as AsyncIterable + + if (typeof (stream as any)?.[Symbol.asyncIterator] !== "function") { throw new Error( "OpenAI SDK did not return an AsyncIterable for Responses API streaming. Falling back to SSE.", ) @@ -550,11 +530,6 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (this.sawSdkEventInCurrentResponse || this.abortController?.signal.aborted) { throw sdkErr } - // A sent WebSocket request may be accepted even before its first event arrives. - // Only a failed HTTP upgrade is safe to replay through the existing fallback. - if (this.webSocketTransport && !(sdkErr instanceof CodexWebSocketUnavailableError)) { - throw sdkErr - } // Fallback to manual SSE via fetch (Codex backend). yield* this.makeCodexRequest(requestBody, model, accessToken, effectiveSessionId) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 297d9fd877..13e1aeeb2d 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -459,41 +459,6 @@ describe("ProviderSettingsManager", () => { expect(storedConfig).toEqual(expectedConfig) }) - it.each([true, false, undefined])( - "round-trips Codex WebSocket preference %s in a saved profile", - async (enabled) => { - mockSecrets.get.mockResolvedValue( - JSON.stringify({ - currentApiConfigName: "default", - apiConfigs: { default: {} }, - modeApiConfigs: {}, - }), - ) - const configuration = { - apiProvider: providerIdentifiers.openaiCodex, - apiModelId: "gpt-5.6-sol", - ...(enabled !== undefined ? { openAiCodexUseWebSocket: enabled } : {}), - } - await providerSettingsManager.saveConfig("codex", configuration) - const stored = mockSecrets.store.mock.calls.at(-1)?.[1] - if (typeof stored !== "string") throw new Error("Profile was not stored") - mockSecrets.get.mockResolvedValue(stored) - expect(await providerSettingsManager.getProfile({ name: "codex" })).toMatchObject(configuration) - expect((await providerSettingsManager.getProfile({ name: "codex" })).openAiCodexUseWebSocket).toBe( - enabled, - ) - const exported = await providerSettingsManager.export() - expect(exported.apiConfigs.codex.openAiCodexUseWebSocket).toBe(enabled) - await providerSettingsManager.import(exported) - const imported = mockSecrets.store.mock.calls.at(-1)?.[1] - if (typeof imported !== "string") throw new Error("Profile was not imported") - mockSecrets.get.mockResolvedValue(imported) - expect((await providerSettingsManager.getProfile({ name: "codex" })).openAiCodexUseWebSocket).toBe( - enabled, - ) - }, - ) - it.each([OpenAiCodexServiceTier.Default, OpenAiCodexServiceTier.Priority] as const)( "should persist the OpenAI Codex %s speed preference", async (openAiCodexServiceTier) => { diff --git a/src/core/task/__tests__/apiConversationHistory.spec.ts b/src/core/task/__tests__/apiConversationHistory.spec.ts index bacb3c03d0..7313e4fa1c 100644 --- a/src/core/task/__tests__/apiConversationHistory.spec.ts +++ b/src/core/task/__tests__/apiConversationHistory.spec.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { prepareApiConversationMessage } from "../apiConversationHistory.js" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" -import type { ApiHandler } from "../../../api" describe("prepareApiConversationMessage", () => { beforeEach(() => { @@ -84,40 +83,6 @@ describe("prepareApiConversationMessage", () => { ]) }) - it.each(["reasoning-1", undefined])("preserves encrypted reasoning alongside its visible text (id: %s)", (id) => { - const api = { - createMessage: vi.fn(), - getModel: vi.fn(), - countTokens: vi.fn(), - getEncryptedContent: () => ({ encrypted_content: "encrypted", ...(id ? { id } : {}) }), - } - const result = prepareApiConversationMessage({ - message: { - role: "assistant", - content: [ - { type: "text", text: "Reading files" }, - { type: "tool_use", id: "call-1", name: "read_file", input: { path: "a.ts" } }, - ], - }, - reasoning: "visible reasoning", - api, - apiConfiguration: { apiProvider: providerIdentifiers.openaiCodex }, - apiConversationHistory: [], - }) - - expect(result.content).toEqual([ - { - type: "reasoning", - text: "visible reasoning", - summary: [], - encrypted_content: "encrypted", - ...(id ? { id } : {}), - }, - { type: "text", text: "Reading files" }, - { type: "tool_use", id: "call-1", name: "read_file", input: { path: "a.ts" } }, - ]) - }) - it("appends thought signatures for non-Anthropic protocols", () => { const result = prepareApiConversationMessage({ message: { role: "assistant", content: "answer" }, diff --git a/src/core/task/apiConversationHistory.ts b/src/core/task/apiConversationHistory.ts index c75fb4f1c5..d1e609c8dc 100644 --- a/src/core/task/apiConversationHistory.ts +++ b/src/core/task/apiConversationHistory.ts @@ -82,13 +82,6 @@ function prepareAssistantMessage( type: "reasoning", text: reasoning, summary: [] as any[], - // A visible summary must not discard the encrypted state needed for continuation. - ...(reasoningData?.encrypted_content - ? { - encrypted_content: reasoningData.encrypted_content, - ...(reasoningData.id ? { id: reasoningData.id } : {}), - } - : {}), } prependContentBlock(messageWithTs, reasoningBlock) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index f9f1ca6eff..0e4aa4aa18 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1945,20 +1945,6 @@ describe("ClineProvider", () => { expect(postedState.apiConfiguration).toMatchObject(expectedConfiguration) }) - test.each([true, false, undefined])( - "returns saved Codex WebSocket preference %s through the webview round trip", - async (enabled) => { - const configuration: ProviderSettings = { - apiProvider: providerIdentifiers.openaiCodex, - ...(enabled !== undefined ? { openAiCodexUseWebSocket: enabled } : {}), - } - await provider.contextProxy.setProviderSettings(configuration) - expect(provider.contextProxy.getProviderSettings().openAiCodexUseWebSocket).toBe(enabled) - expect((await provider.getState()).apiConfiguration.openAiCodexUseWebSocket).toBe(enabled) - expect((await provider.getStateToPostToWebview()).apiConfiguration.openAiCodexUseWebSocket).toBe(enabled) - }, - ) - test.each([true, false, undefined])( "returns saved OpenAI-compatible reasoning settings to the webview when enabled is %s", async (enableReasoningEffort) => { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index fb3369f638..908159f7ab 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -391,7 +391,7 @@ }, "api/providers/openai-codex.ts": { "@typescript-eslint/no-explicit-any": { - "count": 28 + "count": 31 } }, "api/providers/openai-native.ts": {