diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b80d46bc0..c114b273728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Discord/voice: include a bounded one-line STT transcript preview in verbose voice logs so live voice debugging shows what speakers said before the agent reply. - Codex app-server: pin the managed Codex harness and Codex CLI smoke package to `@openai/codex@0.129.0`, defer OpenClaw integration dynamic tools behind Codex tool search by default, and accept current Codex service-tier values so legacy `fast` settings survive the stable harness upgrade as `priority`. - Codex app-server: annotate message-tool-only direct chat turns in the dynamic `message` tool spec so visible replies are sent through `message(action="send")` instead of staying private. (#79704) +- Agents/PI: route explicit OpenAI Codex Responses runs through PI's native WebSocket-capable transport and remove OpenClaw's custom OpenAI Responses WebSocket stack while preserving auth injection, run abort signals, and prompt cache boundary stripping. - Codex app-server: default implicit local stdio app-server permissions to guardian when Codex system requirements disallow the YOLO approval, reviewer, or sandbox value, including hostname-scoped remote sandbox entries, avoiding turn-start failures on managed hosts that permit only reviewed approval or narrower sandboxes. - Plugins/install: run managed npm-root install, uninstall, prune, and repair commands from the managed root without a redundant `--prefix .`, avoiding npm 10.9.3 Arborist crashes on native Windows WhatsApp plugin installs. Fixes #78514. (#78902) Thanks @melihselamett-stack. - Discord/voice: stream ElevenLabs TTS directly into Discord playback and send ElevenLabs latency optimization as the documented query parameter so spoken replies can start sooner. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 7541f117d2b..c01bdbbb58a 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -92,9 +92,8 @@ OpenClaw ships with the pi-ai catalog. These providers require **no** `models.pr - Example models: `openai/gpt-5.5`, `openai/gpt-5.4-mini` - Verify account/model availability with `openclaw models list --provider openai` if a specific install or API key behaves differently. - CLI: `openclaw onboard --auth-choice openai-api-key` -- Default transport is `auto` (WebSocket-first, SSE fallback) +- Default transport is `auto`; OpenClaw passes the transport choice to pi-ai. - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) -- OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) - OpenAI priority processing can be enabled via `agents.defaults.models["openai/"].params.serviceTier` - `/fast` and `params.fastMode` map direct `openai/*` Responses requests to `service_tier=priority` on `api.openai.com` - Use `params.serviceTier` when you want an explicit tier instead of the shared `/fast` toggle diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 78cd7edf6ee..83370a03817 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -835,26 +835,6 @@ the Server-side compaction accordion below. - - OpenClaw enables WebSocket warm-up by default for `openai/*` to reduce first-turn latency. - - ```json5 - // Disable warm-up - { - agents: { - defaults: { - models: { - "openai/gpt-5.5": { - params: { openaiWsWarmup: false }, - }, - }, - }, - }, - } - ``` - - - OpenClaw exposes a shared fast-mode toggle for `openai/*`: diff --git a/extensions/amazon-bedrock-mantle/package.json b/extensions/amazon-bedrock-mantle/package.json index d87a416a3d0..d8347aada77 100644 --- a/extensions/amazon-bedrock-mantle/package.json +++ b/extensions/amazon-bedrock-mantle/package.json @@ -7,7 +7,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.93.0", "@aws/bedrock-token-generator": "^1.1.0", - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-ai": "0.73.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/anthropic-vertex/package.json b/extensions/anthropic-vertex/package.json index 6f013a15f0d..9550af1e33f 100644 --- a/extensions/anthropic-vertex/package.json +++ b/extensions/anthropic-vertex/package.json @@ -6,8 +6,8 @@ "type": "module", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.16.0", - "@mariozechner/pi-agent-core": "0.73.0", - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-agent-core": "0.73.1", + "@mariozechner/pi-ai": "0.73.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/anthropic/package.json b/extensions/anthropic/package.json index 309373d6f5f..8720348e3b1 100644 --- a/extensions/anthropic/package.json +++ b/extensions/anthropic/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Anthropic provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-ai": "0.73.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/codex/package.json b/extensions/codex/package.json index e4a06f71c3e..3123a1cd402 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -8,7 +8,7 @@ }, "type": "module", "dependencies": { - "@mariozechner/pi-coding-agent": "0.73.0", + "@mariozechner/pi-coding-agent": "0.73.1", "@openai/codex": "0.129.0", "ajv": "^8.20.0", "ws": "^8.20.0" diff --git a/extensions/fireworks/package.json b/extensions/fireworks/package.json index 8d6e71dd0f8..52b17060be2 100644 --- a/extensions/fireworks/package.json +++ b/extensions/fireworks/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Fireworks provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-ai": "0.73.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/github-copilot/package.json b/extensions/github-copilot/package.json index 9ae05d259e2..5c7a18d0a04 100644 --- a/extensions/github-copilot/package.json +++ b/extensions/github-copilot/package.json @@ -8,7 +8,7 @@ "@clack/prompts": "^1.3.0" }, "devDependencies": { - "@mariozechner/pi-ai": "0.73.0", + "@mariozechner/pi-ai": "0.73.1", "@openclaw/plugin-sdk": "workspace:*" }, "openclaw": { diff --git a/extensions/google/package.json b/extensions/google/package.json index e0b32b03864..950322a3bf1 100644 --- a/extensions/google/package.json +++ b/extensions/google/package.json @@ -6,7 +6,7 @@ "type": "module", "dependencies": { "@google/genai": "^1.51.0", - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-ai": "0.73.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index c16529492c1..a41af0938cb 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Kimi provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-ai": "0.73.1" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/lmstudio/package.json b/extensions/lmstudio/package.json index 8e2ed63cef0..6707e4c5be4 100644 --- a/extensions/lmstudio/package.json +++ b/extensions/lmstudio/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw LM Studio provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0" + "@mariozechner/pi-ai": "0.73.1" }, "openclaw": { "extensions": [ diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index dd9b79aa7cd..bcfa9666b04 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw Ollama provider plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0", + "@mariozechner/pi-ai": "0.73.1", "typebox": "1.1.37" }, "devDependencies": { diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index a3fdf919755..b2815a62cff 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -67,9 +67,9 @@ function runWrappedPayloadCase(params: { payload?: Record; }) { const payload = params.payload ?? { store: false }; - let capturedOptions: (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined; + let capturedOptions: SimpleStreamOptions | undefined; const baseStreamFn: StreamFn = (model, _context, options) => { - capturedOptions = options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined; + capturedOptions = options; options?.onPayload?.(payload, model); return {} as ReturnType; }; @@ -576,7 +576,6 @@ describe("buildOpenAIProvider", () => { expect(extraParams).toMatchObject({ transport: "sse", }); - expect(extraParams?.openaiWsWarmup).toBeUndefined(); expect(result.payload.store).toBe(true); expect(result.payload.context_management).toEqual([ { type: "compaction", compact_threshold: 140_000 }, @@ -743,12 +742,11 @@ describe("buildOpenAIProvider", () => { expect(result.payload.tools).toEqual([{ type: "function", name: "web_search" }]); }); - it("preserves explicit OpenAI responses transport and warmup overrides", () => { + it("preserves explicit OpenAI responses transport overrides", () => { const provider = buildOpenAIProvider(); const explicit = { transport: "websocket", - openaiWsWarmup: false, fastMode: true, }; @@ -761,7 +759,7 @@ describe("buildOpenAIProvider", () => { ).toBe(explicit); }); - it("defaults Codex responses transport without forcing warmup flags", () => { + it("defaults Codex responses transport without forcing extra flags", () => { const provider = buildOpenAICodexProviderPlugin(); expect( @@ -777,7 +775,6 @@ describe("buildOpenAIProvider", () => { const explicit = { transport: "sse", - openaiWsWarmup: false, }; expect( provider.prepareExtraParams?.({ @@ -823,7 +820,6 @@ describe("buildOpenAIProvider", () => { }); expect(result.options?.transport).toBeUndefined(); - expect(result.options?.openaiWsWarmup).toBeUndefined(); expect(result.payload.reasoning).toEqual({ effort: "none" }); }); diff --git a/extensions/openai/package.json b/extensions/openai/package.json index 31f28e89370..a28cc9188da 100644 --- a/extensions/openai/package.json +++ b/extensions/openai/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw OpenAI provider plugins", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0", + "@mariozechner/pi-ai": "0.73.1", "ws": "^8.20.0" }, "devDependencies": { diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index 143737f3d9d..81347940ea3 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -50,20 +50,17 @@ function hasSupportedOpenAIResponsesTransport( function defaultOpenAIResponsesExtraParams( extraParams: Record | undefined, - options?: { openaiWsWarmup?: boolean; transport?: "auto" | "sse" | "websocket" }, + options?: { transport?: "auto" | "sse" | "websocket" }, ): Record | undefined { const hasSupportedTransport = hasSupportedOpenAIResponsesTransport(extraParams?.transport); - const hasExplicitWarmup = typeof extraParams?.openaiWsWarmup === "boolean"; const defaultTransport = options?.transport ?? "auto"; - const shouldDefaultWarmup = options?.openaiWsWarmup === true; - if (hasSupportedTransport && (!shouldDefaultWarmup || hasExplicitWarmup)) { + if (hasSupportedTransport) { return extraParams; } return { ...extraParams, - ...(hasSupportedTransport ? {} : { transport: defaultTransport }), - ...(shouldDefaultWarmup && !hasExplicitWarmup ? { openaiWsWarmup: true } : {}), + transport: defaultTransport, }; } @@ -93,7 +90,6 @@ const wrapOpenAIResponsesProviderStreamFn: NonNullable< }); export function buildOpenAIResponsesProviderHooks(options?: { - openaiWsWarmup?: boolean; transport?: "auto" | "sse" | "websocket"; }): OpenAIResponsesProviderHooks { return { diff --git a/extensions/xai/package.json b/extensions/xai/package.json index 9396a1b9066..77033d39b70 100644 --- a/extensions/xai/package.json +++ b/extensions/xai/package.json @@ -5,7 +5,7 @@ "description": "OpenClaw xAI plugin", "type": "module", "dependencies": { - "@mariozechner/pi-ai": "0.73.0", + "@mariozechner/pi-ai": "0.73.1", "typebox": "1.1.37" }, "devDependencies": { diff --git a/package.json b/package.json index f73a965651f..96c04d45e55 100644 --- a/package.json +++ b/package.json @@ -1697,10 +1697,10 @@ "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.8", "@lydell/node-pty": "1.2.0-beta.12", - "@mariozechner/pi-agent-core": "0.73.0", - "@mariozechner/pi-ai": "0.73.0", - "@mariozechner/pi-coding-agent": "0.73.0", - "@mariozechner/pi-tui": "0.73.0", + "@mariozechner/pi-agent-core": "0.73.1", + "@mariozechner/pi-ai": "0.73.1", + "@mariozechner/pi-coding-agent": "0.73.1", + "@mariozechner/pi-tui": "0.73.1", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", "@openclaw/fs-safe": "github:openclaw/fs-safe#c7ccb99d3058f2acf2ad2758ad2470c7e113a53c", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 980971cee38..5d3092f3dd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,17 +88,17 @@ importers: specifier: 1.2.0-beta.12 version: 1.2.0-beta.12 '@mariozechner/pi-agent-core': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-coding-agent': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-tui': - specifier: 0.73.0 - version: 0.73.0 + specifier: 0.73.1 + version: 0.73.1 '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.4.3) @@ -353,8 +353,8 @@ importers: specifier: ^1.1.0 version: 1.1.0 '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -363,8 +363,8 @@ importers: extensions/anthropic: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -376,11 +376,11 @@ importers: specifier: ^0.16.0 version: 0.16.0(zod@4.4.3) '@mariozechner/pi-agent-core': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -494,8 +494,8 @@ importers: extensions/codex: dependencies: '@mariozechner/pi-coding-agent': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@openai/codex': specifier: 0.129.0 version: 0.129.0 @@ -715,8 +715,8 @@ importers: extensions/fireworks: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -729,8 +729,8 @@ importers: version: 1.3.0 devDependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) '@openclaw/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk @@ -741,8 +741,8 @@ importers: specifier: ^1.51.0 version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -831,8 +831,8 @@ importers: extensions/kimi-coding: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -870,8 +870,8 @@ importers: extensions/lmstudio: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) extensions/lobster: dependencies: @@ -1137,8 +1137,8 @@ importers: extensions/ollama: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -1156,8 +1156,8 @@ importers: extensions/openai: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) ws: specifier: ^8.20.0 version: 8.20.0 @@ -1586,8 +1586,8 @@ importers: extensions/xai: dependencies: '@mariozechner/pi-ai': - specifier: 0.73.0 - version: 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + specifier: 0.73.1 + version: 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: specifier: 1.1.37 version: 1.1.37 @@ -1676,7 +1676,7 @@ importers: version: 14.1.2 '@vitest/browser-playwright': specifier: 4.1.5 - version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.0.1) @@ -1685,10 +1685,10 @@ importers: version: 1.59.1 vite: specifier: 8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + version: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) vitest: specifier: 4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) packages: @@ -2432,6 +2432,15 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@grammyjs/runner@2.0.3': resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} engines: {node: '>=12.20.0 || >=14.13.1'} @@ -2958,27 +2967,27 @@ packages: resolution: {integrity: sha512-D3F+UrU9CR7roJt0zDLp6Oc+4/KlLDIrN4frH+6V90SJNW2KKUec1oCQIPaaDjCqeOsQyX9dyqYbImIQIM45PA==} engines: {node: '>= 10'} - '@mariozechner/jiti@2.6.5': - resolution: {integrity: sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw==} + '@mariozechner/pi-agent-core@0.73.1': + resolution: {integrity: sha512-Y/KVOhuKSgRQgYBlwmRtO2gPkUcoavOSqGF9bpQIINvNZvc19k6Z1H3bFDTce3Vp5ApMmTsfLH3+tNvOg75fAQ==} + engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward + + '@mariozechner/pi-ai@0.73.1': + resolution: {integrity: sha512-Jh4lXawZYuC83HzSIYuVum9NBqJD49i4JOt3H96cGW/924cwJMOyUs1Mv/e4QPzTXnzrqMoGviNQnvGgSu1LSg==} + engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true - '@mariozechner/pi-agent-core@0.73.0': - resolution: {integrity: sha512-ugcpvq0X9fr9fTSK29/3S4+KU/eeVMrBb7ZU3HqiF3xD7I1GlgumLj4FYmDrYSEA6+rzgNWlJUKwjKh9o0Z6AA==} - engines: {node: '>=20.0.0'} - - '@mariozechner/pi-ai@0.73.0': - resolution: {integrity: sha512-phKOpcde/ssz6UYszkmaGJ9LF9mgt/AP8LrtSwsfap+kMSeFfSQ2/mCSBT1mLJ2BqVuff9uXs1/+op1aQeaafQ==} - engines: {node: '>=20.0.0'} - hasBin: true - - '@mariozechner/pi-coding-agent@0.73.0': - resolution: {integrity: sha512-Fs2dRIgtjDT8X5VDGNGzxj251B0FvkRsgX03YJv1FK4wg5Maj+jkf8/5A6tbPnPcXsCgs41xxJRf3tF5vJRccA==} + '@mariozechner/pi-coding-agent@0.73.1': + resolution: {integrity: sha512-gXQh3SaZmWTfVMc4Ao5+LGbVeKvzyO7tolok0nLsZgq9nGjZx/EEU3NM8C+qUnB4Nvs2rswG5qOVgLzQkq0fHQ==} engines: {node: '>=20.6.0'} + deprecated: please use @earendil-works/pi-coding-agent instead going forward hasBin: true - '@mariozechner/pi-tui@0.73.0': - resolution: {integrity: sha512-St1W+tMPKHatfK+lblsKfL+SsFyFVMK2tW6xHpBfCiMuevbOCRo/CMatso7mu1642UO04ncmfCrrpUK5L9aoog==} + '@mariozechner/pi-tui@0.73.1': + resolution: {integrity: sha512-ybVsRnUbzQRtbocltJ2OXb2QogrO67N2BlUyKjZz9BHcZYiDJtNkcKQockxDjsVvDc0uBCLDX6iZJoBElBd8fw==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-tui instead going forward '@matrix-org/matrix-sdk-crypto-nodejs@0.5.1': resolution: {integrity: sha512-m1nTFhUJv8AZCvuVmZ0wgYsFaseVNMhl3Jqu18KoHs7TQa+mmAW4q3xY6MuVApd75Zu9E0ooQeA5obUZdQ24OA==} @@ -4390,6 +4399,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -5898,6 +5910,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -5997,8 +6013,8 @@ packages: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} - koffi@2.16.1: - resolution: {integrity: sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==} + koffi@2.16.2: + resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==} kysely@0.28.17: resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} @@ -7314,9 +7330,6 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -7890,10 +7903,6 @@ packages: resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==} engines: {node: '>=12'} - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - zca-js@2.1.2: resolution: {integrity: sha512-82+zCqoIXnXEF6C9YuN3Kf7WKlyyujY/6Ejl2n8PkwazYkBK0k7kiPd8S7nHvC5Wl7vjwGRhDYeAM8zTHyoRxQ==} engines: {node: '>=18.0.0'} @@ -8911,7 +8920,7 @@ snapshots: '@copilotkit/aimock@1.17.0(vitest@4.1.5)': optionalDependencies: - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) '@create-markdown/preview@2.0.3(shiki@3.23.0)': optionalDependencies: @@ -9146,6 +9155,19 @@ snapshots: - supports-color - utf-8-validate + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.5 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@grammyjs/runner@2.0.3(grammy@1.42.0)': dependencies: abort-controller: 3.0.0 @@ -9714,14 +9736,9 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.2 optional: true - '@mariozechner/jiti@2.6.5': + '@mariozechner/pi-agent-core@0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: - std-env: 3.10.0 - yoctocolors: 2.1.2 - - '@mariozechner/pi-agent-core@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': - dependencies: - '@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-ai': 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) typebox: 1.1.37 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -9732,11 +9749,11 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': + '@mariozechner/pi-ai@0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1024.0 - '@google/genai': 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 chalk: 5.6.2 openai: 6.26.0(ws@8.20.0)(zod@4.4.3) @@ -9754,12 +9771,11 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': + '@mariozechner/pi-coding-agent@0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3)': dependencies: - '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) - '@mariozechner/pi-ai': 0.73.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) - '@mariozechner/pi-tui': 0.73.0 + '@mariozechner/pi-agent-core': 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-ai': 0.73.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.0)(zod@4.4.3) + '@mariozechner/pi-tui': 0.73.1 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -9769,6 +9785,7 @@ snapshots: glob: 13.0.6 hosted-git-info: 9.0.3 ignore: 7.0.5 + jiti: 2.7.0 marked: 15.0.12 minimatch: 10.2.5 proper-lockfile: 4.1.2 @@ -9788,7 +9805,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.73.0': + '@mariozechner/pi-tui@0.73.1': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -9796,7 +9813,7 @@ snapshots: marked: 15.0.12 mime-types: 3.0.2 optionalDependencies: - koffi: 2.16.1 + koffi: 2.16.2 '@matrix-org/matrix-sdk-crypto-nodejs@0.5.1': dependencies: @@ -11256,6 +11273,11 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + optional: true + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -11285,7 +11307,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 optional: true '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260504.1': @@ -11343,6 +11365,20 @@ snapshots: - msw - utf-8-validate - vite + optional: true + + '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': + dependencies: + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + playwright: 1.59.1 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': dependencies: @@ -11360,6 +11396,24 @@ snapshots: - msw - utf-8-validate - vite + optional: true + + '@vitest/browser@4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite '@vitest/coverage-v8@4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)': dependencies: @@ -11373,9 +11427,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) optionalDependencies: - '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) '@vitest/expect@4.1.5': dependencies: @@ -11394,6 +11448,14 @@ snapshots: optionalDependencies: vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4) + '@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + '@vitest/pretty-format@4.1.5': dependencies: tinyrainbow: 3.1.0 @@ -12941,6 +13003,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@4.15.9: {} jose@6.2.3: {} @@ -13086,7 +13150,7 @@ snapshots: klona@2.0.6: {} - koffi@2.16.1: + koffi@2.16.2: optional: true kysely@0.28.17: {} @@ -14715,8 +14779,6 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} - std-env@4.1.0: {} streamx@2.25.0: @@ -15068,6 +15130,21 @@ snapshots: tsx: 4.21.0 yaml: 2.8.4 + vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.17 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.2 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.21.0 + yaml: 2.8.4 + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 @@ -15099,6 +15176,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.6.2 + '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4))(vitest@4.1.5) + '@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) + jsdom: 29.1.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} w3c-xmlserializer@5.0.0: @@ -15256,8 +15364,6 @@ snapshots: buffer-crc32: 0.2.13 pend: 1.2.0 - yoctocolors@2.1.2: {} - zca-js@2.1.2: dependencies: crypto-js: 4.2.0 diff --git a/src/agents/openai-ws-connection.test.ts b/src/agents/openai-ws-connection.test.ts deleted file mode 100644 index 93410cbd444..00000000000 --- a/src/agents/openai-ws-connection.test.ts +++ /dev/null @@ -1,862 +0,0 @@ -/** - * Unit tests for OpenAIWebSocketManager - * - * Uses a mock WebSocket implementation to avoid real network calls. - * The mock simulates the ws package's EventEmitter-based API. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ClientOptions } from "ws"; -import type { - ClientEvent, - ErrorEvent, - OpenAIWebSocketEvent, - ResponseCompletedEvent, - ResponseCreateEvent, -} from "./openai-ws-connection.js"; -import { getOpenAIWebSocketErrorDetails, OpenAIWebSocketManager } from "./openai-ws-connection.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// Mock WebSocket (hoisted so vi.mock factory can reference it) -// ───────────────────────────────────────────────────────────────────────────── - -// vi.mock() factories are hoisted before ES module imports are resolved. -// vi.hoisted() allows us to define values that are available to both the -// factory AND the test body. We avoid importing EventEmitter here because -// ESM imports aren't available yet in the hoisted zone — instead we -// implement a minimal listener pattern inline. -const { MockWebSocket } = vi.hoisted(() => { - type AnyFn = (...args: unknown[]) => void; - - class MockWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - - readyState: number = MockWebSocket.CONNECTING; - url: string; - options: ClientOptions | undefined; - sentMessages: string[] = []; - - private _listeners: Map = new Map(); - - constructor(url: string, options?: ClientOptions) { - this.url = url; - this.options = options ?? {}; - MockWebSocket.lastInstance = this; - MockWebSocket.instances.push(this); - } - - // Minimal EventEmitter-compatible interface - on(event: string, fn: AnyFn): this { - const list = this._listeners.get(event) ?? []; - list.push(fn); - this._listeners.set(event, list); - return this; - } - - once(event: string, fn: AnyFn): this { - const wrapper = (...args: unknown[]) => { - this.off(event, wrapper); - fn(...args); - }; - return this.on(event, wrapper); - } - - off(event: string, fn: AnyFn): this { - const list = this._listeners.get(event) ?? []; - this._listeners.set( - event, - list.filter((l) => l !== fn), - ); - return this; - } - - removeAllListeners(event?: string): this { - if (event !== undefined) { - this._listeners.delete(event); - } else { - this._listeners.clear(); - } - return this; - } - - emit(event: string, ...args: unknown[]): boolean { - const list = this._listeners.get(event) ?? []; - for (const fn of list) { - fn(...args); - } - return list.length > 0; - } - - // ws-compatible send - send(data: string): void { - this.sentMessages.push(data); - } - - // ws-compatible close — triggers async close event - close(code = 1000, reason = ""): void { - this.readyState = MockWebSocket.CLOSING; - setImmediate(() => { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", code, Buffer.from(reason)); - }); - } - - // ── Test helpers ────────────────────────────────────────────────────── - - simulateOpen(): void { - this.readyState = MockWebSocket.OPEN; - this.emit("open"); - } - - simulateMessage(event: unknown): void { - this.emit("message", Buffer.from(JSON.stringify(event))); - } - - simulateError(err: Error): void { - this.readyState = MockWebSocket.CLOSED; - this.emit("error", err); - } - - simulateClose(code = 1006, reason = "Connection lost"): void { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", code, Buffer.from(reason)); - } - - static lastInstance: MockWebSocket | null = null; - static instances: MockWebSocket[] = []; - - static reset(): void { - MockWebSocket.lastInstance = null; - MockWebSocket.instances = []; - } - } - - return { MockWebSocket }; -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Module Mock -// ───────────────────────────────────────────────────────────────────────────── - -vi.mock("ws", () => { - // ws exports WebSocket as the default export; static constants (OPEN, etc.) - // live on the class itself. - return { default: MockWebSocket }; -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Type alias for the mock class (improves test readability) -// ───────────────────────────────────────────────────────────────────────────── - -type MockWS = typeof MockWebSocket extends { new (...a: infer _): infer R } ? R : never; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -function lastSocket(): MockWS { - const sock = MockWebSocket.lastInstance; - if (!sock) { - throw new Error("No MockWebSocket instance created"); - } - return sock; -} - -function buildManager(opts?: ConstructorParameters[0]) { - return new OpenAIWebSocketManager({ - // Use faster backoff in tests to avoid slow timer waits - backoffDelaysMs: [10, 20, 40, 80, 160], - socketFactory: (url, options) => new MockWebSocket(url, options) as never, - ...opts, - }); -} - -function attachErrorCollector(manager: OpenAIWebSocketManager) { - const errors: Error[] = []; - manager.on("error", (e) => errors.push(e)); - return errors; -} - -async function connectManagerAndGetSocket(manager: OpenAIWebSocketManager) { - const connectPromise = manager.connect("sk-test"); - const sock = lastSocket(); - sock.simulateOpen(); - await connectPromise; - return sock; -} - -async function createConnectedManager( - opts?: ConstructorParameters[0], -): Promise<{ manager: OpenAIWebSocketManager; sock: MockWS }> { - const manager = buildManager(opts); - const sock = await connectManagerAndGetSocket(manager); - return { manager, sock }; -} - -function connectIgnoringFailure(manager: OpenAIWebSocketManager): Promise { - return manager.connect("sk-test").catch(() => { - /* ignore rejection */ - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -describe("OpenAIWebSocketManager", () => { - beforeEach(() => { - MockWebSocket.reset(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - // ─── connect() ───────────────────────────────────────────────────────────── - - describe("connect()", () => { - it("opens a WebSocket with Bearer auth header", async () => { - const manager = buildManager(); - const connectPromise = manager.connect("sk-test-key"); - - const sock = lastSocket(); - expect(sock.url).toBe("wss://api.openai.com/v1/responses"); - expect(sock.options).toMatchObject({ - headers: expect.objectContaining({ - Authorization: "Bearer sk-test-key", - }), - }); - - sock.simulateOpen(); - await connectPromise; - }); - - it("adds OpenClaw attribution headers on the native OpenAI websocket", async () => { - const manager = buildManager(); - const connectPromise = manager.connect("sk-test-key"); - - const sock = lastSocket(); - expect(sock.options).toMatchObject({ - headers: expect.objectContaining({ - originator: "openclaw", - version: expect.any(String), - "User-Agent": expect.stringMatching(/^openclaw\//), - }), - }); - - sock.simulateOpen(); - await connectPromise; - }); - - it("merges native session headers into the websocket handshake", async () => { - const manager = buildManager({ - headers: { - "x-client-request-id": "session-123", - "x-openclaw-session-id": "session-123", - }, - }); - const connectPromise = manager.connect("sk-test-key"); - - const sock = lastSocket(); - expect(sock.options).toMatchObject({ - headers: expect.objectContaining({ - "x-client-request-id": "session-123", - "x-openclaw-session-id": "session-123", - }), - }); - - sock.simulateOpen(); - await connectPromise; - }); - - it("does not add hidden attribution headers on custom websocket endpoints", async () => { - const manager = buildManager({ - url: "wss://proxy.example.com/v1/responses", - }); - const connectPromise = manager.connect("sk-test-key"); - - const sock = lastSocket(); - expect(sock.options).toMatchObject({ - headers: expect.objectContaining({ - Authorization: "Bearer sk-test-key", - "OpenAI-Beta": "responses-websocket=v1", - }), - }); - const headers = sock.options?.headers as Record; - expect(headers.originator).toBeUndefined(); - expect(headers.version).toBeUndefined(); - expect(headers["User-Agent"]).toBeUndefined(); - - sock.simulateOpen(); - await connectPromise; - }); - - it("rejects insecure websocket TLS overrides", async () => { - const manager = buildManager({ - request: { - tls: { - insecureSkipVerify: true, - }, - }, - }); - - await expect(manager.connect("sk-test-key")).rejects.toThrow(/insecureskipverify/i); - expect(MockWebSocket.lastInstance).toBeNull(); - }); - - it("resolves when the connection opens", async () => { - const manager = buildManager(); - const connectPromise = manager.connect("sk-test"); - expect(manager.connectionState).toBe("connecting"); - lastSocket().simulateOpen(); - await expect(connectPromise).resolves.toBeUndefined(); - expect(manager.connectionState).toBe("open"); - }); - - it("rejects when the initial connection fails (maxRetries=0)", async () => { - const manager = buildManager({ maxRetries: 0 }); - const connectPromise = manager.connect("sk-test"); - - lastSocket().simulateError(new Error("ECONNREFUSED")); - - await expect(connectPromise).rejects.toThrow("ECONNREFUSED"); - }); - - it("sets isConnected() to true after open", async () => { - const manager = buildManager(); - expect(manager.isConnected()).toBe(false); - - const connectPromise = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await connectPromise; - - expect(manager.isConnected()).toBe(true); - }); - - it("uses the custom URL when provided", async () => { - const manager = buildManager({ url: "ws://localhost:9999/v1/responses" }); - const connectPromise = manager.connect("sk-test"); - - expect(lastSocket().url).toBe("ws://localhost:9999/v1/responses"); - lastSocket().simulateOpen(); - await connectPromise; - }); - }); - - // ─── send() ──────────────────────────────────────────────────────────────── - - describe("send()", () => { - it("sends a JSON-serialized event over the socket", async () => { - const { manager, sock } = await createConnectedManager(); - - const event: ResponseCreateEvent = { - type: "response.create", - model: "gpt-5.4", - input: [{ type: "message", role: "user", content: "Hello" }], - }; - manager.send(event); - - expect(sock.sentMessages).toHaveLength(1); - expect(JSON.parse(sock.sentMessages[0] ?? "{}")).toEqual(event); - }); - - it("throws if the connection is not open", () => { - const manager = buildManager(); - const event: ClientEvent = { - type: "response.create", - model: "gpt-5.4", - }; - expect(() => manager.send(event)).toThrow(/cannot send/); - }); - - it("includes previous_response_id when provided", async () => { - const { manager, sock } = await createConnectedManager(); - - const event: ResponseCreateEvent = { - type: "response.create", - model: "gpt-5.4", - previous_response_id: "resp_abc123", - input: [{ type: "function_call_output", call_id: "call_1", output: "result" }], - }; - manager.send(event); - - const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as ResponseCreateEvent; - expect(sent.previous_response_id).toBe("resp_abc123"); - }); - }); - - // ─── onMessage() ─────────────────────────────────────────────────────────── - - describe("onMessage()", () => { - it("calls handler for each incoming message", async () => { - const { manager, sock } = await createConnectedManager(); - - const received: OpenAIWebSocketEvent[] = []; - manager.onMessage((e) => received.push(e)); - - const deltaEvent: OpenAIWebSocketEvent = { - type: "response.output_text.delta", - item_id: "item_1", - output_index: 0, - content_index: 0, - delta: "Hello", - }; - sock.simulateMessage(deltaEvent); - - expect(received).toHaveLength(1); - expect(received[0]).toEqual(deltaEvent); - }); - - it("returns an unsubscribe function that stops delivery", async () => { - const { manager, sock } = await createConnectedManager(); - - const received: OpenAIWebSocketEvent[] = []; - const unsubscribe = manager.onMessage((e) => received.push(e)); - - sock.simulateMessage({ type: "response.in_progress", response: makeResponse("r1") }); - unsubscribe(); - sock.simulateMessage({ type: "response.in_progress", response: makeResponse("r2") }); - - expect(received).toHaveLength(1); - }); - - it("supports multiple simultaneous handlers", async () => { - const { manager, sock } = await createConnectedManager(); - - const calls: number[] = []; - manager.onMessage(() => calls.push(1)); - manager.onMessage(() => calls.push(2)); - - sock.simulateMessage({ type: "response.in_progress", response: makeResponse("r1") }); - - expect(calls.toSorted((a, b) => a - b)).toEqual([1, 2]); - }); - }); - - // ─── previousResponseId ──────────────────────────────────────────────────── - - describe("previousResponseId", () => { - it("starts as null", () => { - expect(new OpenAIWebSocketManager().previousResponseId).toBeNull(); - }); - - it("is updated when a response.completed event is received", async () => { - const { manager, sock } = await createConnectedManager(); - - const completedEvent: ResponseCompletedEvent = { - type: "response.completed", - response: makeResponse("resp_done_42", "completed"), - }; - sock.simulateMessage(completedEvent); - - expect(manager.previousResponseId).toBe("resp_done_42"); - }); - - it("tracks the most recent completed response", async () => { - const { manager, sock } = await createConnectedManager(); - - sock.simulateMessage({ - type: "response.completed", - response: makeResponse("resp_1", "completed"), - }); - sock.simulateMessage({ - type: "response.completed", - response: makeResponse("resp_2", "completed"), - }); - - expect(manager.previousResponseId).toBe("resp_2"); - }); - - it("is not updated for non-completed events", async () => { - const { manager, sock } = await createConnectedManager(); - - sock.simulateMessage({ type: "response.in_progress", response: makeResponse("resp_x") }); - - expect(manager.previousResponseId).toBeNull(); - }); - }); - - // ─── isConnected() ───────────────────────────────────────────────────────── - - describe("isConnected()", () => { - it("returns false before connect", () => { - expect(buildManager().isConnected()).toBe(false); - }); - - it("returns true while open", async () => { - const manager = buildManager(); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - expect(manager.isConnected()).toBe(true); - }); - - it("returns false after close()", async () => { - const manager = buildManager(); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - manager.close(); - expect(manager.isConnected()).toBe(false); - }); - }); - - // ─── close() ─────────────────────────────────────────────────────────────── - - describe("close()", () => { - it("marks the manager as disconnected", async () => { - const manager = buildManager(); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - - manager.close(); - - expect(manager.isConnected()).toBe(false); - }); - - it("prevents reconnect after explicit close", async () => { - const manager = buildManager(); - const p = manager.connect("sk-test"); - const sock = lastSocket(); - sock.simulateOpen(); - await p; - - const socketCountBefore = MockWebSocket.instances.length; - manager.close(); - - // Simulate a network drop — should NOT trigger reconnect - sock.simulateClose(1006, "Network error"); - await vi.runAllTimersAsync(); - - expect(MockWebSocket.instances.length).toBe(socketCountBefore); - }); - - it("is safe to call before connect()", () => { - const manager = buildManager(); - expect(manager.close()).toBeUndefined(); - expect(manager.connectionState).toBe("closed"); - }); - }); - - // ─── Auto-reconnect ──────────────────────────────────────────────────────── - - describe("auto-reconnect", () => { - it("reconnects on unexpected close", async () => { - const manager = buildManager({ backoffDelaysMs: [10, 20, 40, 80, 160] }); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - - const sock1 = lastSocket(); - const instancesBefore = MockWebSocket.instances.length; - - // Simulate a network drop - sock1.simulateClose(1006, "Network error"); - expect(manager.connectionState).toBe("reconnecting"); - expect(manager.lastCloseInfo).toEqual({ - code: 1006, - reason: "Network error", - retryable: true, - }); - - // Advance time to trigger first retry (10ms delay) - await vi.advanceTimersByTimeAsync(15); - - // A new socket should have been created - expect(MockWebSocket.instances.length).toBeGreaterThan(instancesBefore); - expect(lastSocket()).not.toBe(sock1); - }); - - it("does not reconnect on non-retryable close codes", async () => { - const manager = buildManager({ backoffDelaysMs: [10, 20] }); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - - const sock = lastSocket(); - const instancesBefore = MockWebSocket.instances.length; - sock.simulateClose(1008, "policy violation"); - - await vi.advanceTimersByTimeAsync(25); - - expect(MockWebSocket.instances.length).toBe(instancesBefore); - expect(manager.connectionState).toBe("closed"); - expect(manager.lastCloseInfo).toEqual({ - code: 1008, - reason: "policy violation", - retryable: false, - }); - }); - - it("stops retrying after maxRetries", async () => { - const manager = buildManager({ maxRetries: 2, backoffDelaysMs: [5, 5] }); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - - const errors: Error[] = []; - manager.on("error", (e) => errors.push(e)); - - // Drop repeatedly — each reconnect attempt also drops immediately - for (let i = 0; i < 4; i++) { - lastSocket().simulateClose(1006, "drop"); - await vi.advanceTimersByTimeAsync(20); - } - - expect(errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.stringContaining("max reconnect retries"), - }), - ]), - ); - }); - - it("does not double-count retries when error and close both fire on a reconnect attempt", async () => { - // In the real `ws` library, a failed connection fires "error" followed - // by "close". Previously, both the onClose handler AND the promise - // .catch() in _scheduleReconnect called _scheduleReconnect(), which - // double-incremented retryCount and exhausted the retry budget - // prematurely (e.g. 3 retries became ~1-2 actual attempts). - const manager = buildManager({ maxRetries: 3, backoffDelaysMs: [5, 5, 5] }); - const errors = attachErrorCollector(manager); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - - // Drop the established connection — triggers first reconnect schedule - lastSocket().simulateClose(1006, "Network error"); - - // Advance past first retry delay — a new socket is created - await vi.advanceTimersByTimeAsync(10); - const sock2 = lastSocket(); - - // Simulate a realistic failure: error fires first, then close follows. - sock2.simulateError(new Error("ECONNREFUSED")); - sock2.simulateClose(1006, "Connection failed"); - - // Advance past second retry delay — another socket should be created - // because we've only used 2 retries (not 3 from double-counting). - await vi.advanceTimersByTimeAsync(10); - const sock3 = lastSocket(); - expect(sock3).not.toBe(sock2); - - // Third attempt also fails with error+close - sock3.simulateError(new Error("ECONNREFUSED")); - sock3.simulateClose(1006, "Connection failed"); - - // Advance past third retry delay — one more attempt (retry 3 of 3) - await vi.advanceTimersByTimeAsync(10); - const sock4 = lastSocket(); - expect(sock4).not.toBe(sock3); - - // Fourth socket also fails — now retries should be exhausted (3/3) - sock4.simulateError(new Error("ECONNREFUSED")); - sock4.simulateClose(1006, "Connection failed"); - await vi.advanceTimersByTimeAsync(10); - - expect(errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.stringContaining("max reconnect retries"), - }), - ]), - ); - }); - - it("resets retry count after a successful reconnect", async () => { - const manager = buildManager({ maxRetries: 3, backoffDelaysMs: [5, 10, 20] }); - const p = manager.connect("sk-test"); - lastSocket().simulateOpen(); - await p; - - // Drop and let first retry succeed - lastSocket().simulateClose(1006, "drop"); - await vi.advanceTimersByTimeAsync(10); - lastSocket().simulateOpen(); // second socket opens successfully - - const socketCountAfterReconnect = MockWebSocket.instances.length; - - // Drop again — should still retry (retry count was reset) - lastSocket().simulateClose(1006, "drop again"); - await vi.advanceTimersByTimeAsync(10); - - expect(MockWebSocket.instances.length).toBeGreaterThan(socketCountAfterReconnect); - }); - }); - - // ─── warmUp() ────────────────────────────────────────────────────────────── - - describe("warmUp()", () => { - it("sends a response.create event with generate: false", async () => { - const { manager, sock } = await createConnectedManager(); - - manager.warmUp({ model: "gpt-5.4", instructions: "You are helpful." }); - - expect(sock.sentMessages).toHaveLength(1); - const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as Record; - expect(sent["type"]).toBe("response.create"); - expect(sent["generate"]).toBe(false); - expect(sent["model"]).toBe("gpt-5.4"); - expect(sent["input"]).toStrictEqual([]); - expect(sent["instructions"]).toBe("You are helpful."); - }); - - it("includes tools when provided", async () => { - const { manager, sock } = await createConnectedManager(); - - manager.warmUp({ - model: "gpt-5.4", - tools: [{ type: "function", name: "exec", description: "Run a command" }], - }); - - const sent = JSON.parse(sock.sentMessages[0] ?? "{}") as Record; - expect(sent["tools"]).toHaveLength(1); - expect((sent["tools"] as Array<{ name?: string }>)[0]?.name).toBe("exec"); - }); - }); - - // ─── Error handling ───────────────────────────────────────────────────────── - - describe("error handling", () => { - it("normalizes nested websocket error payloads", () => { - const details = getOpenAIWebSocketErrorDetails({ - type: "error", - status: 400, - error: { - type: "invalid_request_error", - code: "previous_response_not_found", - message: "Previous response with id 'resp_abc' not found.", - param: "previous_response_id", - }, - } satisfies ErrorEvent); - - expect(details).toEqual({ - status: 400, - type: "invalid_request_error", - code: "previous_response_not_found", - message: "Previous response with id 'resp_abc' not found.", - param: "previous_response_id", - }); - }); - - it("emits error event on malformed JSON message", async () => { - const manager = buildManager(); - const sock = await connectManagerAndGetSocket(manager); - const errors = attachErrorCollector(manager); - - sock.emit("message", Buffer.from("not valid json{{{{")); - - expect(errors).toHaveLength(1); - expect(errors[0]?.message).toContain("failed to parse message"); - }); - - it("emits error event when message has no type field", async () => { - const manager = buildManager(); - const sock = await connectManagerAndGetSocket(manager); - const errors = attachErrorCollector(manager); - - sock.emit("message", Buffer.from(JSON.stringify({ foo: "bar" }))); - - expect(errors).toHaveLength(1); - expect(errors[0]?.message).toContain('no "type" field'); - }); - - it("emits error event on WebSocket socket error", async () => { - const manager = buildManager({ maxRetries: 0 }); - const p = connectIgnoringFailure(manager); - const errors = attachErrorCollector(manager); - - lastSocket().simulateError(new Error("SSL handshake failed")); - await p; - - expect(errors.map((error) => error.message)).toContain("SSL handshake failed"); - }); - - it("handles multiple successive socket errors without crashing", async () => { - const manager = buildManager({ maxRetries: 0 }); - const p = connectIgnoringFailure(manager); - const errors = attachErrorCollector(manager); - - // Fire two errors in quick succession — previously the second would - // be unhandled because .once("error") removed the handler after #1. - lastSocket().simulateError(new Error("first error")); - lastSocket().simulateError(new Error("second error")); - await p; - - expect(errors.length).toBeGreaterThanOrEqual(2); - expect(errors.map((error) => error.message)).toEqual( - expect.arrayContaining(["first error", "second error"]), - ); - }); - }); - - // ─── Integration: full multi-turn sequence ──────────────────────────────── - - describe("full turn sequence", () => { - it("tracks previous_response_id across turns and sends continuation correctly", async () => { - const { manager, sock } = await createConnectedManager(); - - const received: OpenAIWebSocketEvent[] = []; - manager.onMessage((e) => received.push(e)); - - // Send initial turn - manager.send({ type: "response.create", model: "gpt-5.4", input: "Hello" }); - - // Simulate streaming events from server - sock.simulateMessage({ type: "response.created", response: makeResponse("resp_1") }); - sock.simulateMessage({ - type: "response.output_text.delta", - item_id: "i1", - output_index: 0, - content_index: 0, - delta: "Hi!", - }); - sock.simulateMessage({ - type: "response.completed", - response: makeResponse("resp_1", "completed"), - }); - - expect(manager.previousResponseId).toBe("resp_1"); - expect(received).toHaveLength(3); - - // Send continuation turn using the tracked previous_response_id - manager.send({ - type: "response.create", - model: "gpt-5.4", - previous_response_id: manager.previousResponseId!, - input: [{ type: "function_call_output", call_id: "call_99", output: "tool result" }], - }); - - const lastSent = JSON.parse(sock.sentMessages[1] ?? "{}") as ResponseCreateEvent; - expect(lastSent.previous_response_id).toBe("resp_1"); - expect(lastSent.input).toEqual([ - { type: "function_call_output", call_id: "call_99", output: "tool result" }, - ]); - }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Test Fixtures -// ───────────────────────────────────────────────────────────────────────────── - -function makeResponse( - id: string, - status: ResponseCompletedEvent["response"]["status"] = "in_progress", -): ResponseCompletedEvent["response"] { - return { - id, - object: "response", - created_at: Date.now(), - status, - model: "gpt-5.4", - output: [], - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }; -} diff --git a/src/agents/openai-ws-connection.ts b/src/agents/openai-ws-connection.ts deleted file mode 100644 index b181f85fbed..00000000000 --- a/src/agents/openai-ws-connection.ts +++ /dev/null @@ -1,643 +0,0 @@ -import { randomUUID } from "node:crypto"; -/** - * OpenAI WebSocket Connection Manager - * - * Manages a persistent WebSocket connection to the OpenAI Responses API - * (wss://api.openai.com/v1/responses) for multi-turn tool-call workflows. - * - * Features: - * - Auto-reconnect with exponential backoff (max 5 retries: 1s/2s/4s/8s/16s) - * - Tracks previous_response_id per connection for incremental turns - * - Warm-up support (generate: false) to pre-load the connection - * - Typed WebSocket event definitions matching the Responses API SSE spec - * - * @see https://developers.openai.com/api/docs/guides/websocket-mode - */ -import { EventEmitter } from "node:events"; -import WebSocket, { type ClientOptions } from "ws"; -import { rawDataToString } from "../infra/ws.js"; -import { createDebugProxyWebSocketAgent, resolveDebugProxySettings } from "../proxy-capture/env.js"; -import { captureWsEvent } from "../proxy-capture/runtime.js"; -import { buildOpenAIWebSocketWarmUpPayload } from "./openai-ws-request.js"; -import type { - ClientEvent, - FunctionToolDefinition, - InputItem, - OpenAIResponsesAssistantPhase, -} from "./openai-ws-types.js"; -import { - buildProviderRequestTlsClientOptions, - resolveProviderRequestPolicyConfig, - type ModelProviderRequestTransportOverrides, -} from "./provider-request-config.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// WebSocket Event Types (Server → Client) -// ───────────────────────────────────────────────────────────────────────────── - -export interface ResponseObject { - id: string; - object: "response"; - created_at: number; - status: "in_progress" | "completed" | "failed" | "cancelled" | "incomplete"; - model: string; - output: OutputItem[]; - usage?: UsageInfo; - error?: { code: string; message: string }; - incomplete_details?: { reason?: string }; -} - -export interface UsageInfo { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - prompt_tokens?: number; - completion_tokens?: number; - input_tokens_details?: { - cached_tokens?: number; - }; -} - -export type OutputItem = - | { - type: "message"; - id: string; - role: "assistant"; - content: Array<{ type: "output_text"; text: string }>; - phase?: OpenAIResponsesAssistantPhase; - status?: "in_progress" | "completed"; - } - | { - type: "function_call"; - id: string; - call_id: string; - name: string; - arguments: string; - status?: "in_progress" | "completed"; - } - | { - type: "reasoning" | `reasoning.${string}`; - id: string; - content?: unknown; - encrypted_content?: string; - summary?: unknown; - }; - -export interface ResponseCreatedEvent { - type: "response.created"; - response: ResponseObject; -} - -export interface ResponseInProgressEvent { - type: "response.in_progress"; - response: ResponseObject; -} - -export interface ResponseCompletedEvent { - type: "response.completed"; - response: ResponseObject; -} - -export interface ResponseFailedEvent { - type: "response.failed"; - response: ResponseObject; -} - -export interface OutputItemAddedEvent { - type: "response.output_item.added"; - output_index: number; - item: OutputItem; -} - -export interface OutputItemDoneEvent { - type: "response.output_item.done"; - output_index: number; - item: OutputItem; -} - -export interface ContentPartAddedEvent { - type: "response.content_part.added"; - item_id: string; - output_index: number; - content_index: number; - part: { type: "output_text"; text: string }; -} - -export interface ContentPartDoneEvent { - type: "response.content_part.done"; - item_id: string; - output_index: number; - content_index: number; - part: { type: "output_text"; text: string }; -} - -export interface OutputTextDeltaEvent { - type: "response.output_text.delta"; - item_id: string; - output_index: number; - content_index: number; - delta: string; -} - -export interface OutputTextDoneEvent { - type: "response.output_text.done"; - item_id: string; - output_index: number; - content_index: number; - text: string; -} - -export interface FunctionCallArgumentsDeltaEvent { - type: "response.function_call_arguments.delta"; - item_id: string; - output_index: number; - call_id: string; - delta: string; -} - -export interface FunctionCallArgumentsDoneEvent { - type: "response.function_call_arguments.done"; - item_id: string; - output_index: number; - call_id: string; - arguments: string; -} - -export interface RateLimitUpdatedEvent { - type: "rate_limits.updated"; - rate_limits: Array<{ - name: string; - limit: number; - remaining: number; - reset_seconds: number; - }>; -} - -export interface ErrorEvent { - type: "error"; - status?: number; - code?: string; - message?: string; - param?: string; - error?: { - type?: string; - code?: string; - message?: string; - param?: string; - }; -} - -export type OpenAIWebSocketEvent = - | ResponseCreatedEvent - | ResponseInProgressEvent - | ResponseCompletedEvent - | ResponseFailedEvent - | OutputItemAddedEvent - | OutputItemDoneEvent - | ContentPartAddedEvent - | ContentPartDoneEvent - | OutputTextDeltaEvent - | OutputTextDoneEvent - | FunctionCallArgumentsDeltaEvent - | FunctionCallArgumentsDoneEvent - | RateLimitUpdatedEvent - | ErrorEvent; - -export type { - ClientEvent, - ContentPart, - FunctionToolDefinition, - InputItem, - OpenAIResponsesAssistantPhase, - ResponseCreateEvent, - ToolChoice, - WarmUpEvent, -} from "./openai-ws-types.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// Connection Manager -// ───────────────────────────────────────────────────────────────────────────── - -const OPENAI_WS_URL = "wss://api.openai.com/v1/responses"; -const MAX_RETRIES = 5; -/** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s */ -const BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000] as const; - -export interface OpenAIWebSocketManagerOptions { - /** Override the default WebSocket URL (useful for testing) */ - url?: string; - /** Maximum number of reconnect attempts (default: 5) */ - maxRetries?: number; - /** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */ - backoffDelaysMs?: readonly number[]; - /** Custom socket factory for tests. */ - socketFactory?: (url: string, options: ClientOptions) => WebSocket; - /** Extra headers merged into the initial WebSocket handshake request. */ - headers?: Record; - /** Optional transport overrides for provider-owned auth or TLS wiring. */ - request?: ModelProviderRequestTransportOverrides; -} - -export type OpenAIWebSocketConnectionState = - | "idle" - | "connecting" - | "open" - | "reconnecting" - | "closed"; - -export interface OpenAIWebSocketCloseInfo { - code: number; - reason: string; - retryable: boolean; -} - -type InternalEvents = { - message: [event: OpenAIWebSocketEvent]; - open: []; - close: [code: number, reason: string]; - error: [err: Error]; -}; - -/** - * Manages a persistent WebSocket connection to the OpenAI Responses API. - * - * Usage: - * ```ts - * const manager = new OpenAIWebSocketManager(); - * await manager.connect(apiKey); - * - * manager.onMessage((event) => { - * if (event.type === "response.completed") { - * console.log("Response ID:", event.response.id); - * } - * }); - * - * manager.send({ type: "response.create", model: "gpt-5.4", input: [...] }); - * ``` - */ -export class OpenAIWebSocketManager extends EventEmitter { - private ws: WebSocket | null = null; - private apiKey: string | null = null; - private retryCount = 0; - private retryTimer: NodeJS.Timeout | null = null; - private closed = false; - - /** The ID of the most recent completed response on this connection. */ - private _previousResponseId: string | null = null; - private _connectionState: OpenAIWebSocketConnectionState = "idle"; - private _lastCloseInfo: OpenAIWebSocketCloseInfo | null = null; - - private readonly wsUrl: string; - private readonly maxRetries: number; - private readonly backoffDelaysMs: readonly number[]; - private readonly socketFactory: (url: string, options: ClientOptions) => WebSocket; - private readonly headers?: Record; - private readonly request?: ModelProviderRequestTransportOverrides; - private readonly flowId: string; - - constructor(options: OpenAIWebSocketManagerOptions = {}) { - super(); - this.wsUrl = options.url ?? OPENAI_WS_URL; - this.maxRetries = options.maxRetries ?? MAX_RETRIES; - this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS; - this.socketFactory = - options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions)); - this.headers = options.headers; - this.request = options.request; - this.flowId = randomUUID(); - } - - // ─── Public API ──────────────────────────────────────────────────────────── - - /** - * Returns the previous_response_id from the last completed response, - * for use in subsequent response.create events. - */ - get previousResponseId(): string | null { - return this._previousResponseId; - } - - get connectionState(): OpenAIWebSocketConnectionState { - return this._connectionState; - } - - get lastCloseInfo(): OpenAIWebSocketCloseInfo | null { - return this._lastCloseInfo; - } - - /** - * Opens a WebSocket connection to the OpenAI Responses API. - * Resolves when the connection is established (open event fires). - * Rejects if the initial connection fails after max retries. - */ - connect(apiKey: string): Promise { - this.apiKey = apiKey; - this.closed = false; - this.retryCount = 0; - this._connectionState = "connecting"; - this._lastCloseInfo = null; - return this._openConnection(); - } - - /** - * Sends a typed event to the OpenAI Responses API over the WebSocket. - * Throws if the connection is not open. - */ - send(event: ClientEvent): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - throw new Error( - `OpenAIWebSocketManager: cannot send — connection is not open (readyState=${this.ws?.readyState ?? "no socket"})`, - ); - } - const payload = JSON.stringify(event); - captureWsEvent({ - url: this.wsUrl, - direction: "outbound", - kind: "ws-frame", - flowId: this.flowId, - payload, - meta: { eventType: event.type }, - }); - this.ws.send(payload); - } - - /** - * Registers a handler for incoming server-sent WebSocket events. - * Returns an unsubscribe function. - */ - onMessage(handler: (event: OpenAIWebSocketEvent) => void): () => void { - this.on("message", handler); - return () => { - this.off("message", handler); - }; - } - - /** - * Returns true if the WebSocket is currently open and ready to send. - */ - isConnected(): boolean { - return this.ws !== null && this.ws.readyState === WebSocket.OPEN; - } - - /** - * Permanently closes the WebSocket connection and disables auto-reconnect. - */ - close(): void { - this.closed = true; - this._connectionState = "closed"; - this._cancelRetryTimer(); - if (this.ws) { - this.ws.removeAllListeners(); - try { - if (this.ws.readyState === WebSocket.OPEN) { - this.ws.close(1000, "Client closed"); - } else if (this.ws.readyState === WebSocket.CONNECTING) { - // ws can still throw here while the handshake is in-flight. - this.ws.terminate(); - } - } catch { - // Best-effort close during setup/teardown. - } - this.ws = null; - } - } - - // ─── Internal: Connection Lifecycle ──────────────────────────────────────── - - private _openConnection(): Promise { - return new Promise((resolve, reject) => { - if (!this.apiKey) { - reject(new Error("OpenAIWebSocketManager: apiKey is required before connecting.")); - return; - } - - const requestConfig = resolveProviderRequestPolicyConfig({ - provider: "openai", - api: "openai-responses", - baseUrl: this.wsUrl, - capability: "llm", - transport: "websocket", - providerHeaders: { - Authorization: `Bearer ${this.apiKey}`, - "OpenAI-Beta": "responses-websocket=v1", - ...this.headers, - }, - precedence: "defaults-win", - request: this.request, - allowPrivateNetwork: this.request?.allowPrivateNetwork === true, - }); - const debugAgent = createDebugProxyWebSocketAgent(resolveDebugProxySettings()); - const socket = this.socketFactory(this.wsUrl, { - headers: requestConfig.headers, - ...(debugAgent ? { agent: debugAgent } : {}), - ...buildProviderRequestTlsClientOptions(requestConfig), - }); - - this.ws = socket; - - const onOpen = () => { - this.retryCount = 0; - this._connectionState = "open"; - this._lastCloseInfo = null; - captureWsEvent({ - url: this.wsUrl, - direction: "local", - kind: "ws-open", - flowId: this.flowId, - }); - resolve(); - this.emit("open"); - }; - - const onError = (err: Error) => { - // Remove open listener so we don't resolve after an error. - socket.off("open", onOpen); - // Emit "error" on the manager only when there are listeners; otherwise - // the promise rejection below is the primary error channel for this - // initial connection failure. (An uncaught "error" event in Node.js - // throws synchronously and would prevent the promise from rejecting.) - if (this.listenerCount("error") > 0) { - this.emit("error", err); - } - captureWsEvent({ - url: this.wsUrl, - direction: "local", - kind: "error", - flowId: this.flowId, - errorText: err.message, - }); - if (this._connectionState === "connecting" || this._connectionState === "reconnecting") { - this._connectionState = "closed"; - } - reject(err); - }; - - const onClose = (code: number, reason: Buffer) => { - const reasonStr = reason.toString(); - const closeInfo = { - code, - reason: reasonStr, - retryable: isRetryableWebSocketClose(code), - } satisfies OpenAIWebSocketCloseInfo; - this._lastCloseInfo = closeInfo; - captureWsEvent({ - url: this.wsUrl, - direction: "local", - kind: "ws-close", - flowId: this.flowId, - closeCode: code, - payload: reasonStr, - }); - this.emit("close", code, reasonStr); - - if (!this.closed && closeInfo.retryable) { - this._scheduleReconnect(); - } else { - this._connectionState = "closed"; - } - }; - - const onMessage = (data: WebSocket.RawData) => { - captureWsEvent({ - url: this.wsUrl, - direction: "inbound", - kind: "ws-frame", - flowId: this.flowId, - payload: Buffer.from(rawDataToString(data)), - }); - this._handleMessage(data); - }; - - socket.once("open", onOpen); - socket.on("error", onError); - socket.on("close", onClose); - socket.on("message", onMessage); - }); - } - - private _scheduleReconnect(): void { - if (this.closed) { - return; - } - if (this.retryCount >= this.maxRetries) { - this._connectionState = "closed"; - this._safeEmitError( - new Error(`OpenAIWebSocketManager: max reconnect retries (${this.maxRetries}) exceeded.`), - ); - return; - } - - const delayMs = - this.backoffDelaysMs[Math.min(this.retryCount, this.backoffDelaysMs.length - 1)] ?? 1000; - this.retryCount++; - this._connectionState = "reconnecting"; - - this.retryTimer = setTimeout(() => { - if (this.closed) { - return; - } - // The onClose handler already calls _scheduleReconnect() for the next - // attempt, so we intentionally swallow the rejection here to avoid - // double-scheduling (which would double-increment retryCount per - // failed reconnect and exhaust the retry budget prematurely). - this._openConnection().catch(() => {}); - }, delayMs); - } - - /** Emit an error only if there are listeners; prevents Node.js from crashing - * with "unhandled 'error' event" when no one is listening. */ - private _safeEmitError(err: Error): void { - if (this.listenerCount("error") > 0) { - this.emit("error", err); - } - } - - private _cancelRetryTimer(): void { - if (this.retryTimer !== null) { - clearTimeout(this.retryTimer); - this.retryTimer = null; - } - } - - private _handleMessage(data: WebSocket.RawData): void { - let text: string; - if (typeof data === "string") { - text = data; - } else if (Buffer.isBuffer(data)) { - text = data.toString("utf8"); - } else if (data instanceof ArrayBuffer) { - text = Buffer.from(data).toString("utf8"); - } else { - // Blob or other — coerce to string - text = String(data); - } - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - this._safeEmitError( - new Error(`OpenAIWebSocketManager: failed to parse message: ${text.slice(0, 200)}`), - ); - return; - } - - if (!parsed || typeof parsed !== "object" || !("type" in parsed)) { - this._safeEmitError( - new Error( - `OpenAIWebSocketManager: unexpected message shape (no "type" field): ${text.slice(0, 200)}`, - ), - ); - return; - } - - const event = parsed as OpenAIWebSocketEvent; - - // Track previous_response_id on completion - if (event.type === "response.completed" && event.response?.id) { - this._previousResponseId = event.response.id; - } - - this.emit("message", event); - } - - /** - * Sends a warm-up event to pre-load the connection and model without generating output. - * Pass tools/instructions to prime the connection for the upcoming session. - */ - warmUp(params: { - model: string; - tools?: FunctionToolDefinition[]; - instructions?: string; - metadata?: Record; - }): void { - const event = buildOpenAIWebSocketWarmUpPayload(params); - this.send(event); - } -} -export function getOpenAIWebSocketErrorDetails(event: ErrorEvent): { - status?: number; - type?: string; - code?: string; - message?: string; - param?: string; -} { - return { - status: typeof event.status === "number" ? event.status : undefined, - type: event.error?.type, - code: event.error?.code ?? event.code, - message: event.error?.message ?? event.message, - param: event.error?.param ?? event.param, - }; -} - -function isRetryableWebSocketClose(code: number): boolean { - return ( - code === 1001 || - code === 1005 || - code === 1006 || - code === 1011 || - code === 1012 || - code === 1013 - ); -} diff --git a/src/agents/openai-ws-message-conversion.test.ts b/src/agents/openai-ws-message-conversion.test.ts deleted file mode 100644 index 4a818ef4cae..00000000000 --- a/src/agents/openai-ws-message-conversion.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ResponseObject } from "./openai-ws-connection.js"; -import { buildAssistantMessageFromResponse, convertTools } from "./openai-ws-message-conversion.js"; - -describe("openai ws message conversion", () => { - it("preserves image_generate transparent-background guidance in OpenAI tool payloads", () => { - const [tool] = convertTools([ - { - name: "image_generate", - description: - 'Generate images. For transparent OpenAI backgrounds, use outputFormat="png" or "webp" and openai.background="transparent"; OpenClaw routes the default OpenAI image model to gpt-image-1.5 for that mode.', - parameters: { - type: "object", - properties: { - model: { - type: "string", - description: - "Optional provider/model override; use openai/gpt-image-1.5 for transparent OpenAI backgrounds.", - }, - outputFormat: { type: "string", enum: ["png", "jpeg", "webp"] }, - openai: { - type: "object", - properties: { - background: { - type: "string", - enum: ["transparent", "opaque", "auto"], - description: - "For transparent output use outputFormat png or webp; OpenClaw routes the default OpenAI image model to gpt-image-1.5 for this mode.", - }, - }, - }, - }, - }, - }, - ]); - - expect(tool?.description).toContain('openai.background="transparent"'); - expect(tool?.description).toContain("gpt-image-1.5"); - expect(JSON.stringify(tool?.parameters)).toContain("openai/gpt-image-1.5"); - expect(JSON.stringify(tool?.parameters)).toContain("transparent"); - }); - - it("preserves cached token usage from responses usage details", () => { - const response: ResponseObject = { - id: "resp_123", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5", - output: [ - { - type: "message", - id: "msg_123", - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: "hello" }], - }, - ], - usage: { - input_tokens: 120, - output_tokens: 30, - total_tokens: 250, - input_tokens_details: { cached_tokens: 100 }, - }, - }; - - const message = buildAssistantMessageFromResponse(response, { - api: "openai-responses", - provider: "openai", - id: "gpt-5", - }); - - expect(message.usage).toMatchObject({ - input: 20, - output: 30, - cacheRead: 100, - cacheWrite: 0, - totalTokens: 250, - }); - }); - - it("derives cache-inclusive total tokens when responses total is missing", () => { - const response: ResponseObject = { - id: "resp_124", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5", - output: [ - { - type: "message", - id: "msg_124", - role: "assistant", - status: "completed", - content: [{ type: "output_text", text: "hello" }], - }, - ], - usage: { - input_tokens: 120, - output_tokens: 30, - input_tokens_details: { cached_tokens: 100 }, - }, - }; - - const message = buildAssistantMessageFromResponse(response, { - api: "openai-responses", - provider: "openai", - id: "gpt-5", - }); - - expect(message.usage).toMatchObject({ - input: 20, - output: 30, - cacheRead: 100, - cacheWrite: 0, - totalTokens: 150, - }); - }); -}); diff --git a/src/agents/openai-ws-message-conversion.ts b/src/agents/openai-ws-message-conversion.ts deleted file mode 100644 index c2b30fd689d..00000000000 --- a/src/agents/openai-ws-message-conversion.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { Context, Message, StopReason } from "@mariozechner/pi-ai"; -import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { - encodeAssistantTextSignature, - normalizeAssistantPhase, - parseAssistantTextSignature, -} from "../shared/chat-message-content.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { - normalizeOpenAIStrictToolParameters, - resolveOpenAIStrictToolFlagForInventory, -} from "./openai-tool-schema.js"; -import type { - ContentPart, - FunctionToolDefinition, - InputItem, - OpenAIResponsesAssistantPhase, - ResponseObject, -} from "./openai-ws-connection.js"; -import { buildAssistantMessage, buildUsageWithNoCost } from "./stream-message-shared.js"; -import { normalizeUsage } from "./usage.js"; - -type AnyMessage = Message & { role: string; content: unknown }; -type AssistantMessageWithPhase = AssistantMessage & { phase?: OpenAIResponsesAssistantPhase }; -type ReplayModelInfo = { input?: ReadonlyArray; api?: string }; -type ReplayableReasoningItem = Extract; -type ReplayableReasoningSignature = { - type: "reasoning" | `reasoning.${string}`; - id?: string; - content?: unknown; - encrypted_content?: string; - summary?: unknown; -}; -type ToolCallReplayId = { callId: string; itemId?: string }; -type PlannedTurnInput = { - inputItems: InputItem[]; - previousResponseId?: string; - mode: "incremental_tool_results" | "full_context_initial" | "full_context_restart"; -}; - -function toNonEmptyString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = normalizeOptionalString(value) ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function supportsImageInput(modelOverride?: ReplayModelInfo): boolean { - return !Array.isArray(modelOverride?.input) || modelOverride.input.includes("image"); -} - -function usesOpenAICompletionsImageParts(modelOverride?: ReplayModelInfo): boolean { - return modelOverride?.api === "openai-completions"; -} - -function toImageUrlFromBase64(params: { mediaType?: string; data: string }): string { - return `data:${params.mediaType ?? "image/jpeg"};base64,${params.data}`; -} - -function contentToText(content: unknown): string { - if (typeof content === "string") { - return content; - } - if (!Array.isArray(content)) { - return ""; - } - return content - .filter( - (part): part is { type?: string; text?: string } => Boolean(part) && typeof part === "object", - ) - .filter( - (part) => - (part.type === "text" || part.type === "input_text" || part.type === "output_text") && - typeof part.text === "string", - ) - .map((part) => part.text as string) - .join(""); -} - -function contentToOpenAIParts(content: unknown, modelOverride?: ReplayModelInfo): ContentPart[] { - if (typeof content === "string") { - return content ? [{ type: "input_text", text: content }] : []; - } - if (!Array.isArray(content)) { - return []; - } - - const includeImages = supportsImageInput(modelOverride); - const useImageUrl = usesOpenAICompletionsImageParts(modelOverride); - const parts: ContentPart[] = []; - for (const part of content as Array<{ - type?: string; - text?: string; - data?: string; - mimeType?: string; - source?: unknown; - }>) { - if ( - (part.type === "text" || part.type === "input_text" || part.type === "output_text") && - typeof part.text === "string" - ) { - parts.push({ type: "input_text", text: part.text }); - continue; - } - - if (!includeImages) { - continue; - } - - if (part.type === "image" && typeof part.data === "string") { - if (useImageUrl) { - parts.push({ - type: "image_url", - image_url: { - url: toImageUrlFromBase64({ mediaType: part.mimeType, data: part.data }), - }, - }); - continue; - } - parts.push({ - type: "input_image", - source: { - type: "base64", - media_type: part.mimeType ?? "image/jpeg", - data: part.data, - }, - }); - continue; - } - - if ( - part.type === "input_image" && - part.source && - typeof part.source === "object" && - typeof (part.source as { type?: unknown }).type === "string" - ) { - const source = part.source as - | { type: "url"; url: string } - | { type: "base64"; media_type: string; data: string }; - if (useImageUrl) { - parts.push({ - type: "image_url", - image_url: { - url: - source.type === "url" - ? source.url - : toImageUrlFromBase64({ mediaType: source.media_type, data: source.data }), - }, - }); - continue; - } - parts.push({ - type: "input_image", - source, - }); - } - } - return parts; -} - -function isReplayableReasoningType(value: unknown): value is "reasoning" | `reasoning.${string}` { - return typeof value === "string" && (value === "reasoning" || value.startsWith("reasoning.")); -} - -function toReplayableReasoningId(value: unknown): string | null { - const id = toNonEmptyString(value); - return id && id.startsWith("rs_") ? id : null; -} - -function toReasoningSignature( - value: unknown, - options?: { requireReplayableId?: boolean }, -): ReplayableReasoningSignature | null { - if (!value || typeof value !== "object") { - return null; - } - const record = value as { - type?: unknown; - id?: unknown; - content?: unknown; - encrypted_content?: unknown; - summary?: unknown; - }; - if (!isReplayableReasoningType(record.type)) { - return null; - } - const reasoningId = toReplayableReasoningId(record.id); - if (options?.requireReplayableId && !reasoningId) { - return null; - } - return { - type: record.type, - ...(reasoningId ? { id: reasoningId } : {}), - ...(record.content !== undefined ? { content: record.content } : {}), - ...(typeof record.encrypted_content === "string" - ? { encrypted_content: record.encrypted_content } - : {}), - ...(record.summary !== undefined ? { summary: record.summary } : {}), - }; -} - -function encodeThinkingSignature(signature: ReplayableReasoningSignature): string { - return JSON.stringify(signature); -} - -function parseReasoningItem(value: unknown): ReplayableReasoningItem | null { - const signature = toReasoningSignature(value); - if (!signature) { - return null; - } - return { - type: "reasoning", - ...(signature.id ? { id: signature.id } : {}), - ...(signature.content !== undefined ? { content: signature.content } : {}), - ...(signature.encrypted_content !== undefined - ? { encrypted_content: signature.encrypted_content } - : {}), - ...(signature.summary !== undefined ? { summary: signature.summary } : {}), - }; -} - -function parseThinkingSignature(value: unknown): ReplayableReasoningItem | null { - if (typeof value !== "string" || value.trim().length === 0) { - return null; - } - try { - return parseReasoningItem(JSON.parse(value)); - } catch { - return null; - } -} - -function encodeToolCallReplayId(params: ToolCallReplayId): string { - return params.itemId ? `${params.callId}|${params.itemId}` : params.callId; -} - -function decodeToolCallReplayId(value: unknown): ToolCallReplayId | null { - const raw = toNonEmptyString(value); - if (!raw) { - return null; - } - const [callId, itemId] = raw.split("|", 2); - return { - callId, - ...(itemId ? { itemId } : {}), - }; -} - -function extractReasoningSummaryText(value: unknown): string { - if (typeof value === "string") { - return value.trim(); - } - if (!Array.isArray(value)) { - return ""; - } - return value - .map((item) => { - if (typeof item === "string") { - return item.trim(); - } - if (!item || typeof item !== "object") { - return ""; - } - const record = item as { text?: unknown }; - return normalizeOptionalString(record.text) ?? ""; - }) - .filter(Boolean) - .join("\n") - .trim(); -} - -function extractResponseReasoningText(item: unknown): string { - if (!item || typeof item !== "object") { - return ""; - } - const record = item as { summary?: unknown; content?: unknown }; - const summaryText = extractReasoningSummaryText(record.summary); - if (summaryText) { - return summaryText; - } - if (typeof record.content === "string") { - return normalizeOptionalString(record.content) ?? ""; - } - if (Array.isArray(record.content)) { - return record.content - .map((part) => { - if (typeof part === "string") { - return part.trim(); - } - if (!part || typeof part !== "object") { - return ""; - } - return normalizeOptionalString((part as { text?: unknown }).text) ?? ""; - }) - .filter(Boolean) - .join("\n") - .trim(); - } - return ""; -} - -export function convertTools( - tools: Context["tools"], - options?: { strict?: boolean | null }, -): FunctionToolDefinition[] { - if (!tools || tools.length === 0) { - return []; - } - const strict = resolveOpenAIStrictToolFlagForInventory(tools, options?.strict); - return tools.map((tool) => { - return { - type: "function" as const, - name: tool.name, - description: typeof tool.description === "string" ? tool.description : undefined, - parameters: normalizeOpenAIStrictToolParameters( - tool.parameters ?? {}, - strict === true, - ) as Record, - ...(strict === undefined ? {} : { strict }), - }; - }); -} - -export function planTurnInput(params: { - context: Context; - model: ReplayModelInfo; - previousResponseId: string | null; - lastContextLength: number; -}): PlannedTurnInput { - if (params.previousResponseId && params.lastContextLength > 0) { - const newMessages = params.context.messages.slice(params.lastContextLength); - const toolResults = newMessages.filter( - (message) => (message as AnyMessage).role === "toolResult", - ); - if (toolResults.length > 0) { - return { - mode: "incremental_tool_results", - previousResponseId: params.previousResponseId, - inputItems: convertMessagesToInputItems(toolResults, params.model), - }; - } - return { - mode: "full_context_restart", - inputItems: convertMessagesToInputItems(params.context.messages, params.model), - }; - } - - return { - mode: "full_context_initial", - inputItems: convertMessagesToInputItems(params.context.messages, params.model), - }; -} - -export function convertMessagesToInputItems( - messages: Message[], - modelOverride?: ReplayModelInfo, -): InputItem[] { - const items: InputItem[] = []; - - for (const msg of messages) { - const m = msg as AnyMessage & { - phase?: unknown; - toolCallId?: unknown; - toolUseId?: unknown; - }; - - if (m.role === "user") { - const parts = contentToOpenAIParts(m.content, modelOverride); - if (parts.length === 0) { - continue; - } - items.push({ - type: "message", - role: "user", - content: - parts.length === 1 && parts[0]?.type === "input_text" - ? (parts[0] as { type: "input_text"; text: string }).text - : parts, - }); - continue; - } - - if (m.role === "assistant") { - const content = m.content; - const assistantMessagePhase = normalizeAssistantPhase(m.phase); - if (Array.isArray(content)) { - const textParts: string[] = []; - let currentTextPhase: OpenAIResponsesAssistantPhase | undefined; - const hasExplicitBlockPhase = content.some((block) => { - if (!block || typeof block !== "object") { - return false; - } - const record = block as { type?: unknown; textSignature?: unknown }; - return ( - record.type === "text" && - Boolean(parseAssistantTextSignature(record.textSignature)?.phase) - ); - }); - const pushAssistantText = (phase?: OpenAIResponsesAssistantPhase) => { - if (textParts.length === 0) { - return; - } - items.push({ - type: "message", - role: "assistant", - content: textParts.join(""), - ...(phase ? { phase } : {}), - }); - textParts.length = 0; - }; - - for (const block of content as Array<{ - type?: string; - text?: string; - textSignature?: unknown; - id?: unknown; - name?: unknown; - arguments?: unknown; - thinkingSignature?: unknown; - }>) { - if (block.type === "text" && typeof block.text === "string") { - const parsedSignature = parseAssistantTextSignature(block.textSignature); - const blockPhase = - parsedSignature?.phase ?? - (parsedSignature?.id - ? assistantMessagePhase - : hasExplicitBlockPhase - ? undefined - : assistantMessagePhase); - if (textParts.length > 0 && blockPhase !== currentTextPhase) { - pushAssistantText(currentTextPhase); - } - textParts.push(block.text); - currentTextPhase = blockPhase; - continue; - } - - if (block.type === "thinking") { - pushAssistantText(currentTextPhase); - const reasoningItem = parseThinkingSignature(block.thinkingSignature); - if (reasoningItem) { - items.push(reasoningItem); - } - continue; - } - - if (block.type !== "toolCall") { - continue; - } - - pushAssistantText(currentTextPhase); - const replayId = decodeToolCallReplayId(block.id); - const toolName = toNonEmptyString(block.name); - if (!replayId || !toolName) { - continue; - } - items.push({ - type: "function_call", - ...(replayId.itemId ? { id: replayId.itemId } : {}), - call_id: replayId.callId, - name: toolName, - arguments: - typeof block.arguments === "string" - ? block.arguments - : JSON.stringify(block.arguments ?? {}), - }); - } - - pushAssistantText(currentTextPhase); - continue; - } - - const text = contentToText(content); - if (!text) { - continue; - } - items.push({ - type: "message", - role: "assistant", - content: text, - ...(assistantMessagePhase ? { phase: assistantMessagePhase } : {}), - }); - continue; - } - - if (m.role !== "toolResult") { - continue; - } - - const toolCallId = toNonEmptyString(m.toolCallId) ?? toNonEmptyString(m.toolUseId); - if (!toolCallId) { - continue; - } - const replayId = decodeToolCallReplayId(toolCallId); - if (!replayId) { - continue; - } - const parts = Array.isArray(m.content) ? contentToOpenAIParts(m.content, modelOverride) : []; - const textOutput = contentToText(m.content); - const imageParts = parts.filter( - (part) => part.type === "input_image" || part.type === "image_url", - ); - items.push({ - type: "function_call_output", - call_id: replayId.callId, - output: textOutput || (imageParts.length > 0 ? "(see attached image)" : ""), - }); - if (imageParts.length > 0) { - items.push({ - type: "message", - role: "user", - content: [ - { type: "input_text", text: "Attached image(s) from tool result:" }, - ...imageParts, - ], - }); - } - } - - return items; -} - -export function buildAssistantMessageFromResponse( - response: ResponseObject, - modelInfo: { api: string; provider: string; id: string }, -): AssistantMessage { - const content: AssistantMessage["content"] = []; - const assistantMessageOutputs = (response.output ?? []).filter( - (item): item is Extract => - item.type === "message", - ); - const hasExplicitPhasedAssistantText = assistantMessageOutputs.some((item) => { - const itemPhase = normalizeAssistantPhase(item.phase); - return Boolean( - itemPhase && item.content?.some((part) => part.type === "output_text" && Boolean(part.text)), - ); - }); - const hasFinalAnswerText = assistantMessageOutputs.some((item) => { - if (normalizeAssistantPhase(item.phase) !== "final_answer") { - return false; - } - return item.content?.some((part) => part.type === "output_text" && Boolean(part.text)) ?? false; - }); - const includedAssistantPhases = new Set(); - let hasIncludedUnphasedAssistantText = false; - - for (const item of response.output ?? []) { - if (item.type === "message") { - const itemPhase = normalizeAssistantPhase(item.phase); - for (const part of item.content ?? []) { - if (part.type === "output_text" && part.text) { - const shouldIncludeText = hasFinalAnswerText - ? itemPhase === "final_answer" - : hasExplicitPhasedAssistantText - ? itemPhase === undefined - : true; - if (!shouldIncludeText) { - continue; - } - if (itemPhase) { - includedAssistantPhases.add(itemPhase); - } else { - hasIncludedUnphasedAssistantText = true; - } - content.push({ - type: "text", - text: part.text, - textSignature: encodeAssistantTextSignature({ - id: item.id, - ...(itemPhase ? { phase: itemPhase } : {}), - }), - }); - } - } - } else if (item.type === "function_call") { - const toolName = toNonEmptyString(item.name); - if (!toolName) { - continue; - } - const callId = toNonEmptyString(item.call_id); - const itemId = toNonEmptyString(item.id); - content.push({ - type: "toolCall", - id: encodeToolCallReplayId({ - callId: callId ?? `call_${randomUUID()}`, - itemId: itemId ?? undefined, - }), - name: toolName, - arguments: (() => { - try { - return JSON.parse(item.arguments) as Record; - } catch { - return item.arguments as unknown as Record; - } - })(), - }); - } else { - if (!isReplayableReasoningType(item.type)) { - continue; - } - const reasoningSignature = toReasoningSignature(item, { requireReplayableId: true }); - const reasoning = extractResponseReasoningText(item); - if (!reasoning && !reasoningSignature) { - continue; - } - content.push({ - type: "thinking", - thinking: reasoning, - ...(reasoningSignature - ? { thinkingSignature: encodeThinkingSignature(reasoningSignature) } - : {}), - } as AssistantMessage["content"][number]); - } - } - - const hasToolCalls = content.some((part) => part.type === "toolCall"); - const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop"; - const normalizedUsage = normalizeUsage(response.usage); - const rawTotalTokens = normalizedUsage?.total; - const resolvedTotalTokens = - rawTotalTokens && rawTotalTokens > 0 - ? rawTotalTokens - : (normalizedUsage?.input ?? 0) + - (normalizedUsage?.output ?? 0) + - (normalizedUsage?.cacheRead ?? 0) + - (normalizedUsage?.cacheWrite ?? 0); - - const message = buildAssistantMessage({ - model: modelInfo, - content, - stopReason, - usage: buildUsageWithNoCost({ - input: normalizedUsage?.input ?? 0, - output: normalizedUsage?.output ?? 0, - cacheRead: normalizedUsage?.cacheRead ?? 0, - cacheWrite: normalizedUsage?.cacheWrite ?? 0, - totalTokens: resolvedTotalTokens > 0 ? resolvedTotalTokens : undefined, - }), - }); - - const finalAssistantPhase = - includedAssistantPhases.size === 1 && !hasIncludedUnphasedAssistantText - ? [...includedAssistantPhases][0] - : undefined; - - return finalAssistantPhase - ? ({ ...message, phase: finalAssistantPhase } as AssistantMessageWithPhase) - : message; -} - -export function convertResponseToInputItems( - response: ResponseObject, - modelInfo: { api: string; provider: string; id: string; input?: ReadonlyArray }, -): InputItem[] { - return convertMessagesToInputItems( - [buildAssistantMessageFromResponse(response, modelInfo)] as Message[], - modelInfo, - ); -} diff --git a/src/agents/openai-ws-request.ts b/src/agents/openai-ws-request.ts deleted file mode 100644 index ff475ef6802..00000000000 --- a/src/agents/openai-ws-request.ts +++ /dev/null @@ -1,212 +0,0 @@ -import type { StreamFn } from "@mariozechner/pi-agent-core"; -import { readStringValue } from "../shared/string-coerce.js"; -import { mapOpenAIReasoningEffortForModel } from "./openai-reasoning-compat.js"; -import { normalizeOpenAIReasoningEffort } from "./openai-reasoning-effort.js"; -import { resolveOpenAITextVerbosity } from "./openai-text-verbosity.js"; -import type { - FunctionToolDefinition, - InputItem, - ResponseCreateEvent, - WarmUpEvent, -} from "./openai-ws-types.js"; -import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js"; -import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; - -type WsModel = Parameters[0]; -type WsContext = Parameters[1]; -type WsOptions = Parameters[2] & { - temperature?: number; - maxTokens?: number; - topP?: number; - toolChoice?: unknown; - textVerbosity?: string; - text_verbosity?: string; - reasoning?: string; - reasoningEffort?: string; - reasoningSummary?: string; -}; - -interface PlannedWsTurnInput { - inputItems: InputItem[]; - previousResponseId?: string; -} - -type PlannedWsRequestPayload = { - mode: "full_context" | "incremental"; - payload: ResponseCreateEvent; -}; - -function stringifyStable(value: unknown): string { - if (value === undefined) { - return ""; - } - if (value === null || typeof value !== "object") { - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map((entry) => stringifyStable(entry)).join(",")}]`; - } - const entries = Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .toSorted(([left], [right]) => left.localeCompare(right)); - return `{${entries - .map(([key, entry]) => `${JSON.stringify(key)}:${stringifyStable(entry)}`) - .join(",")}}`; -} - -function payloadWithoutIncrementalFields(payload: ResponseCreateEvent): Record { - const { - input: _input, - metadata: _metadata, - previous_response_id: _previousResponseId, - ...rest - } = payload; - return rest; -} - -function payloadFieldsMatch(left: ResponseCreateEvent, right: ResponseCreateEvent): boolean { - return ( - stringifyStable(payloadWithoutIncrementalFields(left)) === - stringifyStable(payloadWithoutIncrementalFields(right)) - ); -} - -function inputItemsStartWith(input: InputItem[], baseline: InputItem[]): boolean { - if (baseline.length > input.length) { - return false; - } - return baseline.every((item, index) => stringifyStable(item) === stringifyStable(input[index])); -} - -export function planOpenAIWebSocketRequestPayload(params: { - fullPayload: ResponseCreateEvent; - previousRequestPayload?: ResponseCreateEvent; - previousResponseId?: string | null; - previousResponseInputItems?: InputItem[]; -}): PlannedWsRequestPayload { - const fullInputItems = Array.isArray(params.fullPayload.input) ? params.fullPayload.input : []; - const previousInputItems = Array.isArray(params.previousRequestPayload?.input) - ? params.previousRequestPayload.input - : []; - const previousResponseInputItems = params.previousResponseInputItems ?? []; - - if ( - params.previousResponseId && - params.previousRequestPayload && - payloadFieldsMatch(params.fullPayload, params.previousRequestPayload) - ) { - const baseline = [...previousInputItems, ...previousResponseInputItems]; - if (inputItemsStartWith(fullInputItems, baseline)) { - return { - mode: "incremental", - payload: { - ...params.fullPayload, - previous_response_id: params.previousResponseId, - input: fullInputItems.slice(baseline.length), - }, - }; - } - } - - const { previous_response_id: _previousResponseId, ...payload } = params.fullPayload; - return { mode: "full_context", payload }; -} - -export function buildOpenAIWebSocketWarmUpPayload(params: { - model: string; - tools?: FunctionToolDefinition[]; - instructions?: string; - metadata?: Record; -}): WarmUpEvent { - return { - type: "response.create", - generate: false, - model: params.model, - input: [], - ...(params.tools?.length ? { tools: params.tools } : {}), - ...(params.instructions ? { instructions: params.instructions } : {}), - ...(params.metadata ? { metadata: params.metadata } : {}), - }; -} - -export function buildOpenAIWebSocketResponseCreatePayload(params: { - model: WsModel; - context: WsContext; - options?: WsOptions; - turnInput: PlannedWsTurnInput; - tools: FunctionToolDefinition[]; - metadata?: Record; -}): ResponseCreateEvent { - const extraParams: Record = {}; - const streamOpts = params.options; - - if (streamOpts?.temperature !== undefined) { - extraParams.temperature = streamOpts.temperature; - } - if (streamOpts?.maxTokens !== undefined) { - extraParams.max_output_tokens = streamOpts.maxTokens; - } - if (streamOpts?.topP !== undefined) { - extraParams.top_p = streamOpts.topP; - } - if (streamOpts?.toolChoice !== undefined) { - extraParams.tool_choice = streamOpts.toolChoice; - } - - const reasoningEffort = mapOpenAIReasoningEffortForModel({ - model: params.model, - effort: - streamOpts?.reasoningEffort ?? - streamOpts?.reasoning ?? - (params.model.reasoning ? "high" : undefined), - }); - if (reasoningEffort || streamOpts?.reasoningSummary) { - const reasoning: { effort?: string; summary?: string } = {}; - if (reasoningEffort !== undefined) { - reasoning.effort = normalizeOpenAIReasoningEffort(reasoningEffort); - } - if (reasoningEffort !== "none" && streamOpts?.reasoningSummary !== undefined) { - reasoning.summary = streamOpts.reasoningSummary; - } - extraParams.reasoning = reasoning; - if (reasoning.effort && reasoning.effort !== "none") { - extraParams.include = ["reasoning.encrypted_content"]; - } - } - - const textVerbosity = resolveOpenAITextVerbosity( - streamOpts as Record | undefined, - ); - if (textVerbosity !== undefined) { - const existingText = - extraParams.text && typeof extraParams.text === "object" - ? (extraParams.text as Record) - : {}; - extraParams.text = { ...existingText, verbosity: textVerbosity }; - } - - const supportsResponsesStoreField = resolveProviderRequestPolicyConfig({ - provider: readStringValue(params.model.provider), - api: readStringValue(params.model.api), - baseUrl: readStringValue(params.model.baseUrl), - compat: (params.model as { compat?: { supportsStore?: boolean } }).compat, - capability: "llm", - transport: "websocket", - }).capabilities.supportsResponsesStoreField; - - return { - type: "response.create", - model: params.model.id, - ...(supportsResponsesStoreField ? { store: false } : {}), - input: params.turnInput.inputItems, - instructions: params.context.systemPrompt - ? stripSystemPromptCacheBoundary(params.context.systemPrompt) - : undefined, - tools: params.tools.length > 0 ? params.tools : undefined, - ...(params.turnInput.previousResponseId - ? { previous_response_id: params.turnInput.previousResponseId } - : {}), - ...(params.metadata ? { metadata: params.metadata } : {}), - ...extraParams, - }; -} diff --git a/src/agents/openai-ws-stream.e2e.test.ts b/src/agents/openai-ws-stream.e2e.test.ts deleted file mode 100644 index d3bd5ae0c6b..00000000000 --- a/src/agents/openai-ws-stream.e2e.test.ts +++ /dev/null @@ -1,595 +0,0 @@ -/** - * End-to-end integration tests for OpenAI WebSocket streaming. - * - * These tests hit the real OpenAI Responses API over WebSocket and verify - * the full request/response lifecycle including: - * - Connection establishment and session reuse - * - Context options forwarding (temperature) - * - Graceful fallback to HTTP on connection failure - * - Connection lifecycle cleanup via releaseWsSession - * - * Run manually with a valid OPENAI_API_KEY: - * OPENCLAW_LIVE_TEST=1 pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts - * - * This now runs only in the keyed live/release lanes. - */ - -import type { - AssistantMessage, - AssistantMessageEvent, - AssistantMessageEventStream, - Context, -} from "@mariozechner/pi-ai"; -import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { isLiveTestEnabled } from "./live-test-helpers.js"; -import type { OutputItem, ResponseObject } from "./openai-ws-connection.js"; - -const API_KEY = process.env.OPENAI_API_KEY; -const LIVE = isLiveTestEnabled(["OPENAI_LIVE_TEST"]) && !!API_KEY; -const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_MODEL || "gpt-5.4"; -const testFn = LIVE ? it : it.skip; - -type OpenAIWsStreamModule = typeof import("./openai-ws-stream.js"); -type OpenAIWsConnectionModule = typeof import("./openai-ws-connection.js"); -type StreamFactory = OpenAIWsStreamModule["createOpenAIWebSocketStreamFn"]; -type StreamReturn = ReturnType>; -let openAIWsStreamModule: OpenAIWsStreamModule; -let openAIWsConnectionModule: OpenAIWsConnectionModule; - -const model = { - api: "openai-responses" as const, - provider: "openai", - id: LIVE_MODEL_ID, - name: LIVE_MODEL_ID, - contextWindow: 128_000, - maxTokens: 4_096, - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, -} as unknown as Parameters>[0]; - -type StreamFnParams = Parameters>; -function makeContext(userMessage: string): StreamFnParams[1] { - return { - systemPrompt: "You are a helpful assistant. Reply in one sentence.", - messages: [{ role: "user" as const, content: userMessage }], - tools: [], - } as unknown as StreamFnParams[1]; -} - -function makeToolContext(userMessage: string): StreamFnParams[1] { - return { - systemPrompt: "You are a precise assistant. Follow tool instructions exactly.", - messages: [{ role: "user" as const, content: userMessage }], - tools: [ - { - name: "noop", - description: "Return the supplied tool result to the user.", - parameters: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }, - ], - } as unknown as Context; -} - -function makeToolResultMessage( - callId: string, - output: string, -): StreamFnParams[1]["messages"][number] { - return { - role: "toolResult" as const, - toolCallId: callId, - toolName: "noop", - content: [{ type: "text" as const, text: output }], - isError: false, - timestamp: Date.now(), - } as unknown as StreamFnParams[1]["messages"][number]; -} - -async function runWebsocketToolFollowupTurn(params: { - streamFn: ReturnType; - context: StreamFnParams[1]; - firstDone: AssistantMessage; - toolCallId: string; - output: string; -}) { - const secondContext = { - ...params.context, - messages: [ - ...params.context.messages, - params.firstDone, - makeToolResultMessage(params.toolCallId, params.output), - ], - } as unknown as StreamFnParams[1]; - - return expectDone( - await collectEvents( - params.streamFn(model, secondContext, { - transport: "websocket", - maxTokens: 16, - reasoningEffort: "none", - textVerbosity: "low", - } as unknown as StreamFnParams[2]), - ), - ); -} - -async function collectEvents(stream: StreamReturn): Promise { - const events: AssistantMessageEvent[] = []; - const resolvedStream: AssistantMessageEventStream = await stream; - for await (const event of resolvedStream) { - events.push(event); - } - return events; -} - -function expectDone(events: AssistantMessageEvent[]): AssistantMessage { - const done = events.find((event) => event.type === "done")?.message; - if (!done) { - throw new MissingDoneEventError(events); - } - return done; -} - -class MissingDoneEventError extends Error { - constructor(events: AssistantMessageEvent[]) { - super( - `OpenAI WebSocket stream ended without a done event; event types: ${events.map((event) => event.type).join(", ") || ""}`, - ); - this.name = "MissingDoneEventError"; - } -} - -class WebSocketLiveAttemptTimeoutError extends Error { - constructor(label: string, timeoutMs: number) { - super(`${label} timed out after ${timeoutMs}ms`); - this.name = "WebSocketLiveAttemptTimeoutError"; - } -} - -async function withWebSocketLiveAttemptTimeout( - label: string, - timeoutMs: number, - run: () => Promise, -): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - run(), - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new WebSocketLiveAttemptTimeoutError(label, timeoutMs)), - timeoutMs, - ); - timer.unref?.(); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -function isTransientWebSocketLiveError(error: unknown): boolean { - if (error instanceof MissingDoneEventError) { - return true; - } - if (!(error instanceof Error)) { - return false; - } - const message = error.message.toLowerCase(); - return ( - message.includes("websocket closed") || - message.includes("websocket stream ended") || - message.includes("timeout") || - message.includes("aborted") - ); -} - -function assistantText(message: AssistantMessage): string { - return message.content - .filter((block) => block.type === "text") - .map((block) => block.text) - .join(""); -} - -function extractThinkingBlocks(message: AssistantMessage) { - return message.content.filter((block) => block.type === "thinking") as Array<{ - type: "thinking"; - thinking: string; - thinkingSignature?: string; - }>; -} - -function extractToolCall(message: AssistantMessage) { - return message.content.find((block) => block.type === "toolCall") as - | { type: "toolCall"; id: string; name: string } - | undefined; -} - -function requireToolCall(message: AssistantMessage) { - const toolCall = extractToolCall(message); - if (!toolCall?.id) { - throw new Error("expected assistant tool call with id"); - } - return toolCall; -} - -function requireCompletedResponse(responses: ResponseObject[], index: number): ResponseObject { - const response = responses[index]; - if (!response) { - throw new Error(`expected completed OpenAI response at index ${index}`); - } - return response; -} - -function requireRawToolCall( - response: ResponseObject, -): Extract { - const rawToolCall = response.output.find( - (item): item is Extract => item.type === "function_call", - ); - if (!rawToolCall) { - throw new Error("expected raw function_call output item"); - } - return rawToolCall; -} - -function parseReasoningSignature(value: string | undefined) { - if (!value) { - return null; - } - try { - return JSON.parse(value) as { - id?: unknown; - type?: unknown; - content?: unknown; - encrypted_content?: unknown; - summary?: unknown; - }; - } catch { - return null; - } -} - -function extractReasoningText(item: { summary?: unknown; content?: unknown }): string { - const summary = item.summary; - if (typeof summary === "string") { - return summary.trim(); - } - if (Array.isArray(summary)) { - const textParts: string[] = []; - for (const part of summary) { - const text = - typeof part === "string" - ? part.trim() - : part && - typeof part === "object" && - typeof (part as { text?: unknown }).text === "string" - ? (part as { text: string }).text.trim() - : ""; - if (text.length > 0) { - textParts.push(text); - } - } - const summaryText = textParts.join("\n").trim(); - if (summaryText) { - return summaryText; - } - } - return typeof item.content === "string" ? item.content.trim() : ""; -} - -function toExpectedReasoningSignature(item: { id?: string; type: string }) { - const record = item as { - content?: unknown; - encrypted_content?: unknown; - summary?: unknown; - }; - return { - type: item.type, - ...(typeof item.id === "string" && item.id.startsWith("rs_") ? { id: item.id } : {}), - ...(record.content !== undefined ? { content: record.content } : {}), - ...(typeof record.encrypted_content === "string" - ? { encrypted_content: record.encrypted_content } - : {}), - ...(record.summary !== undefined ? { summary: record.summary } : {}), - }; -} - -/** Each test gets a unique session ID to avoid cross-test interference. */ -const sessions: string[] = []; -function freshSession(name: string): string { - const id = `e2e-${name}-${Date.now()}`; - sessions.push(id); - return id; -} - -describe("OpenAI WebSocket e2e", () => { - beforeEach(async () => { - vi.resetModules(); - vi.doMock("@mariozechner/pi-ai", async () => { - const actual = - await vi.importActual("@mariozechner/pi-ai"); - return { - ...actual, - createAssistantMessageEventStream: actual.createAssistantMessageEventStream, - }; - }); - openAIWsConnectionModule = await import("./openai-ws-connection.js"); - openAIWsStreamModule = await import("./openai-ws-stream.js"); - }); - - afterEach(() => { - for (const id of sessions) { - openAIWsStreamModule.releaseWsSession(id); - } - openAIWsStreamModule.__testing.setDepsForTest(); - sessions.length = 0; - }); - - testFn( - "completes a single-turn request over WebSocket", - async () => { - const sid = freshSession("single"); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); - const stream = streamFn(model, makeContext("What is 2+2?"), { transport: "websocket" }); - const done = expectDone(await collectEvents(stream)); - - expect(done.content.length).toBeGreaterThan(0); - const text = assistantText(done); - expect(text).toMatch(/4/); - }, - 45_000, - ); - - testFn( - "forwards temperature option to the API", - async () => { - const sid = freshSession("temp"); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); - const stream = streamFn(model, makeContext("Pick a random number between 1 and 1000."), { - transport: "websocket", - temperature: 0.8, - }); - const events = await collectEvents(stream); - - // Stream must complete (done or error with fallback) — must NOT hang. - const hasTerminal = events.some((e) => e.type === "done" || e.type === "error"); - expect(hasTerminal).toBe(true); - }, - 45_000, - ); - - testFn( - "reuses the websocket session for tool-call follow-up turns", - async () => { - const sid = freshSession("tool-roundtrip"); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); - const firstContext = makeToolContext( - "Call the tool `noop` with {}. After the tool result arrives, reply with exactly the tool output and nothing else.", - ); - const firstEvents = await collectEvents( - streamFn(model, firstContext, { - transport: "websocket", - toolChoice: "required", - maxTokens: 16, - reasoningEffort: "none", - textVerbosity: "low", - } as unknown as StreamFnParams[2]), - ); - const firstDone = expectDone(firstEvents); - const toolCall = requireToolCall(firstDone); - expect(toolCall.name).toBe("noop"); - - const secondDone = await runWebsocketToolFollowupTurn({ - streamFn, - context: firstContext, - firstDone, - toolCallId: toolCall.id, - output: "TOOL_OK", - }); - - expect(assistantText(secondDone)).toMatch(/TOOL_OK/); - }, - // Live CI can spend more than a minute waiting for a stable follow-up turn - // when websocket reuse and tool callbacks contend with other provider lanes. - 120_000, - ); - - testFn( - "surfaces replay-safe reasoning metadata on websocket tool turns", - async () => { - let lastError: unknown; - for (let attempt = 0; attempt < 2; attempt += 1) { - const sid = freshSession(`tool-reasoning-${attempt}`); - try { - await withWebSocketLiveAttemptTimeout( - `OpenAI WebSocket reasoning metadata attempt ${attempt + 1}`, - 75_000, - async () => { - const completedResponses: ResponseObject[] = []; - openAIWsStreamModule.__testing.setDepsForTest({ - createManager: (options) => { - const manager = new openAIWsConnectionModule.OpenAIWebSocketManager(options); - manager.onMessage((event) => { - if (event.type === "response.completed") { - completedResponses.push(event.response); - } - }); - return manager; - }, - }); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); - const firstContext = makeToolContext( - "Think carefully, call the tool `noop` with {} first, then after the tool result reply with exactly TOOL_OK.", - ); - const firstDone = expectDone( - await collectEvents( - streamFn(model, firstContext, { - transport: "websocket", - toolChoice: "required", - reasoningEffort: "high", - reasoningSummary: "detailed", - maxTokens: 256, - } as unknown as StreamFnParams[2]), - ), - ); - - const firstResponse = requireCompletedResponse(completedResponses, 0); - - const rawReasoningItems = firstResponse.output.filter( - ( - item, - ): item is Extract => - item.type === "reasoning" || item.type.startsWith("reasoning."), - ); - const replayableReasoningItems = rawReasoningItems.filter( - (item) => typeof item.id === "string" && item.id.startsWith("rs_"), - ); - const thinkingBlocks = extractThinkingBlocks(firstDone); - expect(thinkingBlocks).toHaveLength(replayableReasoningItems.length); - expect(thinkingBlocks.map((block) => block.thinking)).toEqual( - replayableReasoningItems.map((item) => extractReasoningText(item)), - ); - expect( - thinkingBlocks.map((block) => parseReasoningSignature(block.thinkingSignature)), - ).toEqual(replayableReasoningItems.map((item) => toExpectedReasoningSignature(item))); - - const rawToolCall = requireRawToolCall(firstResponse); - const toolCall = requireToolCall(firstDone); - expect(toolCall.name).toBe(rawToolCall.name); - expect(toolCall.id).toBe(`${rawToolCall.call_id}|${rawToolCall.id}`); - - const secondDone = await runWebsocketToolFollowupTurn({ - streamFn, - context: firstContext, - firstDone, - toolCallId: toolCall.id, - output: "TOOL_OK", - }); - - expect(assistantText(secondDone)).toMatch(/TOOL_OK/); - }, - ); - return; - } catch (error) { - lastError = error; - openAIWsStreamModule.releaseWsSession(sid); - openAIWsStreamModule.__testing.setDepsForTest(); - if (!isTransientWebSocketLiveError(error) || attempt === 1) { - throw error; - } - } - } - throw lastError; - }, - 120_000, - ); - - testFn( - "supports websocket warm-up before the first request", - async () => { - const sid = freshSession("warmup"); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); - const events = await collectEvents( - streamFn(model, makeContext("Reply with exactly the single word warmed."), { - transport: "websocket", - openaiWsWarmup: true, - maxTokens: 8, - reasoningEffort: "none", - textVerbosity: "low", - } as unknown as StreamFnParams[2]), - ); - - const hasTerminal = events.some((event) => event.type === "done" || event.type === "error"); - expect(hasTerminal).toBe(true); - - const done = events.find((event) => event.type === "done")?.message; - if (done) { - expect(assistantText(done).toLowerCase()).toContain("warmed"); - } - }, - // This transport check does not need expensive reasoning. Keep the timeout - // generous for CI jitter, but force a minimal response shape so the first - // websocket request stays bounded. - 720_000, - ); - - testFn( - "session is tracked in registry during request", - async () => { - const sid = freshSession("registry"); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); - - expect(openAIWsStreamModule.hasWsSession(sid)).toBe(false); - - await collectEvents(streamFn(model, makeContext("Say hello."), { transport: "websocket" })); - - expect(openAIWsStreamModule.hasWsSession(sid)).toBe(true); - openAIWsStreamModule.releaseWsSession(sid); - expect(openAIWsStreamModule.hasWsSession(sid)).toBe(false); - }, - 45_000, - ); - - testFn( - "falls back to HTTP gracefully when websocket connect fails", - async () => { - const sid = freshSession("fallback"); - openAIWsStreamModule.__testing.setDepsForTest({ - createHttpFallbackStreamFn: () => - (() => { - const stream = createAssistantMessageEventStream(); - queueMicrotask(() => { - stream.push({ - type: "done", - reason: "stop", - message: { - role: "assistant", - content: [{ type: "text", text: "FALLBACK_OK" }], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: LIVE_MODEL_ID, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }, - }); - stream.end(); - }); - return stream; - }) as never, - }); - const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid, { - managerOptions: { - url: "ws://127.0.0.1:1", - maxRetries: 0, - backoffDelaysMs: [0], - }, - }); - const stream = streamFn(model, makeContext("Reply with exactly FALLBACK_OK."), { - maxTokens: 8, - reasoningEffort: "none", - textVerbosity: "low", - } as unknown as StreamFnParams[2]); - const events = await collectEvents(stream); - - const done = expectDone(events); - expect(assistantText(done)).toContain("FALLBACK_OK"); - }, - 45_000, - ); -}); diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts deleted file mode 100644 index 3db95513511..00000000000 --- a/src/agents/openai-ws-stream.test.ts +++ /dev/null @@ -1,4320 +0,0 @@ -/** - * Unit tests for openai-ws-stream.ts - * - * Covers: - * - Message format converters (convertMessagesToInputItems, convertTools) - * - Response → AssistantMessage parser (buildAssistantMessageFromResponse) - * - createOpenAIWebSocketStreamFn behaviour (connect, send, receive, fallback) - * - Session registry helpers (releaseWsSession, hasWsSession) - */ - -import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ResponseObject } from "./openai-ws-connection.js"; -import { - buildOpenAIWebSocketResponseCreatePayload, - planOpenAIWebSocketRequestPayload, -} from "./openai-ws-request.js"; -import { - __testing as openAIWsStreamTesting, - buildAssistantMessageFromResponse, - convertMessagesToInputItems, - convertTools, - createOpenAIWebSocketStreamFn, - hasWsSession, - planTurnInput, - releaseWsSession, -} from "./openai-ws-stream.js"; -import type { InputItem, ResponseCreateEvent } from "./openai-ws-types.js"; -import { log } from "./pi-embedded-runner/logger.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; - -function countMatching(items: readonly T[], predicate: (item: T) => boolean) { - let count = 0; - for (const item of items) { - if (predicate(item)) { - count += 1; - } - } - return count; -} - -async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), timeoutMs); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Mock OpenAIWebSocketManager -// ───────────────────────────────────────────────────────────────────────────── - -// We mock the entire openai-ws-connection module so no real WebSocket is opened. -const { MockManager } = vi.hoisted(() => { - const { EventEmitter } = require("node:events") as typeof import("node:events"); - type AnyFn = (...args: unknown[]) => void; - - // Shared mutable flag so inner class can see it - let _globalConnectShouldFail = false; - let _globalSendFailuresRemaining = 0; - - class MockManager extends EventEmitter { - private _listeners: AnyFn[] = []; - private _previousResponseId: string | null = null; - private _connected = false; - private _broken = false; - private _lastCloseInfo: { code: number; reason: string; retryable: boolean } | null = null; - - sentEvents: unknown[] = []; - connectCallCount = 0; - connectApiKeys: string[] = []; - closeCallCount = 0; - options: unknown; - - // Allow tests to override connect/send behaviour - connectShouldFail = false; - sendShouldFail = false; - - constructor(options?: unknown) { - super(); - this.options = options; - } - - get previousResponseId(): string | null { - return this._previousResponseId; - } - - get lastCloseInfo(): { code: number; reason: string; retryable: boolean } | null { - return this._lastCloseInfo; - } - - async connect(_apiKey: string): Promise { - this.connectCallCount++; - this.connectApiKeys.push(_apiKey); - if (this.connectShouldFail || _globalConnectShouldFail) { - throw new Error("Mock connect failure"); - } - this._connected = true; - } - - isConnected(): boolean { - return this._connected && !this._broken; - } - - send(event: unknown): void { - if (!this._connected) { - throw new Error("cannot send — not connected"); - } - if (this.sendShouldFail || _globalSendFailuresRemaining > 0) { - if (_globalSendFailuresRemaining > 0) { - _globalSendFailuresRemaining--; - } - throw new Error("Mock send failure"); - } - this.sentEvents.push(event); - const maybeEvent = event as { type?: string; generate?: boolean; model?: string } | null; - // Auto-complete warm-up events so warm-up-enabled tests don't hang waiting - // for the warm-up terminal event. - if (maybeEvent?.type === "response.create" && maybeEvent.generate === false) { - queueMicrotask(() => { - this.simulateEvent({ - type: "response.completed", - response: makeResponseObject(`warmup-${Date.now()}`), - }); - }); - } - } - - warmUp(params: { model: string; tools?: unknown[]; instructions?: string }): void { - this.send({ - type: "response.create", - generate: false, - model: params.model, - ...(params.tools ? { tools: params.tools } : {}), - ...(params.instructions ? { instructions: params.instructions } : {}), - }); - } - - onMessage(handler: (event: unknown) => void): () => void { - this._listeners.push(handler as AnyFn); - return () => { - this._listeners = this._listeners.filter((l) => l !== handler); - }; - } - - close(): void { - this.closeCallCount++; - this._connected = false; - this._lastCloseInfo = { - code: 1000, - reason: "closed", - retryable: false, - }; - this.emit("close", 1000, "closed"); - } - - // Test helper: simulate WebSocket connection drop mid-request - simulateClose(code = 1006, reason = "connection lost"): void { - this._connected = false; - this._lastCloseInfo = { - code, - reason, - retryable: - code === 1001 || - code === 1005 || - code === 1006 || - code === 1011 || - code === 1012 || - code === 1013, - }; - this.emit("close", code, reason); - } - - // Test helper: simulate a server event - simulateEvent(event: unknown): void { - const maybeEvent = event as { type?: string; response?: { id?: string } }; - if (maybeEvent.type === "response.completed" && maybeEvent.response?.id) { - this._previousResponseId = maybeEvent.response.id; - } - for (const fn of this._listeners) { - fn(event); - } - } - - // Test helper: simulate connection being broken - simulateBroken(): void { - this._connected = false; - this._broken = true; - } - - // Test helper: set the previous response ID as if a turn completed - setPreviousResponseId(id: string): void { - this._previousResponseId = id; - } - - static lastInstance: MockManager | null = null; - static instances: MockManager[] = []; - - static reset(): void { - MockManager.lastInstance = null; - MockManager.instances = []; - } - } - - // Patch constructor to track instances - const OriginalMockManager = MockManager; - class TrackedMockManager extends OriginalMockManager { - constructor(...args: ConstructorParameters) { - super(...args); - TrackedMockManager.lastInstance = this; - TrackedMockManager.instances.push(this); - } - - static lastInstance: TrackedMockManager | null = null; - static instances: TrackedMockManager[] = []; - - /** Class-level flag: make ALL new instances fail on connect(). */ - static get globalConnectShouldFail(): boolean { - return _globalConnectShouldFail; - } - static set globalConnectShouldFail(v: boolean) { - _globalConnectShouldFail = v; - } - - static get globalSendFailuresRemaining(): number { - return _globalSendFailuresRemaining; - } - static set globalSendFailuresRemaining(v: number) { - _globalSendFailuresRemaining = v; - } - - static reset(): void { - TrackedMockManager.lastInstance = null; - TrackedMockManager.instances = []; - _globalConnectShouldFail = false; - _globalSendFailuresRemaining = 0; - } - } - - return { MockManager: TrackedMockManager }; -}); - -vi.mock("../plugins/provider-runtime.js", () => ({ - resolveProviderTransportTurnStateWithPlugin: () => undefined, - resolveProviderWebSocketSessionPolicyWithPlugin: () => undefined, -})); - -// Track if streamSimple (HTTP fallback) was called -const streamSimpleCalls: Array<{ model: unknown; context: unknown; options?: unknown }> = []; -const mockStreamSimple = vi.fn((model: unknown, context: unknown, options?: unknown) => { - streamSimpleCalls.push({ model, context, options }); - const stream = createAssistantMessageEventStream(); - queueMicrotask(() => { - const msg = makeFakeAssistantMessage("http fallback response"); - stream.push({ type: "done", reason: "stop", message: msg }); - stream.end(); - }); - return stream; -}); -const mockCreateHttpFallbackStreamFn = vi.fn(() => mockStreamSimple as never); - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** Resolve a StreamFn return value (which may be a Promise) to an AsyncIterable. */ -async function resolveStream( - stream: ReturnType>, -): Promise> { - return stream instanceof Promise ? await stream : stream; -} - -function requireValue(value: T | null | undefined, message: string): T { - if (value == null) { - throw new Error(message); - } - return value; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fixtures -// ───────────────────────────────────────────────────────────────────────────── - -type FakeMessage = - | { role: "user"; content: string | unknown[]; timestamp: number } - | { - role: "assistant"; - content: unknown[]; - phase?: "commentary" | "final_answer"; - stopReason: string; - api: string; - provider: string; - model: string; - usage: unknown; - timestamp: number; - } - | { - role: "toolResult"; - toolCallId: string; - toolName: string; - content: unknown[]; - isError: boolean; - timestamp: number; - }; - -function userMsg(text: string): FakeMessage { - return { role: "user", content: text, timestamp: 0 }; -} - -function assistantMsg( - textBlocks: string[], - toolCalls: Array<{ id: string; name: string; args: Record }> = [], - phase?: "commentary" | "final_answer", -): FakeMessage { - const content: unknown[] = []; - for (const t of textBlocks) { - content.push({ type: "text", text: t }); - } - for (const tc of toolCalls) { - content.push({ type: "toolCall", id: tc.id, name: tc.name, arguments: tc.args }); - } - return { - role: "assistant", - content, - phase, - stopReason: toolCalls.length > 0 ? "toolUse" : "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; -} - -function toolResultMsg(callId: string, output: string): FakeMessage { - return { - role: "toolResult", - toolCallId: callId, - toolName: "test_tool", - content: [{ type: "text", text: output }], - isError: false, - timestamp: 0, - }; -} - -function makeFakeAssistantMessage(text: string) { - return { - role: "assistant" as const, - content: [{ type: "text" as const, text }], - stopReason: "stop" as const, - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: { - input: 10, - output: 5, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 15, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - timestamp: Date.now(), - }; -} - -function makeResponseObject( - id: string, - outputText?: string, - toolCallName?: string, - phase?: "commentary" | "final_answer", -): ResponseObject { - const output: ResponseObject["output"] = []; - if (outputText) { - output.push({ - type: "message", - id: "item_1", - role: "assistant", - content: [{ type: "output_text", text: outputText }], - phase, - }); - } - if (toolCallName) { - output.push({ - type: "function_call", - id: "item_2", - call_id: "call_abc", - name: toolCallName, - arguments: '{"arg":"value"}', - }); - } - return { - id, - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output, - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test suite -// ───────────────────────────────────────────────────────────────────────────── - -describe("convertTools", () => { - it("returns empty array for undefined tools", () => { - expect(convertTools(undefined)).toStrictEqual([]); - }); - - it("returns empty array for empty tools", () => { - expect(convertTools([])).toStrictEqual([]); - }); - - it("converts tools to FunctionToolDefinition format", () => { - const tools = [ - { - name: "exec", - description: "Run a command", - parameters: { type: "object", properties: { cmd: { type: "string" } } }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0]); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: "function", - name: "exec", - description: "Run a command", - parameters: { type: "object", properties: { cmd: { type: "string" } } }, - }); - }); - - it("handles tools without description", () => { - const tools = [{ name: "ping", description: "", parameters: {} }]; - const result = convertTools(tools as Parameters[0]); - expect(result[0]?.name).toBe("ping"); - }); - - it("normalizes truly empty parameter schemas for parameter-free tools", () => { - const tools = [{ name: "ping", description: "No params", parameters: {} }]; - const result = convertTools(tools as Parameters[0]); - expect(result[0]?.parameters).toEqual({ - type: "object", - properties: {}, - }); - }); - - it("injects properties:{} for type:object schemas missing properties (MCP no-param tools)", () => { - const tools = [ - { name: "list_regions", description: "List AWS regions", parameters: { type: "object" } }, - ]; - const result = convertTools(tools as unknown as Parameters[0]); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - type: "function", - name: "list_regions", - description: "List AWS regions", - parameters: { type: "object", properties: {} }, - }); - }); - - it("adds missing top-level type for raw object-ish MCP schemas", () => { - const tools = [ - { - name: "query", - description: "Run a query", - parameters: { properties: { q: { type: "string" } }, required: ["q"] }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0]); - expect(result[0]?.parameters).toEqual({ - type: "object", - properties: { q: { type: "string" } }, - required: ["q"], - }); - }); - - it("flattens raw top-level anyOf MCP schemas into one object schema", () => { - const tools = [ - { - name: "dispatch", - description: "Dispatch an action", - parameters: { - anyOf: [ - { - type: "object", - properties: { action: { const: "ping" } }, - required: ["action"], - }, - { - type: "object", - properties: { - action: { const: "echo" }, - text: { type: "string" }, - }, - required: ["action", "text"], - }, - ], - }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0]); - expect(result[0]?.parameters).toEqual({ - type: "object", - properties: { - action: { type: "string", enum: ["ping", "echo"] }, - text: { type: "string" }, - }, - required: ["action"], - additionalProperties: true, - }); - }); - - it("leaves top-level allOf schemas unchanged", () => { - const tools = [ - { - name: "conditional", - description: "Conditional schema", - parameters: { - allOf: [{ type: "object", properties: { id: { type: "string" } } }], - }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0]); - expect(result[0]?.parameters).toEqual({ - allOf: [{ type: "object", properties: { id: { type: "string" } } }], - }); - }); - - it("preserves existing properties on type:object schemas", () => { - const tools = [ - { - name: "exec", - description: "Run a command", - parameters: { type: "object", properties: { cmd: { type: "string" } } }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0]); - expect(result[0]?.parameters).toEqual({ - type: "object", - properties: { cmd: { type: "string" } }, - }); - }); - - it("adds strict:true and required:[] for native strict-compatible no-param tools", () => { - const tools = [ - { - name: "ping", - description: "No params", - parameters: { type: "object", properties: {}, additionalProperties: false }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0], { - strict: true, - }); - - expect(result[0]).toEqual({ - type: "function", - name: "ping", - description: "No params", - parameters: { - type: "object", - properties: {}, - additionalProperties: false, - required: [], - }, - strict: true, - }); - }); - - it("falls back to strict:false for native tools with non-strict-compatible schemas", () => { - const tools = [ - { - name: "read", - description: "Read file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - additionalProperties: false, - }, - }, - ]; - const result = convertTools(tools as unknown as Parameters[0], { - strict: true, - }); - - expect(result[0]).toEqual({ - type: "function", - name: "read", - description: "Read file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - additionalProperties: false, - }, - strict: false, - }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── - -describe("convertMessagesToInputItems", () => { - it("converts a simple user text message", () => { - const items = convertMessagesToInputItems([userMsg("Hello!")] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ type: "message", role: "user", content: "Hello!" }); - }); - - it("uses image_url parts for OpenAI-compatible user images", () => { - const msg: FakeMessage = { - role: "user", - content: [ - { type: "text", text: "describe this" }, - { type: "image", mimeType: "image/png", data: "AAAA" }, - ], - timestamp: 0, - }; - const items = convertMessagesToInputItems( - [msg] as Parameters[0], - { api: "openai-completions", input: ["text", "image"] }, - ); - - expect(items).toEqual([ - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "describe this" }, - { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, - ], - }, - ]); - }); - - it("keeps input_image parts for Responses user images", () => { - const msg: FakeMessage = { - role: "user", - content: [{ type: "image", mimeType: "image/png", data: "AAAA" }], - timestamp: 0, - }; - const items = convertMessagesToInputItems( - [msg] as Parameters[0], - { api: "openai-responses", input: ["text", "image"] }, - ); - - expect(items).toEqual([ - { - type: "message", - role: "user", - content: [ - { - type: "input_image", - source: { type: "base64", media_type: "image/png", data: "AAAA" }, - }, - ], - }, - ]); - }); - - it("converts an assistant text-only message", () => { - const items = convertMessagesToInputItems([assistantMsg(["Hi there."])] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ type: "message", role: "assistant", content: "Hi there." }); - }); - - it("preserves assistant phase on replayed assistant messages", () => { - const items = convertMessagesToInputItems([ - assistantMsg(["Working on it."], [], "commentary"), - ] as Parameters[0]); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ - type: "message", - role: "assistant", - content: "Working on it.", - phase: "commentary", - }); - }); - - it("converts an assistant message with a tool call", () => { - const msg = assistantMsg( - ["Let me run that."], - [{ id: "call_1", name: "exec", args: { cmd: "ls" } }], - ); - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - // Should produce a text message and a function_call item - const textItem = requireValue( - items.find((i) => i.type === "message"), - "assistant text item missing", - ); - const fcItem = items.find((i) => i.type === "function_call"); - expect(fcItem).toMatchObject({ - type: "function_call", - call_id: "call_1", - name: "exec", - }); - expect(textItem).not.toHaveProperty("phase"); - const fc = fcItem as { arguments: string }; - expect(JSON.parse(fc.arguments)).toEqual({ cmd: "ls" }); - }); - - it("preserves assistant phase on commentary text before tool calls", () => { - const msg = assistantMsg( - ["Let me run that."], - [{ id: "call_1", name: "exec", args: { cmd: "ls" } }], - "commentary", - ); - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - const textItem = items.find((i) => i.type === "message"); - expect(textItem).toMatchObject({ - type: "message", - role: "assistant", - content: "Let me run that.", - phase: "commentary", - }); - }); - - it("preserves assistant phase from textSignature metadata without local phase field", () => { - const msg = { - role: "assistant" as const, - content: [ - { - type: "text" as const, - text: "Working on it.", - textSignature: JSON.stringify({ v: 1, id: "msg_sig", phase: "commentary" }), - }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ - type: "message", - role: "assistant", - content: "Working on it.", - phase: "commentary", - }); - }); - - it("splits replayed assistant text on phase changes from block signatures", () => { - const msg = { - role: "assistant" as const, - phase: "final_answer" as const, - content: [ - { - type: "text" as const, - text: "Working... ", - textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }), - }, - { - type: "text" as const, - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: {}, - timestamp: 0, - }; - - expect( - convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]), - ).toEqual([ - { - type: "message", - role: "assistant", - content: "Working... ", - phase: "commentary", - }, - { - type: "message", - role: "assistant", - content: "Done.", - phase: "final_answer", - }, - ]); - }); - - it("inherits message-level phase for id-only textSignature blocks, merging with phased text", () => { - const msg = { - role: "assistant" as const, - phase: "final_answer" as const, - content: [ - { - type: "text" as const, - text: "Replay. ", - textSignature: JSON.stringify({ v: 1, id: "item_pending_phase" }), - }, - { - type: "text" as const, - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: {}, - timestamp: 0, - }; - - expect( - convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]), - ).toEqual([ - { - type: "message", - role: "assistant", - content: "Replay. Done.", - phase: "final_answer", - }, - ]); - }); - - it("keeps truly unsigned legacy blocks separate when phased siblings are present", () => { - const msg = { - role: "assistant" as const, - phase: "final_answer" as const, - content: [ - { - type: "text" as const, - text: "Legacy. ", - }, - { - type: "text" as const, - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: {}, - timestamp: 0, - }; - - expect( - convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]), - ).toEqual([ - { - type: "message", - role: "assistant", - content: "Legacy. ", - }, - { - type: "message", - role: "assistant", - content: "Done.", - phase: "final_answer", - }, - ]); - }); - - it("preserves ordering when commentary text, tool calls, and final answer share one stored assistant message", () => { - const msg = { - role: "assistant" as const, - content: [ - { - type: "text" as const, - text: "Working... ", - textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }), - }, - { - type: "toolCall" as const, - id: "call_1|fc_1", - name: "exec", - arguments: { cmd: "ls" }, - }, - { - type: "text" as const, - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ], - stopReason: "toolUse", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: {}, - timestamp: 0, - }; - - expect( - convertMessagesToInputItems([msg] as Parameters[0]), - ).toEqual([ - { - type: "message", - role: "assistant", - content: "Working... ", - phase: "commentary", - }, - { - type: "function_call", - id: "fc_1", - call_id: "call_1", - name: "exec", - arguments: JSON.stringify({ cmd: "ls" }), - }, - { - type: "message", - role: "assistant", - content: "Done.", - phase: "final_answer", - }, - ]); - }); - - it("converts a tool result message", () => { - const items = convertMessagesToInputItems([toolResultMsg("call_1", "file.txt")] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ - type: "function_call_output", - call_id: "call_1", - output: "file.txt", - }); - }); - - it("preserves OpenAI-compatible tool-result images as follow-up image_url parts", () => { - const msg: FakeMessage = { - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "image", mimeType: "image/png", data: "AAAA" }], - isError: false, - timestamp: 0, - }; - const items = convertMessagesToInputItems( - [msg] as Parameters[0], - { api: "openai-completions", input: ["text", "image"] }, - ); - - expect(items).toEqual([ - { - type: "function_call_output", - call_id: "call_1", - output: "(see attached image)", - }, - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "Attached image(s) from tool result:" }, - { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, - ], - }, - ]); - }); - - it("drops tool result messages with empty tool call id", () => { - const msg = { - role: "toolResult" as const, - toolCallId: " ", - toolName: "test_tool", - content: [{ type: "text", text: "output" }], - isError: false, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toStrictEqual([]); - }); - - it("falls back to toolUseId when toolCallId is missing", () => { - const msg = { - role: "toolResult" as const, - toolUseId: "call_from_tool_use", - toolName: "test_tool", - content: [{ type: "text", text: "ok" }], - isError: false, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect(items[0]).toMatchObject({ - type: "function_call_output", - call_id: "call_from_tool_use", - output: "ok", - }); - }); - - it("converts a full multi-turn conversation", () => { - const messages: FakeMessage[] = [ - userMsg("Run ls"), - assistantMsg([], [{ id: "call_1", name: "exec", args: { cmd: "ls" } }]), - toolResultMsg("call_1", "file.txt\nfoo.ts"), - ]; - const items = convertMessagesToInputItems( - messages as Parameters[0], - ); - - const userItem = items.find( - (i) => i.type === "message" && (i as { role?: string }).role === "user", - ); - const fcItem = items.find((i) => i.type === "function_call"); - const outputItem = items.find((i) => i.type === "function_call_output"); - - expect(userItem).toMatchObject({ type: "message", role: "user" }); - expect(fcItem).toMatchObject({ type: "function_call", call_id: "call_1" }); - expect(outputItem).toMatchObject({ type: "function_call_output", call_id: "call_1" }); - }); - - it("handles assistant messages with only tool calls (no text)", () => { - const msg = assistantMsg([], [{ id: "call_2", name: "read", args: { path: "/etc/hosts" } }]); - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect(items[0]?.type).toBe("function_call"); - }); - - it("drops assistant tool calls with empty ids", () => { - const msg = assistantMsg([], [{ id: " ", name: "read", args: { path: "/tmp/a" } }]); - const items = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toStrictEqual([]); - }); - - it("skips thinking blocks in assistant messages", () => { - const msg = { - role: "assistant" as const, - content: [ - { type: "thinking", thinking: "internal reasoning..." }, - { type: "text", text: "Here is my answer." }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toHaveLength(1); - expect((items[0] as { content?: unknown }).content).toBe("Here is my answer."); - }); - - it("replays reasoning blocks from thinking signatures", () => { - const msg = { - role: "assistant" as const, - content: [ - { - type: "thinking" as const, - thinking: "internal reasoning...", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_test", - summary: [], - }), - }, - { type: "text" as const, text: "Here is my answer." }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]); - expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_test", summary: [] }); - }); - - it("replays encrypted reasoning content from thinking signatures", () => { - const msg = { - role: "assistant" as const, - content: [ - { - type: "thinking" as const, - thinking: "", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_encrypted", - encrypted_content: "encrypted-payload", - summary: [], - }), - }, - { type: "text" as const, text: "Here is my answer." }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]); - expect(items[0]).toMatchObject({ - type: "reasoning", - id: "rs_encrypted", - encrypted_content: "encrypted-payload", - summary: [], - }); - }); - - it("replays reasoning blocks when signature type is reasoning.*", () => { - const msg = { - role: "assistant" as const, - content: [ - { - type: "thinking" as const, - thinking: "internal reasoning...", - thinkingSignature: JSON.stringify({ - type: "reasoning.summary", - id: "rs_summary", - }), - }, - { type: "text" as const, text: "Here is my answer." }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]); - expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_summary" }); - }); - - it("drops reasoning replay ids that do not match OpenAI reasoning ids", () => { - const msg = { - role: "assistant" as const, - content: [ - { - type: "thinking" as const, - thinking: "internal reasoning...", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: " bad-id ", - }), - }, - { type: "text" as const, text: "Here is my answer." }, - ], - stopReason: "stop", - api: "openai-responses", - provider: "openai", - model: "gpt-5.4", - usage: {}, - timestamp: 0, - }; - const items = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(items).toEqual([ - { - type: "reasoning", - }, - { - type: "message", - role: "assistant", - content: "Here is my answer.", - }, - ]); - }); - - it("returns empty array for empty messages", () => { - expect(convertMessagesToInputItems([])).toStrictEqual([]); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── - -describe("buildAssistantMessageFromResponse", () => { - const modelInfo = { api: "openai-responses", provider: "openai", id: "gpt-5.4" }; - - it("extracts text content from a message output item", () => { - const response = makeResponseObject("resp_1", "Hello from assistant"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content).toHaveLength(1); - const textBlock = msg.content[0] as { type: string; text: string }; - expect(textBlock.type).toBe("text"); - expect(textBlock.text).toBe("Hello from assistant"); - }); - - it("sets stopReason to 'stop' for text-only responses", () => { - const response = makeResponseObject("resp_1", "Just text"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.stopReason).toBe("stop"); - }); - - it("extracts tool call from function_call output item", () => { - const response = makeResponseObject("resp_2", undefined, "exec"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - const tc = requireValue( - msg.content.find((c) => c.type === "toolCall") as - | { - type: string; - id: string; - name: string; - arguments: Record; - } - | undefined, - "tool call missing", - ); - expect(tc.name).toBe("exec"); - expect(tc.id).toBe("call_abc|item_2"); - expect(tc.arguments).toEqual({ arg: "value" }); - }); - - it("preserves malformed function-call arguments as the raw string", () => { - const response: ResponseObject = { - id: "resp_malformed", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "function_call", - id: "item_bad_args", - call_id: "call_bad", - name: "exec", - arguments: "not valid json", - }, - ], - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - const tc = requireValue( - msg.content.find((c) => c.type === "toolCall") as - | { - type: string; - name: string; - arguments: unknown; - } - | undefined, - "tool call missing", - ); - - expect(tc.name).toBe("exec"); - expect(tc.arguments).toBe("not valid json"); - }); - - it("sets stopReason to 'toolUse' when tool calls are present", () => { - const response = makeResponseObject("resp_3", undefined, "exec"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.stopReason).toBe("toolUse"); - }); - - it("includes both text and tool calls when both present", () => { - const response = makeResponseObject("resp_4", "Running...", "exec"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content.map((c) => c.type)).toEqual(["text", "toolCall"]); - expect(msg.stopReason).toBe("toolUse"); - }); - - it("maps usage tokens correctly", () => { - const response = makeResponseObject("resp_5", "Hello"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.usage.input).toBe(100); - expect(msg.usage.output).toBe(50); - expect(msg.usage.totalTokens).toBe(150); - }); - - it("maps prompt_tokens and completion_tokens usage aliases", () => { - const response = makeResponseObject("resp_5b", "Hello"); - response.usage = { - prompt_tokens: 44, - completion_tokens: 11, - total_tokens: 55, - }; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.usage.input).toBe(44); - expect(msg.usage.output).toBe(11); - expect(msg.usage.totalTokens).toBe(55); - }); - - it("falls back to normalized input and output when total_tokens is missing", () => { - const response = makeResponseObject("resp_5c", "Hello"); - response.usage = { - prompt_tokens: 10, - completion_tokens: 5, - }; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.usage.input).toBe(10); - expect(msg.usage.output).toBe(5); - expect(msg.usage.totalTokens).toBe(15); - }); - - it("falls back to normalized input and output when total_tokens is zero", () => { - const response = makeResponseObject("resp_5d", "Hello"); - response.usage = { - input_tokens: 10, - output_tokens: 5, - total_tokens: 0, - }; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.usage.input).toBe(10); - expect(msg.usage.output).toBe(5); - expect(msg.usage.totalTokens).toBe(15); - }); - - it("sets model/provider/api from modelInfo", () => { - const response = makeResponseObject("resp_6", "Hi"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.api).toBe("openai-responses"); - expect(msg.provider).toBe("openai"); - expect(msg.model).toBe("gpt-5.4"); - }); - - it("handles empty output gracefully", () => { - const response = makeResponseObject("resp_7"); - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content).toStrictEqual([]); - expect(msg.stopReason).toBe("stop"); - }); - - it("preserves phase from assistant message output items", () => { - const response = makeResponseObject("resp_8", "Final answer", undefined, "final_answer"); - const msg = buildAssistantMessageFromResponse(response, modelInfo) as { - phase?: string; - content: Array<{ type: string; text?: string }>; - }; - expect(msg.phase).toBe("final_answer"); - expect(msg.content[0]?.text).toBe("Final answer"); - }); - - it("keeps only final-answer text when a response contains mixed assistant phases", () => { - const response = { - id: "resp_mixed_phase", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.2", - output: [ - { - type: "message", - id: "item_commentary", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Working... " }], - }, - { - type: "message", - id: "item_final", - role: "assistant", - phase: "final_answer", - content: [{ type: "output_text", text: "Done." }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - - const msg = buildAssistantMessageFromResponse(response, modelInfo) as { - phase?: string; - content: Array<{ type: string; text?: string; textSignature?: string }>; - }; - - expect(msg.phase).toBe("final_answer"); - expect(msg.content).toMatchObject([ - { - type: "text", - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ]); - }); - - it("keeps only phased final text when unphased legacy text and phased final text coexist", () => { - const response = { - id: "resp_unphased_plus_final", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.2", - output: [ - { - type: "message", - id: "item_legacy", - role: "assistant", - content: [{ type: "output_text", text: "Legacy. " }], - }, - { - type: "message", - id: "item_final", - role: "assistant", - phase: "final_answer", - content: [{ type: "output_text", text: "Done." }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - - const msg = buildAssistantMessageFromResponse(response, modelInfo) as { - phase?: string; - content: Array<{ type: string; text?: string; textSignature?: string }>; - }; - - expect(msg.phase).toBe("final_answer"); - expect(msg.content).toMatchObject([ - { - type: "text", - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ]); - }); - - it("drops commentary-only text from completed assistant messages but keeps tool calls", () => { - const response = { - id: "resp_commentary_only_tool", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.2", - output: [ - { - type: "message", - id: "item_commentary", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Working... " }], - }, - { - type: "function_call", - id: "item_tool", - call_id: "call_abc", - name: "exec", - arguments: '{"arg":"value"}', - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - - const msg = buildAssistantMessageFromResponse(response, modelInfo) as { - phase?: string; - content: Array<{ type: string; text?: string; name?: string }>; - stopReason: string; - }; - - expect(msg.phase).toBeUndefined(); - expect(msg.content.some((part) => part.type === "text")).toBe(false); - expect(msg.content).toMatchObject([{ type: "toolCall", name: "exec" }]); - expect(msg.stopReason).toBe("toolUse"); - }); - - it("maps reasoning output items to thinking blocks with signature", () => { - const response = { - id: "resp_reasoning", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "rs_123", - summary: [{ text: "Plan step A" }, { text: "Plan step B" }], - }, - { - type: "message", - id: "item_1", - role: "assistant", - content: [{ type: "output_text", text: "Final answer" }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - const msg = buildAssistantMessageFromResponse(response, modelInfo); - const thinkingBlock = msg.content.find((c) => c.type === "thinking") as - | { type: "thinking"; thinking: string; thinkingSignature?: string } - | undefined; - expect(thinkingBlock?.thinking).toBe("Plan step A\nPlan step B"); - expect(thinkingBlock?.thinkingSignature).toBe( - JSON.stringify({ - type: "reasoning", - id: "rs_123", - summary: [{ text: "Plan step A" }, { text: "Plan step B" }], - }), - ); - }); - - it("maps reasoning.* output items to thinking blocks", () => { - const response = { - id: "resp_reasoning_kind", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning.summary", - id: "rs_456", - content: "Derived hidden reasoning", - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - const msg = buildAssistantMessageFromResponse(response, modelInfo); - const thinkingBlock = msg.content[0] as - | { type: "thinking"; thinking: string; thinkingSignature?: string } - | undefined; - expect(thinkingBlock?.type).toBe("thinking"); - expect(thinkingBlock?.thinking).toBe("Derived hidden reasoning"); - expect(JSON.parse(thinkingBlock?.thinkingSignature ?? "{}")).toEqual({ - type: "reasoning.summary", - id: "rs_456", - content: "Derived hidden reasoning", - }); - }); - - it("prefers reasoning summary text over fallback content and preserves item order", () => { - const response = { - id: "resp_reasoning_order", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning.summary", - id: "rs_789", - summary: ["Plan A", { text: "Plan B" }, { nope: true }], - content: "hidden fallback content", - }, - { - type: "function_call", - id: "fc_789", - call_id: "call_789", - name: "exec", - arguments: '{"arg":"value"}', - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content.map((block) => block.type)).toEqual(["thinking", "toolCall"]); - const thinkingBlock = msg.content[0] as - | { type: "thinking"; thinking: string; thinkingSignature?: string } - | undefined; - expect(thinkingBlock?.thinking).toBe("Plan A\nPlan B"); - expect(JSON.parse(thinkingBlock?.thinkingSignature ?? "{}")).toEqual({ - type: "reasoning.summary", - id: "rs_789", - content: "hidden fallback content", - summary: ["Plan A", { text: "Plan B" }, { nope: true }], - }); - }); - - it("drops invalid reasoning ids from thinking signatures while preserving the visible block", () => { - const response = { - id: "resp_invalid_reasoning_id", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "invalid_reasoning_id", - content: "Hidden reasoning", - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content).toEqual([{ type: "thinking", thinking: "Hidden reasoning" }]); - }); - - it("preserves encrypted-only reasoning items with empty visible thinking", () => { - const response = { - id: "resp_encrypted_reasoning", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "rs_encrypted_empty", - encrypted_content: "encrypted-payload", - summary: [], - }, - { - type: "message", - id: "msg_encrypted_empty", - role: "assistant", - content: [{ type: "output_text", text: "NO_REPLY" }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as unknown as ResponseObject; - - const msg = buildAssistantMessageFromResponse(response, modelInfo); - expect(msg.content.map((block) => block.type)).toEqual(["thinking", "text"]); - const thinkingBlock = msg.content[0] as { - type: "thinking"; - thinking: string; - thinkingSignature?: string; - }; - expect(thinkingBlock.thinking).toBe(""); - expect(JSON.parse(thinkingBlock.thinkingSignature ?? "{}")).toEqual({ - encrypted_content: "encrypted-payload", - id: "rs_encrypted_empty", - summary: [], - type: "reasoning", - }); - - const replayItems = convertMessagesToInputItems([msg] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(replayItems.map((item) => item.type)).toEqual(["reasoning", "message"]); - expect(replayItems[0]).toMatchObject({ - type: "reasoning", - id: "rs_encrypted_empty", - encrypted_content: "encrypted-payload", - summary: [], - }); - }); - - it("preserves function call item ids for replay when reasoning is present", () => { - const response = { - id: "resp_tool_reasoning", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "rs_tool", - content: "Thinking before tool call", - }, - { - type: "function_call", - id: "fc_tool", - call_id: "call_tool", - name: "exec", - arguments: '{"arg":"value"}', - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - } as ResponseObject; - - const assistant = buildAssistantMessageFromResponse(response, modelInfo); - const toolCall = assistant.content.find((item) => item.type === "toolCall") as - | { type: "toolCall"; id: string } - | undefined; - expect(toolCall?.id).toBe("call_tool|fc_tool"); - - const replayItems = convertMessagesToInputItems([assistant] as Parameters< - typeof convertMessagesToInputItems - >[0]); - expect(replayItems.map((item) => item.type)).toEqual(["reasoning", "function_call"]); - expect(replayItems[1]).toMatchObject({ - type: "function_call", - call_id: "call_tool", - id: "fc_tool", - }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── - -describe("planTurnInput", () => { - const replayModel = { input: ["text"] }; - - it("uses incremental tool result replay when a previous response id and new tool results exist", () => { - const context = { - systemPrompt: "You are helpful.", - messages: [ - userMsg("Run ls"), - assistantMsg([], [{ id: "call_1|fc_1", name: "exec", args: { cmd: "ls" } }]), - toolResultMsg("call_1|fc_1", "file.txt"), - ] as Parameters[0], - tools: [], - }; - - const turnInput = planTurnInput({ - context, - model: replayModel, - previousResponseId: "resp_prev", - lastContextLength: 2, - }); - - expect(turnInput.mode).toBe("incremental_tool_results"); - expect(turnInput.previousResponseId).toBe("resp_prev"); - expect(turnInput.inputItems).toEqual([ - { - type: "function_call_output", - call_id: "call_1", - output: "file.txt", - }, - ]); - }); - - it("restarts with full context when follow-up turns have no new tool results", () => { - const turn1Response = { - id: "resp_turn1_reasoning", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "rs_turn1", - content: "Thinking before tool call", - }, - { - type: "function_call", - id: "fc_turn1", - call_id: "call_turn1", - name: "exec", - arguments: '{"cmd":"ls"}', - }, - ], - usage: { input_tokens: 12, output_tokens: 8, total_tokens: 20 }, - } as ResponseObject; - - const context = { - systemPrompt: "You are helpful.", - messages: [ - userMsg("Run ls"), - buildAssistantMessageFromResponse(turn1Response, { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - }), - ] as Parameters[0], - tools: [], - }; - - const turnInput = planTurnInput({ - context, - model: replayModel, - previousResponseId: "resp_turn1_reasoning", - lastContextLength: context.messages.length, - }); - - expect(turnInput.mode).toBe("full_context_restart"); - expect(turnInput.previousResponseId).toBeUndefined(); - expect(turnInput.inputItems.map((item) => item.type)).toEqual([ - "message", - "reasoning", - "function_call", - ]); - expect(turnInput.inputItems[1]).toMatchObject({ type: "reasoning", id: "rs_turn1" }); - expect(turnInput.inputItems[2]).toMatchObject({ - type: "function_call", - call_id: "call_turn1", - id: "fc_turn1", - }); - }); - - it("uses full context on the initial turn", () => { - const context = { - systemPrompt: "You are helpful.", - messages: [userMsg("Hello!")] as Parameters[0], - tools: [], - }; - - const turnInput = planTurnInput({ - context, - model: replayModel, - previousResponseId: null, - lastContextLength: 0, - }); - - expect(turnInput).toMatchObject({ - mode: "full_context_initial", - inputItems: [{ type: "message", role: "user", content: "Hello!" }], - }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── - -describe("planOpenAIWebSocketRequestPayload", () => { - it("sends only the strict suffix when the full input extends the prior response chain", () => { - const previousInputItems: InputItem[] = [{ type: "message", role: "user", content: "Hello" }]; - const previousRequest: ResponseCreateEvent = { - type: "response.create", - model: "gpt-5.4", - store: false, - instructions: "You are helpful.", - input: previousInputItems, - }; - const previousResponseInputItems: InputItem[] = [ - { type: "message", role: "assistant", content: "Hi" }, - ]; - const fullPayload: ResponseCreateEvent = { - type: "response.create", - model: "gpt-5.4", - store: false, - instructions: "You are helpful.", - input: [ - ...previousInputItems, - ...previousResponseInputItems, - { type: "message", role: "user", content: "Next" }, - ], - }; - - const plan = planOpenAIWebSocketRequestPayload({ - fullPayload, - previousRequestPayload: previousRequest, - previousResponseId: "resp_prev", - previousResponseInputItems: [...previousResponseInputItems], - }); - - expect(plan.mode).toBe("incremental"); - expect(plan.payload.previous_response_id).toBe("resp_prev"); - expect(plan.payload.input).toEqual([{ type: "message", role: "user", content: "Next" }]); - }); - - it("falls back to full context when non-input fields differ", () => { - const previousInputItems: InputItem[] = [{ type: "message", role: "user", content: "Hello" }]; - const previousRequest: ResponseCreateEvent = { - type: "response.create", - model: "gpt-5.4", - store: false, - instructions: "Old instructions", - input: previousInputItems, - }; - const fullPayload: ResponseCreateEvent = { - ...previousRequest, - instructions: "New instructions", - input: [ - ...previousInputItems, - { type: "message", role: "assistant", content: "Hi" }, - { type: "message", role: "user", content: "Next" }, - ], - }; - - const plan = planOpenAIWebSocketRequestPayload({ - fullPayload, - previousRequestPayload: previousRequest, - previousResponseId: "resp_prev", - previousResponseInputItems: [{ type: "message", role: "assistant", content: "Hi" }], - }); - - expect(plan.mode).toBe("full_context"); - expect(plan.payload.previous_response_id).toBeUndefined(); - expect(plan.payload.input).toEqual(fullPayload.input); - }); - - it("falls back to full context when the input is not a strict response-chain extension", () => { - const previousRequest: ResponseCreateEvent = { - type: "response.create", - model: "gpt-5.4", - store: false, - input: [{ type: "message", role: "user", content: "Hello" }], - }; - const fullPayload: ResponseCreateEvent = { - ...previousRequest, - input: [{ type: "message", role: "user", content: "Different" }], - }; - - const plan = planOpenAIWebSocketRequestPayload({ - fullPayload, - previousRequestPayload: previousRequest, - previousResponseId: "resp_prev", - previousResponseInputItems: [{ type: "message", role: "assistant", content: "Hi" }], - }); - - expect(plan.mode).toBe("full_context"); - expect(plan.payload.previous_response_id).toBeUndefined(); - expect(plan.payload.input).toEqual(fullPayload.input); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── - -describe("createOpenAIWebSocketStreamFn", () => { - const modelStub = { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.2", - }; - - const contextStub = { - systemPrompt: "You are helpful.", - messages: [userMsg("Hello!") as Parameters[0][number]], - tools: [], - }; - - beforeEach(() => { - MockManager.reset(); - streamSimpleCalls.length = 0; - mockCreateHttpFallbackStreamFn.mockReset(); - mockCreateHttpFallbackStreamFn.mockReturnValue(mockStreamSimple as never); - openAIWsStreamTesting.setDepsForTest({ - createManager: ((options?: unknown) => new MockManager(options)) as never, - createHttpFallbackStreamFn: mockCreateHttpFallbackStreamFn as never, - streamSimple: mockStreamSimple, - }); - }); - - afterEach(() => { - // Clean up any sessions created in tests to avoid cross-test pollution - MockManager.instances.forEach((_, i) => { - // Session IDs used in tests follow a predictable pattern - releaseWsSession(`test-session-${i}`); - }); - releaseWsSession("sess-1"); - releaseWsSession("sess-2"); - releaseWsSession("sess-boundary"); - releaseWsSession("sess-fallback"); - releaseWsSession("sess-explicit-sse"); - releaseWsSession("sess-boundary-http-fallback"); - releaseWsSession("sess-full-context-replay"); - releaseWsSession("sess-encrypted-full-context-replay"); - releaseWsSession("sess-incremental"); - releaseWsSession("sess-full"); - releaseWsSession("sess-onpayload"); - releaseWsSession("sess-onpayload-async"); - releaseWsSession("sess-phase"); - releaseWsSession("sess-phase-stream"); - releaseWsSession("sess-phase-late-map"); - releaseWsSession("sess-reason"); - releaseWsSession("sess-reason-none"); - releaseWsSession("sess-tools"); - releaseWsSession("sess-store-default"); - releaseWsSession("sess-store-compat"); - releaseWsSession("sess-store-proxy"); - releaseWsSession("sess-max-tokens-zero"); - releaseWsSession("sess-runtime-fallback-nested"); - releaseWsSession("sess-runtime-fallback"); - releaseWsSession("sess-runtime-retry"); - releaseWsSession("sess-send-fail-reset"); - releaseWsSession("sess-temp"); - releaseWsSession("sess-text-verbosity"); - releaseWsSession("sess-text-verbosity-invalid"); - releaseWsSession("sess-topp"); - releaseWsSession("sess-turn-metadata-retry"); - releaseWsSession("sess-warmup-disabled"); - releaseWsSession("sess-warmup-enabled"); - releaseWsSession("sess-degraded-cooldown"); - releaseWsSession("sess-drop"); - openAIWsStreamTesting.setWsDegradeCooldownMsForTest(); - openAIWsStreamTesting.setDepsForTest(); - }); - - it("connects to the WebSocket on first call", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-1"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - // Give the microtask queue time to run - await new Promise((r) => setImmediate(r)); - - const manager = MockManager.lastInstance; - expect(manager?.connectCallCount).toBe(1); - releaseWsSession("sess-1"); - for await (const _ of await resolveStream(stream)) { - // consume - } - }); - - it("sends a response.create event on first turn (full context)", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-full"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const completed = new Promise((res, rej) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - - // Simulate the server completing the response - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_1", "Hello!"), - }); - - for await (const _ of await resolveStream(stream)) { - // consume events - } - res(); - } catch (e) { - rej(e); - } - }); - }); - - await completed; - - const manager = MockManager.lastInstance!; - expect(manager.sentEvents).toHaveLength(1); - const sent = manager.sentEvents[0] as { type: string; model: string; input: unknown[] }; - expect(sent.type).toBe("response.create"); - expect(sent.model).toBe("gpt-5.4"); - expect(Array.isArray(sent.input)).toBe(true); - }); - - it("includes store:false by default", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-store-default"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const completed = new Promise((res, rej) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_store_default", "ok"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - res(); - } catch (e) { - rej(e); - } - }); - }); - await completed; - - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.store).toBe(false); - }); - - it("omits store when compat.supportsStore is false (#39086)", async () => { - releaseWsSession("sess-store-compat"); - const noStoreModel = { - ...modelStub, - compat: { supportsStore: false }, - }; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-store-compat"); - const stream = streamFn( - noStoreModel as Parameters[0], - contextStub as Parameters[1], - ); - - const completed = new Promise((res, rej) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_no_store", "ok"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - res(); - } catch (e) { - rej(e); - } - }); - }); - await completed; - - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent).not.toHaveProperty("store"); - }); - - it("keeps store=false for proxied openai-responses routes when store is still supported", () => { - const proxiedModel = { - ...modelStub, - baseUrl: "https://proxy.example.com/v1", - }; - const turnInput = planTurnInput({ - context: contextStub as Parameters[0]["context"], - model: proxiedModel as Parameters[0]["model"], - previousResponseId: null, - lastContextLength: 0, - }); - const sent = buildOpenAIWebSocketResponseCreatePayload({ - model: proxiedModel as Parameters< - typeof buildOpenAIWebSocketResponseCreatePayload - >[0]["model"], - context: contextStub as Parameters< - typeof buildOpenAIWebSocketResponseCreatePayload - >[0]["context"], - turnInput, - tools: [], - }) as Record; - expect(sent.store).toBe(false); - }); - - it("emits an AssistantMessage on response.completed", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-2"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: unknown[] = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_hello", "Hello back!"), - }); - - await done; - - const doneEvent = events.find((e) => (e as { type?: string }).type === "done") as - | { - type: string; - reason: string; - message: { content: Array<{ text: string }> }; - } - | undefined; - expect(requireValue(doneEvent, "done event missing").message.content[0]?.text).toBe( - "Hello back!", - ); - }); - - it("suppresses commentary-only text on completed WebSocket responses", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: unknown[] = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_phase", "Working...", "exec", "commentary"), - }); - - await done; - - const doneEvent = events.find((e) => (e as { type?: string }).type === "done") as - | { - type: string; - reason: string; - message: { phase?: string; stopReason: string; content?: Array<{ type?: string }> }; - } - | undefined; - expect(doneEvent?.message.phase).toBeUndefined(); - expect(doneEvent?.message.content?.some((part) => part.type === "text")).toBe(false); - expect(doneEvent?.message.stopReason).toBe("toolUse"); - }); - - it("emits accumulated phase-aware partials when output item mapping is available", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase-stream"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: Array<{ - type?: string; - delta?: string; - partial?: { phase?: string; content?: unknown[] }; - }> = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev as (typeof events)[number]); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: "item_commentary", - role: "assistant", - phase: "commentary", - content: [], - }, - }); - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_commentary", - output_index: 0, - content_index: 0, - delta: "Working", - }); - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_commentary", - output_index: 0, - content_index: 0, - delta: "...", - }); - manager.simulateEvent({ - type: "response.output_item.added", - output_index: 1, - item: { - type: "message", - id: "item_final", - role: "assistant", - phase: "final_answer", - content: [], - }, - }); - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_final", - output_index: 1, - content_index: 0, - delta: "Done.", - }); - manager.simulateEvent({ - type: "response.completed", - response: { - id: "resp_phase_stream", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.2", - output: [ - { - type: "message", - id: "item_commentary", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Working..." }], - }, - { - type: "message", - id: "item_final", - role: "assistant", - phase: "final_answer", - content: [{ type: "output_text", text: "Done." }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - }, - }); - - await done; - - const deltas = events.filter((event) => event.type === "text_delta"); - expect(deltas).toHaveLength(3); - expect(deltas[0]).toMatchObject({ delta: "Working" }); - expect(deltas[0]?.partial?.phase).toBe("commentary"); - expect(deltas[0]?.partial?.content).toEqual([ - { - type: "text", - text: "Working", - textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }), - }, - ]); - expect(deltas[1]).toMatchObject({ delta: "..." }); - expect(deltas[1]?.partial?.phase).toBe("commentary"); - expect(deltas[1]?.partial?.content).toEqual([ - { - type: "text", - text: "Working...", - textSignature: JSON.stringify({ v: 1, id: "item_commentary", phase: "commentary" }), - }, - ]); - expect(deltas[2]).toMatchObject({ delta: "Done." }); - expect(deltas[2]?.partial?.phase).toBe("final_answer"); - expect(deltas[2]?.partial?.content).toEqual([ - { - type: "text", - text: "Done.", - textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }), - }, - ]); - }); - - it("buffers text deltas until item mapping is available", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase-late-map"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: Array<{ - type?: string; - delta?: string; - partial?: { phase?: string; content?: unknown[] }; - }> = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev as (typeof events)[number]); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_late", - output_index: 0, - content_index: 0, - delta: "Working", - }); - manager.simulateEvent({ - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: "item_late", - role: "assistant", - phase: "commentary", - content: [], - }, - }); - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_late", - output_index: 0, - content_index: 0, - delta: "...", - }); - manager.simulateEvent({ - type: "response.completed", - response: { - id: "resp_phase_late_map", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.2", - output: [ - { - type: "message", - id: "item_late", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Working..." }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - }, - }); - - await done; - - const deltas = events.filter((event) => event.type === "text_delta"); - expect(deltas).toHaveLength(2); - expect(deltas[0]).toMatchObject({ delta: "Working" }); - expect(deltas[0]?.partial?.phase).toBe("commentary"); - expect(deltas[0]?.partial?.content).toEqual([ - { - type: "text", - text: "Working", - textSignature: JSON.stringify({ v: 1, id: "item_late", phase: "commentary" }), - }, - ]); - expect(deltas[1]).toMatchObject({ delta: "..." }); - expect(deltas[1]?.partial?.phase).toBe("commentary"); - expect(deltas[1]?.partial?.content).toEqual([ - { - type: "text", - text: "Working...", - textSignature: JSON.stringify({ v: 1, id: "item_late", phase: "commentary" }), - }, - ]); - }); - - it("keeps buffering text deltas until item phase is defined", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase-late-map-undefined"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: Array<{ - type?: string; - delta?: string; - partial?: { phase?: string; content?: unknown[] }; - }> = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev as (typeof events)[number]); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_late_undefined", - output_index: 0, - content_index: 0, - delta: "Working", - }); - manager.simulateEvent({ - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: "item_late_undefined", - role: "assistant", - content: [], - }, - }); - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_late_undefined", - output_index: 0, - content_index: 0, - delta: "...", - }); - - await new Promise((r) => setImmediate(r)); - const prematureDeltas = events.filter((event) => event.type === "text_delta"); - expect(prematureDeltas).toHaveLength(0); - - manager.simulateEvent({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "message", - id: "item_late_undefined", - role: "assistant", - phase: "commentary", - content: [], - }, - }); - manager.simulateEvent({ - type: "response.completed", - response: { - id: "resp_phase_late_map_undefined", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "message", - id: "item_late_undefined", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Working..." }], - }, - ], - usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, - }, - }); - - await done; - - const deltas = events.filter((event) => event.type === "text_delta"); - expect(deltas).toHaveLength(1); - expect(deltas[0]).toMatchObject({ delta: "Working..." }); - expect(deltas[0]?.partial?.phase).toBe("commentary"); - expect(deltas[0]?.partial?.content).toEqual([ - { - type: "text", - text: "Working...", - textSignature: JSON.stringify({ - v: 1, - id: "item_late_undefined", - phase: "commentary", - }), - }, - ]); - }); - it("buffers text when output_item.added arrives without phase metadata", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phaseless-gate"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: Array<{ - type?: string; - delta?: string; - partial?: { phase?: string; content?: unknown[] }; - }> = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev as (typeof events)[number]); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - - // output_item.added WITHOUT phase — simulates phaseless announcement - manager.simulateEvent({ - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: "item_phaseless", - role: "assistant", - content: [], - }, - }); - - // Text delta arrives while phase is still unknown - manager.simulateEvent({ - type: "response.output_text.delta", - item_id: "item_phaseless", - output_index: 0, - content_index: 0, - delta: "Leaked?", - }); - - // Yield to let any would-be emissions propagate - await new Promise((r) => setImmediate(r)); - const prematureDeltas = events.filter((e) => e.type === "text_delta"); - expect(prematureDeltas).toHaveLength(0); - - // output_item.done delivers the actual phase — should flush buffered text - manager.simulateEvent({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "message", - id: "item_phaseless", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Leaked?" }], - }, - }); - - manager.simulateEvent({ - type: "response.completed", - response: { - id: "resp_phaseless_gate", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "message", - id: "item_phaseless", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Leaked?" }], - }, - ], - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }, - }); - - await done; - - const deltas = events.filter((e) => e.type === "text_delta"); - expect(deltas).toHaveLength(1); - expect(deltas[0]).toMatchObject({ delta: "Leaked?" }); - expect(deltas[0]?.partial?.phase).toBe("commentary"); - }); - - it("buffers output_text.done until item phase is defined", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phaseless-done-gate"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - const events: Array<{ - type?: string; - delta?: string; - partial?: { phase?: string; content?: unknown[] }; - }> = []; - const done = (async () => { - for await (const ev of await resolveStream(stream)) { - events.push(ev as (typeof events)[number]); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - - manager.simulateEvent({ - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: "item_phaseless_done", - role: "assistant", - content: [], - }, - }); - manager.simulateEvent({ - type: "response.output_text.done", - item_id: "item_phaseless_done", - output_index: 0, - content_index: 0, - text: "Buffered final text", - }); - - await new Promise((r) => setImmediate(r)); - const prematureDeltas = events.filter((event) => event.type === "text_delta"); - expect(prematureDeltas).toHaveLength(0); - - manager.simulateEvent({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "message", - id: "item_phaseless_done", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Buffered final text" }], - }, - }); - manager.simulateEvent({ - type: "response.completed", - response: { - id: "resp_phaseless_done_gate", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "message", - id: "item_phaseless_done", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Buffered final text" }], - }, - ], - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }, - }); - - await done; - - const deltas = events.filter((event) => event.type === "text_delta"); - expect(deltas).toHaveLength(1); - expect(deltas[0]).toMatchObject({ delta: "Buffered final text" }); - expect(deltas[0]?.partial?.phase).toBe("commentary"); - }); - - it("falls back to HTTP when WebSocket connect fails (session pre-broken via flag)", async () => { - // Set the class-level flag BEFORE calling streamFn so the new instance - // fails on connect(). We patch the static default via MockManager directly. - MockManager.globalConnectShouldFail = true; - - try { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-fallback"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - - // Consume — should fall back to HTTP (streamSimple mock). - const messages: unknown[] = []; - for await (const ev of await resolveStream(stream)) { - messages.push(ev); - } - - // streamSimple was called as part of HTTP fallback - expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); - - // The failed manager is closed before the replacement session manager is installed. - expect( - countMatching(MockManager.instances, (instance) => instance.closeCallCount >= 1), - ).toBeGreaterThanOrEqual(1); - } finally { - MockManager.globalConnectShouldFail = false; - } - }); - - it("ends the HTTP fallback stream when explicit SSE transport is selected", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-explicit-sse"); - const stream = await resolveStream( - streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "sse" } as Parameters[2], - ), - ); - - await expect( - withTimeout( - ( - stream as unknown as { - result: () => Promise<{ content?: Array<{ text?: string }> }>; - } - ).result(), - 100, - "SSE fallback result timed out", - ), - ).resolves.toMatchObject({ - content: [{ text: "http fallback response" }], - }); - expect(streamSimpleCalls).toHaveLength(1); - }); - - it("falls back to HTTP when WebSocket errors before any output in auto mode", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-runtime-fallback"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "error", - message: "temporary upstream glitch", - code: "ws_runtime_error", - }); - - const events: Array<{ type?: string; message?: { content?: Array<{ text?: string }> } }> = []; - for await (const ev of await resolveStream(stream)) { - events.push(ev as { type?: string; message?: { content?: Array<{ text?: string }> } }); - } - - expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); - expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(countMatching(events, (event) => event.type === "start")).toBe(1); - expect(events.some((event) => event.type === "error")).toBe(false); - const doneEvent = events.find((event) => event.type === "done"); - expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); - }); - - it("falls back to HTTP when OpenAI sends a nested websocket error payload", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-runtime-fallback-nested"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "error", - status: 400, - error: { - type: "invalid_request_error", - code: "previous_response_not_found", - message: "Previous response with id 'resp_abc' not found.", - param: "previous_response_id", - }, - }); - - const events: Array<{ type?: string; message?: { content?: Array<{ text?: string }> } }> = []; - for await (const ev of await resolveStream(stream)) { - events.push(ev as { type?: string; message?: { content?: Array<{ text?: string }> } }); - } - - expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); - expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(countMatching(events, (event) => event.type === "start")).toBe(1); - expect(events.some((event) => event.type === "error")).toBe(false); - const doneEvent = events.find((event) => event.type === "done"); - expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); - }); - - it("retries one retryable mid-request close before falling back in auto mode", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-runtime-retry"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - - await new Promise((r) => setImmediate(r)); - const firstManager = MockManager.lastInstance!; - firstManager.simulateClose(1006, "connection lost"); - - await new Promise((r) => setImmediate(r)); - const secondManager = MockManager.lastInstance!; - expect(secondManager).not.toBe(firstManager); - expect(secondManager.connectCallCount).toBe(1); - - secondManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-retried", "retry succeeded"), - }); - - const events: Array<{ type?: string; message?: { content?: Array<{ text?: string }> } }> = []; - for await (const ev of await resolveStream(stream)) { - events.push(ev as { type?: string; message?: { content?: Array<{ text?: string }> } }); - } - - expect(streamSimpleCalls).toHaveLength(0); - expect(firstManager.closeCallCount).toBeGreaterThanOrEqual(1); - expect(countMatching(events, (event) => event.type === "start")).toBe(1); - const doneEvent = events.find((event) => event.type === "done"); - expect(doneEvent?.message?.content?.[0]?.text).toBe("retry succeeded"); - }); - - it("keeps native turn metadata stable across websocket retries and increments attempt", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-turn-metadata-retry"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - - await new Promise((r) => setImmediate(r)); - const firstManager = MockManager.lastInstance!; - firstManager.simulateClose(1006, "connection lost"); - - await new Promise((r) => setImmediate(r)); - const secondManager = MockManager.lastInstance!; - secondManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-retried-meta", "retry succeeded"), - }); - - for await (const _ of await resolveStream(stream)) { - // consume - } - - const firstPayload = firstManager.sentEvents[0] as { metadata?: Record }; - const secondPayload = secondManager.sentEvents[0] as { metadata?: Record }; - expect(firstPayload.metadata?.openclaw_session_id).toBe("sess-turn-metadata-retry"); - expect(firstPayload.metadata?.openclaw_transport).toBe("websocket"); - const turnId = requireValue(firstPayload.metadata?.openclaw_turn_id, "turn id missing"); - expect(turnId).not.toBe(""); - expect(secondPayload.metadata?.openclaw_turn_id).toBe(turnId); - expect(firstPayload.metadata?.openclaw_turn_attempt).toBe("1"); - expect(secondPayload.metadata?.openclaw_turn_attempt).toBe("2"); - }); - - it("does not attach native OpenAI session headers or metadata for custom responses endpoints", async () => { - const sessionId = "sess-custom-openai-endpoint"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - const customEndpointModel = { - ...modelStub, - baseUrl: "http://127.0.0.1:4100/v1", - }; - const stream = streamFn( - customEndpointModel as Parameters[0], - contextStub as Parameters[1], - { transport: "websocket" } as Parameters[2], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-custom-endpoint", "custom endpoint"), - }); - - for await (const _ of await resolveStream(stream)) { - // consume - } - - expect((manager.options as { headers?: Record } | undefined)?.headers).toBe( - undefined, - ); - const payload = manager.sentEvents[0] as { metadata?: Record }; - expect(payload.metadata?.openclaw_session_id).toBeUndefined(); - expect(payload.metadata?.openclaw_transport).toBeUndefined(); - releaseWsSession(sessionId); - }); - - it("keeps websocket degraded for the session until the cool-down expires", async () => { - openAIWsStreamTesting.setWsDegradeCooldownMsForTest(50); - MockManager.globalConnectShouldFail = true; - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000); - - try { - const sessionId = "sess-degraded-cooldown"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - const firstStream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - void firstStream; - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(streamSimpleCalls.length).toBe(1); - expect(MockManager.instances).toHaveLength(2); - const cooledManager = MockManager.lastInstance!; - expect(cooledManager.connectCallCount).toBe(0); - - MockManager.globalConnectShouldFail = false; - - const secondStream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - void secondStream; - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(streamSimpleCalls.length).toBe(2); - expect(MockManager.instances).toHaveLength(2); - expect(cooledManager.connectCallCount).toBe(0); - - nowSpy.mockReturnValue(1_060); - - const thirdStream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "auto" } as Parameters[2], - ); - - void thirdStream; - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - expect(cooledManager.connectCallCount).toBe(1); - expect(streamSimpleCalls.length).toBe(2); - cooledManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-after-cooldown", "ws recovered"), - }); - await new Promise((resolve) => setImmediate(resolve)); - } finally { - nowSpy.mockRestore(); - MockManager.globalConnectShouldFail = false; - openAIWsStreamTesting.setWsDegradeCooldownMsForTest(); - releaseWsSession("sess-degraded-cooldown"); - releaseWsSession("sess-turn-metadata-retry"); - } - }); - - it("tracks previous_response_id across turns (incremental send)", async () => { - const sessionId = "sess-incremental"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - // ── Turn 1: full context ───────────────────────────────────────────── - const ctx1 = { - systemPrompt: "You are helpful.", - messages: [userMsg("Run ls")] as Parameters[0], - tools: [], - }; - - const stream1 = streamFn( - modelStub as Parameters[0], - ctx1 as Parameters[1], - ); - - const events1: unknown[] = []; - const done1 = (async () => { - for await (const ev of await resolveStream(stream1)) { - events1.push(ev); - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - - // Server responds with a tool call - const turn1Response = makeResponseObject("resp_turn1", undefined, "exec"); - manager.simulateEvent({ type: "response.completed", response: turn1Response }); - await done1; - - // ── Turn 2: incremental (tool results only) ─────────────────────────── - const ctx2 = { - systemPrompt: "You are helpful.", - messages: [ - userMsg("Run ls"), - buildAssistantMessageFromResponse(turn1Response, modelStub), - toolResultMsg("call_abc|item_2", "file.txt"), - ] as Parameters[0], - tools: [], - }; - - const stream2 = streamFn( - modelStub as Parameters[0], - ctx2 as Parameters[1], - ); - - const events2: unknown[] = []; - const done2 = (async () => { - for await (const ev of await resolveStream(stream2)) { - events2.push(ev); - } - })(); - - await new Promise((r) => setImmediate(r)); - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_turn2", "Here are the files."), - }); - await done2; - - // Turn 2 should have sent previous_response_id and only tool results - expect(manager.sentEvents).toHaveLength(2); - const sent2 = manager.sentEvents[1] as { - previous_response_id?: string; - input: Array<{ type: string }>; - }; - expect(sent2.previous_response_id).toBe("resp_turn1"); - // Input should only contain tool results, not the full history - const inputTypes = (sent2.input ?? []).map((i) => i.type); - expect(inputTypes).toEqual(["function_call_output"]); - }); - - it("sends only a follow-up user message when the full context is a strict extension", async () => { - const sessionId = "sess-user-delta"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - const ctx1 = { - systemPrompt: "You are helpful.", - messages: [userMsg("Hello")] as Parameters[0], - tools: [], - }; - - const stream1 = streamFn( - modelStub as Parameters[0], - ctx1 as Parameters[1], - ); - const done1 = (async () => { - for await (const _ of await resolveStream(stream1)) { - /* consume */ - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - const turn1Response = makeResponseObject("resp_turn1_text", "Hi there."); - manager.simulateEvent({ type: "response.completed", response: turn1Response }); - await done1; - - const ctx2 = { - systemPrompt: "You are helpful.", - messages: [ - userMsg("Hello"), - buildAssistantMessageFromResponse(turn1Response, modelStub), - userMsg("What can you do?"), - ] as Parameters[0], - tools: [], - }; - - const stream2 = streamFn( - modelStub as Parameters[0], - ctx2 as Parameters[1], - ); - const done2 = (async () => { - for await (const _ of await resolveStream(stream2)) { - /* consume */ - } - })(); - - await new Promise((r) => setImmediate(r)); - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_turn2_text", "I can help."), - }); - await done2; - - const sent2 = manager.sentEvents[1] as { - previous_response_id?: string; - input: Array<{ type: string; role?: string; content?: unknown }>; - }; - expect(sent2.previous_response_id).toBe("resp_turn1_text"); - expect(sent2.input).toEqual([{ type: "message", role: "user", content: "What can you do?" }]); - }); - - it("uses an empty incremental payload when replay context exactly matches the response chain", async () => { - const sessionId = "sess-full-context-replay"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - const ctx1 = { - systemPrompt: "You are helpful.", - messages: [userMsg("Run ls")] as Parameters[0], - tools: [], - }; - - const turn1Response = { - id: "resp_turn1_reasoning", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "rs_turn1", - content: "Thinking before tool call", - }, - { - type: "function_call", - id: "fc_turn1", - call_id: "call_turn1", - name: "exec", - arguments: '{"cmd":"ls"}', - }, - ], - usage: { input_tokens: 12, output_tokens: 8, total_tokens: 20 }, - } as ResponseObject; - - const stream1 = streamFn( - modelStub as Parameters[0], - ctx1 as Parameters[1], - ); - const done1 = (async () => { - for await (const _ of await resolveStream(stream1)) { - /* consume */ - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ type: "response.completed", response: turn1Response }); - await done1; - - const ctx2 = { - systemPrompt: "You are helpful.", - messages: [ - userMsg("Run ls"), - buildAssistantMessageFromResponse(turn1Response, modelStub), - ] as Parameters[0], - tools: [], - }; - - const stream2 = streamFn( - modelStub as Parameters[0], - ctx2 as Parameters[1], - ); - const done2 = (async () => { - for await (const _ of await resolveStream(stream2)) { - /* consume */ - } - })(); - - await new Promise((r) => setImmediate(r)); - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_turn2", "Done"), - }); - await done2; - - const sent2 = manager.sentEvents[1] as { - previous_response_id?: string; - input: Array<{ type: string; id?: string; call_id?: string }>; - }; - expect(sent2.previous_response_id).toBe("resp_turn1_reasoning"); - expect(sent2.input).toStrictEqual([]); - }); - - it("replays encrypted-only reasoning when websocket must send full context", async () => { - const sessionId = "sess-encrypted-full-context-replay"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - const ctx1 = { - systemPrompt: "You are helpful.", - messages: [userMsg("Run ls")] as Parameters[0], - tools: [], - }; - const turn1Response = { - id: "resp_turn1_encrypted_reasoning", - object: "response", - created_at: Date.now(), - status: "completed", - model: "gpt-5.4", - output: [ - { - type: "reasoning", - id: "rs_turn1_encrypted", - encrypted_content: "encrypted-payload", - summary: [], - }, - { - type: "function_call", - id: "fc_turn1", - call_id: "call_turn1", - name: "exec", - arguments: '{"cmd":"ls"}', - }, - ], - usage: { input_tokens: 12, output_tokens: 8, total_tokens: 20 }, - } as ResponseObject; - - const stream1 = streamFn( - modelStub as Parameters[0], - ctx1 as Parameters[1], - ); - const done1 = (async () => { - for await (const _ of await resolveStream(stream1)) { - /* consume */ - } - })(); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ type: "response.completed", response: turn1Response }); - await done1; - - const ctx2 = { - systemPrompt: "You are helpful. Use the updated instruction.", - messages: [ - userMsg("Run ls"), - buildAssistantMessageFromResponse(turn1Response, modelStub), - toolResultMsg("call_turn1|fc_turn1", "TOOL_OK"), - ] as Parameters[0], - tools: [], - }; - - const stream2 = streamFn( - modelStub as Parameters[0], - ctx2 as Parameters[1], - ); - const done2 = (async () => { - for await (const _ of await resolveStream(stream2)) { - /* consume */ - } - })(); - - await new Promise((r) => setImmediate(r)); - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_turn2", "Done"), - }); - await done2; - - const sent2 = manager.sentEvents[1] as { - previous_response_id?: string; - input: Array>; - }; - expect(sent2.previous_response_id).toBeUndefined(); - expect(sent2.input).toEqual([ - { type: "message", role: "user", content: "Run ls" }, - { - type: "reasoning", - id: "rs_turn1_encrypted", - encrypted_content: "encrypted-payload", - summary: [], - }, - { - type: "function_call", - id: "fc_turn1", - call_id: "call_turn1", - name: "exec", - arguments: '{"cmd":"ls"}', - }, - { type: "function_call_output", call_id: "call_turn1", output: "TOOL_OK" }, - ]); - }); - - it("sends instructions (system prompt) in each request", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-tools"); - const ctx = { - systemPrompt: "Be concise.", - messages: [userMsg("Hello")] as Parameters[0], - tools: [{ name: "exec", description: "run", parameters: {} }], - }; - - const stream = streamFn( - modelStub as Parameters[0], - ctx as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_x", "ok"), - }); - - for await (const _ of await resolveStream(stream)) { - // consume - } - - const sent = manager.sentEvents[0] as { - instructions?: string; - tools?: unknown[]; - }; - expect(sent.instructions).toBe("Be concise."); - expect(Array.isArray(sent.tools)).toBe(true); - expect((sent.tools ?? []).length).toBeGreaterThan(0); - }); - - it("strips the internal cache boundary from websocket instructions", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-boundary"); - const ctx = { - systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`, - messages: [userMsg("Hello")] as Parameters[0], - tools: [], - }; - - const stream = streamFn( - modelStub as Parameters[0], - ctx as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_boundary", "ok"), - }); - - for await (const _ of await resolveStream(stream)) { - // consume - } - - const sent = manager.sentEvents[0] as { - instructions?: string; - }; - expect(sent.instructions).toBe("Stable prefix\nDynamic suffix"); - }); - - it("falls back to HTTP after the websocket send retry budget is exhausted", async () => { - const sessionId = "sess-send-fail-reset"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - // 1. Run a successful first turn to populate the registry - const stream1 = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-ok", "OK"), - }); - for await (const _ of await resolveStream(stream1)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - expect(hasWsSession(sessionId)).toBe(true); - - // 2. Exhaust both websocket send attempts so auto mode must fall back. - MockManager.globalSendFailuresRemaining = 2; - const callsBefore = streamSimpleCalls.length; - - // 3. Second call: send throws → must fall back to HTTP and clear registry - const stream2 = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - for await (const _ of await resolveStream(stream2)) { - /* consume */ - } - - // Registry cleared after retry budget exhaustion + HTTP fallback - expect(hasWsSession(sessionId)).toBe(false); - // HTTP fallback invoked - expect(streamSimpleCalls.length).toBeGreaterThan(callsBefore); - }); - - it("routes websocket HTTP fallback through the configured HTTP fallback builder", async () => { - const httpFallbackCalls: Array<{ model: unknown; context: unknown; options?: unknown }> = []; - const httpFallbackStreamFn = vi.fn((model: unknown, context: unknown, options?: unknown) => { - httpFallbackCalls.push({ model, context, options }); - const stream = createAssistantMessageEventStream(); - queueMicrotask(() => { - const msg = makeFakeAssistantMessage("boundary-safe fallback"); - stream.push({ type: "done", reason: "stop", message: msg }); - stream.end(); - }); - return stream; - }); - mockCreateHttpFallbackStreamFn.mockReturnValue(httpFallbackStreamFn as never); - const sessionId = "sess-boundary-http-fallback"; - const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); - - const stream1 = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-ok", "OK"), - }); - for await (const _ of await resolveStream(stream1)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - - MockManager.globalSendFailuresRemaining = 2; - const stream2 = streamFn( - modelStub as Parameters[0], - { - ...contextStub, - systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`, - } as Parameters[1], - ); - for await (const _ of await resolveStream(stream2)) { - /* consume */ - } - - expect(mockCreateHttpFallbackStreamFn).toHaveBeenCalled(); - expect(streamSimpleCalls).toHaveLength(0); - expect(httpFallbackCalls).toHaveLength(1); - expect(httpFallbackCalls[0]?.context).toMatchObject({ - systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`, - }); - }); - - it("keeps the default websocket HTTP fallback on the OpenClaw transport", () => { - expect( - openAIWsStreamTesting.getDefaultHttpFallbackStreamFnForTest(modelStub as never), - ).toBeTypeOf("function"); - }); - - it("forwards temperature and maxTokens to response.create", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-temp"); - const opts = { temperature: 0.3, maxTokens: 256 }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-temp", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.temperature).toBe(0.3); - expect(sent.max_output_tokens).toBe(256); - }); - - it("forwards maxTokens: 0 to response.create as max_output_tokens", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-max-tokens-zero"); - const opts = { maxTokens: 0 }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-max-zero", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.max_output_tokens).toBe(0); - }); - - it("forwards text verbosity to response.create text block", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-text-verbosity"); - const opts = { textVerbosity: "low" }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-text-verbosity", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.text).toEqual({ verbosity: "low" }); - }); - - it("warns and skips invalid text verbosity in the websocket path", async () => { - const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); - try { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-text-verbosity-invalid"); - const opts = { textVerbosity: "loud" }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-text-verbosity-invalid", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent).not.toHaveProperty("text"); - expect(warnSpy).toHaveBeenCalledWith("ignoring invalid OpenAI text verbosity param: loud"); - } finally { - warnSpy.mockRestore(); - } - }); - - it("forwards reasoningEffort/reasoningSummary to response.create reasoning block", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-reason"); - const opts = { reasoningEffort: "high", reasoningSummary: "auto" }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-reason", "Deep thought"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.reasoning).toEqual({ effort: "high", summary: "auto" }); - expect(sent.include).toEqual(["reasoning.encrypted_content"]); - }); - - it("defaults response.create reasoning effort to high for reasoning models", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-reason-default"); - const stream = streamFn( - { ...modelStub, reasoning: true } as Parameters[0], - contextStub as Parameters[1], - undefined, - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-reason-default", "Default thought"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.reasoning).toEqual({ effort: "high" }); - expect(sent.include).toEqual(["reasoning.encrypted_content"]); - }); - - it("forwards shared reasoning to response.create reasoning effort", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-reason-shared"); - const opts = { reasoning: "medium" }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-reason-shared", "Shared thought"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.reasoning).toEqual({ effort: "medium" }); - }); - - it("maps minimal shared reasoning to low in response.create", () => { - const sent = buildOpenAIWebSocketResponseCreatePayload({ - model: modelStub as never, - context: contextStub as never, - options: { reasoning: "minimal" } as never, - turnInput: { inputItems: [] }, - tools: [], - }); - - expect(sent.reasoning).toEqual({ effort: "low" }); - }); - - it("maps low reasoning to medium for Codex mini websocket requests", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-reason-codex-mini"); - const opts = { reasoning: "low" }; - const stream = streamFn( - { - ...modelStub, - id: "gpt-5.1-codex-mini", - name: "gpt-5.1-codex-mini", - provider: "openai-codex", - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - reasoning: true, - } as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-reason-codex-mini", "Mini thought"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.reasoning).toEqual({ effort: "medium" }); - }); - - it("sends response.create reasoning none when the model supports it", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-reason-none"); - const opts = { reasoningEffort: "none" }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-reason-none", "Short answer"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.reasoning).toEqual({ effort: "none" }); - }); - - it("applies onPayload mutations before sending response.create", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-onpayload"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { - onPayload: (payload: unknown) => { - const request = payload as Record; - request.reasoning = { effort: "none" }; - request.text = { verbosity: "low" }; - request.service_tier = "priority"; - return undefined; - }, - } as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-onpayload", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.reasoning).toEqual({ effort: "none" }); - expect(sent.text).toEqual({ verbosity: "low" }); - expect(sent.service_tier).toBe("priority"); - }); - - it("awaits async onPayload mutations before sending response.create", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-onpayload-async"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { - onPayload: async (payload: unknown) => { - const request = payload as Record; - await Promise.resolve(); - request.metadata = { async_hook: "applied" }; - return undefined; - }, - } as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-onpayload-async", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.metadata).toMatchObject({ async_hook: "applied" }); - }); - it("forwards topP and toolChoice to response.create", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-topp"); - const opts = { topP: 0.9, toolChoice: "auto" }; - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - opts as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-topp", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - /* consume */ - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents[0] as Record; - expect(sent.type).toBe("response.create"); - expect(sent.top_p).toBe(0.9); - expect(sent.tool_choice).toBe("auto"); - }); - - it("keeps explicit websocket mode surfacing mid-request drops", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-drop"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { transport: "websocket" } as Parameters[2], - ); - // Let the send go through, then simulate connection drop before response.completed - await new Promise((resolve) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - // Simulate a connection drop instead of sending response.completed - MockManager.lastInstance!.simulateClose(1006, "connection lost"); - const events: unknown[] = []; - for await (const ev of await resolveStream(stream)) { - events.push(ev); - } - // Should have gotten an error event, not hung forever - const hasError = events.some( - (e) => typeof e === "object" && e !== null && (e as { type: string }).type === "error", - ); - expect(hasError).toBe(true); - resolve(); - } catch { - // The error propagation is also acceptable — promise rejected - resolve(); - } - }); - }); - }); - - it("sends warm-up event before first request when openaiWsWarmup=true", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-warmup-enabled"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { openaiWsWarmup: true } as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-warm", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents as Array>; - expect(sent).toHaveLength(2); - expect(sent[0]?.type).toBe("response.create"); - expect(sent[0]?.generate).toBe(false); - expect(sent[1]?.type).toBe("response.create"); - }); - - it("skips warm-up when openaiWsWarmup=false", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-warmup-disabled"); - const stream = streamFn( - modelStub as Parameters[0], - contextStub as Parameters[1], - { openaiWsWarmup: false } as unknown as Parameters[2], - ); - await new Promise((resolve, reject) => { - queueMicrotask(async () => { - try { - await new Promise((r) => setImmediate(r)); - MockManager.lastInstance!.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-nowarm", "Done"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - resolve(); - } catch (e) { - reject(e); - } - }); - }); - const sent = MockManager.lastInstance!.sentEvents as Array>; - expect(sent).toHaveLength(1); - expect(sent[0]?.type).toBe("response.create"); - expect(sent[0]?.generate).toBeUndefined(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── - -describe("releaseWsSession / hasWsSession", () => { - beforeEach(() => { - MockManager.reset(); - openAIWsStreamTesting.setDepsForTest({ - createManager: (() => new MockManager()) as never, - createHttpFallbackStreamFn: mockCreateHttpFallbackStreamFn as never, - streamSimple: mockStreamSimple, - }); - }); - - afterEach(() => { - releaseWsSession("registry-test"); - openAIWsStreamTesting.setDepsForTest(); - }); - - it("hasWsSession returns false for unknown session", () => { - expect(hasWsSession("nonexistent-session")).toBe(false); - }); - - it("hasWsSession returns true after a session is created", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "registry-test"); - const stream = streamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.2", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Hi") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - // Session should be registered and connected - expect(hasWsSession("registry-test")).toBe(true); - - // Clean up - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_z", "done"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - }); - - it("releaseWsSession closes the connection and removes the session", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "registry-test"); - const stream = streamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.2", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Hi") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp_zz", "done"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - - releaseWsSession("registry-test"); - expect(hasWsSession("registry-test")).toBe(false); - expect(manager.closeCallCount).toBe(1); - }); - - it("pools cleanly released sessions behind the explicit pool flag", async () => { - const streamFn = createOpenAIWebSocketStreamFn("sk-test", "registry-test"); - const stream = streamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.4", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Hi") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - const manager = MockManager.lastInstance!; - manager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-pooled", "done"), - }); - for await (const _ of await resolveStream(stream)) { - // consume - } - - vi.useFakeTimers(); - try { - releaseWsSession("registry-test", { - allowPool: true, - env: { - OPENCLAW_OPENAI_WS_POOL: "1", - OPENCLAW_OPENAI_WS_SESSION_POOL_IDLE_MS: "1000", - } as NodeJS.ProcessEnv, - }); - - expect(hasWsSession("registry-test")).toBe(true); - expect(manager.closeCallCount).toBe(0); - - await vi.advanceTimersByTimeAsync(999); - expect(hasWsSession("registry-test")).toBe(true); - - await vi.advanceTimersByTimeAsync(1); - expect(hasWsSession("registry-test")).toBe(false); - expect(manager.closeCallCount).toBe(1); - } finally { - vi.useRealTimers(); - } - }); - - it("releaseWsSession is a no-op for unknown sessions", () => { - expect(releaseWsSession("nonexistent-session")).toBeUndefined(); - }); - - it("recreates the cached manager when request overrides change for the same session", async () => { - const sessionId = "registry-test"; - const firstStreamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId, { - managerOptions: { - request: { - headers: { "x-test": "one" }, - }, - }, - }); - const firstStream = firstStreamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.4", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Hi") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - const firstManager = MockManager.lastInstance!; - firstManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-first", "done"), - }); - for await (const _ of await resolveStream(firstStream)) { - // consume - } - - const secondStreamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId, { - managerOptions: { - request: { - headers: { "x-test": "two" }, - allowPrivateNetwork: true, - }, - }, - }); - const secondStream = secondStreamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.4", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Again") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - expect(MockManager.instances).toHaveLength(2); - expect(firstManager.closeCallCount).toBe(1); - const secondManager = MockManager.lastInstance!; - expect(secondManager).not.toBe(firstManager); - expect(secondManager.connectCallCount).toBe(1); - - secondManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-second", "done"), - }); - for await (const _ of await resolveStream(secondStream)) { - // consume - } - }); - - it("recreates the cached manager when the API key changes for the same session", async () => { - const sessionId = "registry-test"; - const firstStreamFn = createOpenAIWebSocketStreamFn("sk-first", sessionId); - const firstStream = firstStreamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.4", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Hi") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - const firstManager = MockManager.lastInstance!; - expect(firstManager.connectApiKeys).toEqual(["sk-first"]); - firstManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-first-key", "done"), - }); - for await (const _ of await resolveStream(firstStream)) { - // consume - } - - const secondStreamFn = createOpenAIWebSocketStreamFn("sk-second", sessionId); - const secondStream = secondStreamFn( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - contextWindow: 128000, - maxTokens: 4096, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - name: "GPT-5.4", - } as Parameters[0], - { - systemPrompt: "test", - messages: [userMsg("Again") as Parameters[0][number]], - tools: [], - } as Parameters[1], - ); - - await new Promise((r) => setImmediate(r)); - expect(MockManager.instances).toHaveLength(2); - expect(firstManager.closeCallCount).toBe(1); - const secondManager = MockManager.lastInstance!; - expect(secondManager).not.toBe(firstManager); - expect(secondManager.connectApiKeys).toEqual(["sk-second"]); - - secondManager.simulateEvent({ - type: "response.completed", - response: makeResponseObject("resp-second-key", "done"), - }); - for await (const _ of await resolveStream(secondStream)) { - // consume - } - }); -}); - -describe("convertMessagesToInputItems — phase inheritance", () => { - it("keeps unsigned legacy text unphased while id-only replay text inherits message phase", () => { - const msg = { - role: "assistant" as const, - phase: "commentary", - content: [ - { type: "text", text: "Untagged block A" }, - { - type: "text", - text: "Replay block", - textSignature: JSON.stringify({ v: 1, id: "s0" }), - }, - { - type: "text", - text: "Explicitly final", - textSignature: JSON.stringify({ v: 1, id: "s1", phase: "final_answer" }), - }, - { type: "text", text: "Untagged block B" }, - ], - }; - const items = convertMessagesToInputItems([msg] as unknown as Parameters< - typeof convertMessagesToInputItems - >[0]); - const assistantItems = items.filter((i: Record) => i.role === "assistant"); - expect(assistantItems).toHaveLength(4); - expect(assistantItems[0]).toMatchObject({ - role: "assistant", - content: "Untagged block A", - }); - expect((assistantItems[0] as Record).phase).toBeUndefined(); - expect(assistantItems[1]).toMatchObject({ - role: "assistant", - content: "Replay block", - phase: "commentary", - }); - expect(assistantItems[2]).toMatchObject({ - role: "assistant", - content: "Explicitly final", - phase: "final_answer", - }); - expect(assistantItems[3]).toMatchObject({ - role: "assistant", - content: "Untagged block B", - }); - expect((assistantItems[3] as Record).phase).toBeUndefined(); - }); -}); diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts deleted file mode 100644 index 279a5e69a3e..00000000000 --- a/src/agents/openai-ws-stream.ts +++ /dev/null @@ -1,1369 +0,0 @@ -import { createHash, randomUUID } from "node:crypto"; -import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { - AssistantMessage, - AssistantMessageEvent, - AssistantMessageEventStream, - StopReason, -} from "@mariozechner/pi-ai"; -import * as piAi from "@mariozechner/pi-ai"; -/** - * OpenAI WebSocket StreamFn Integration - * - * Wraps `OpenAIWebSocketManager` in a `StreamFn` that can be plugged into the - * pi-embedded-runner agent in place of the default `streamSimple` HTTP function. - * - * Key behaviours: - * - Per-session `OpenAIWebSocketManager` (keyed by sessionId) - * - Tracks `previous_response_id` to send only incremental tool-result inputs - * - Falls back to the OpenClaw HTTP transport if the WebSocket connection fails - * - Cleanup helpers for releasing sessions after the run completes - * - * Complexity budget & risk mitigation: - * - **Transport aware**: respects `transport` (`auto` | `websocket` | `sse`) - * - **Transparent fallback in `auto` mode**: connect/send failures fall back to - * the existing HTTP path; forced `websocket` mode surfaces WS errors - * - **Zero shared state**: per-session registry; session cleanup on dispose prevents leaks - * - **Full parity**: all generation options (temperature, top_p, max_output_tokens, - * tool_choice, reasoning) forwarded identically to the HTTP path - * - * @see src/agents/openai-ws-connection.ts for the connection manager - */ -import { formatErrorMessage } from "../infra/errors.js"; -import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; -import { - resolveProviderTransportTurnStateWithPlugin, - resolveProviderWebSocketSessionPolicyWithPlugin, -} from "../plugins/provider-runtime.js"; -import type { ProviderTransportTurnState } from "../plugins/types.js"; -import { - encodeAssistantTextSignature, - normalizeAssistantPhase, -} from "../shared/chat-message-content.js"; -import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; -import { - getOpenAIWebSocketErrorDetails, - OpenAIWebSocketManager, - type FunctionToolDefinition, - type OpenAIResponsesAssistantPhase, - type OpenAIWebSocketManagerOptions, -} from "./openai-ws-connection.js"; -import { - buildAssistantMessageFromResponse, - convertMessagesToInputItems, - convertResponseToInputItems, - convertTools, - planTurnInput, -} from "./openai-ws-message-conversion.js"; -import { - buildOpenAIWebSocketResponseCreatePayload, - planOpenAIWebSocketRequestPayload, -} from "./openai-ws-request.js"; -import type { ResponseCreateEvent } from "./openai-ws-types.js"; -import { log } from "./pi-embedded-runner/logger.js"; -import { resolveProviderEndpoint } from "./provider-attribution.js"; -import { normalizeProviderId } from "./provider-id.js"; -import { createOpenClawTransportStreamFnForModel } from "./provider-transport-stream.js"; -import { - buildAssistantMessageWithZeroUsage, - buildStreamErrorAssistantMessage, -} from "./stream-message-shared.js"; -import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; -import { mergeTransportMetadata } from "./transport-stream-shared.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// Per-session state -// ───────────────────────────────────────────────────────────────────────────── - -interface WsSession { - manager: OpenAIWebSocketManager; - managerConfigSignature: string; - authSignature: string; - /** Number of messages that were in context.messages at the END of the last streamFn call. */ - lastContextLength: number; - /** Last full canonical request, before any incremental previous_response_id delta rewrite. */ - lastRequestPayload?: ResponseCreateEvent; - /** Last response output converted to the same replay form used by future full-context sends. */ - lastResponseInputItems: ReturnType; - /** True if the connection has been established at least once. */ - everConnected: boolean; - /** True once a best-effort warm-up attempt has run for this session. */ - warmUpAttempted: boolean; - /** True if the session is permanently broken (no more reconnect). */ - broken: boolean; - /** Pending idle release timer when disabled-by-default pooling retains a session. */ - idleTimer?: ReturnType; - pooledUntil?: number; - /** Session-scoped cool-down after repeated websocket failures. */ - degradedUntil: number | null; - degradeCooldownMs: number; -} - -function resolveOpenAIWebSocketStrictToolSetting( - model: Parameters[0], -): boolean | undefined { - return resolveOpenAIStrictToolSetting(model, { - transport: "websocket", - supportsStrictMode: - model.compat && typeof model.compat === "object" - ? (model.compat as { supportsStrictMode?: boolean }).supportsStrictMode - : undefined, - }); -} - -/** Module-level registry: sessionId → WsSession */ -const wsRegistry = new Map(); - -type OpenAIWsStreamDeps = { - createManager: (options?: OpenAIWebSocketManagerOptions) => OpenAIWebSocketManager; - createHttpFallbackStreamFn: (model: ProviderRuntimeModel) => StreamFn | undefined; - streamSimple: typeof piAi.streamSimple; -}; - -type AssistantMessageWithPhase = AssistantMessage & { phase?: OpenAIResponsesAssistantPhase }; - -const defaultOpenAIWsStreamDeps: OpenAIWsStreamDeps = { - createManager: (options) => new OpenAIWebSocketManager(options), - // WebSocket auto-mode HTTP fallback must keep the OpenClaw transport path so - // degraded sessions do not leak cache-boundary markers or lose strict tools. - createHttpFallbackStreamFn: (model) => createOpenClawTransportStreamFnForModel(model), - streamSimple: (...args) => piAi.streamSimple(...args), -}; - -let openAIWsStreamDeps: OpenAIWsStreamDeps = defaultOpenAIWsStreamDeps; - -type AssistantMessageEventStreamLike = { - push(event: AssistantMessageEvent): void; - end(result?: AssistantMessage): void; - result(): Promise; - [Symbol.asyncIterator](): AsyncIterator; -}; - -class LocalAssistantMessageEventStream implements AssistantMessageEventStreamLike { - private readonly queue: AssistantMessageEvent[] = []; - private readonly waiting: Array<(value: IteratorResult) => void> = []; - private done = false; - private readonly finalResultPromise: Promise; - private resolveFinalResult!: (result: AssistantMessage) => void; - - constructor() { - this.finalResultPromise = new Promise((resolve) => { - this.resolveFinalResult = resolve; - }); - } - - push(event: AssistantMessageEvent): void { - if (this.done) { - return; - } - if (event.type === "done") { - this.done = true; - this.resolveFinalResult(event.message); - } else if (event.type === "error") { - this.done = true; - this.resolveFinalResult(event.error); - } - const waiter = this.waiting.shift(); - if (waiter) { - waiter({ value: event, done: false }); - return; - } - this.queue.push(event); - } - - end(result?: AssistantMessage): void { - this.done = true; - if (result) { - this.resolveFinalResult(result); - } - while (this.waiting.length > 0) { - const waiter = this.waiting.shift(); - waiter?.({ value: undefined as unknown as AssistantMessageEvent, done: true }); - } - } - - async *[Symbol.asyncIterator](): AsyncIterator { - while (true) { - if (this.queue.length > 0) { - yield this.queue.shift()!; - continue; - } - if (this.done) { - return; - } - const result = await new Promise>((resolve) => { - this.waiting.push(resolve); - }); - if (result.done) { - return; - } - yield result.value; - } - } - - result(): Promise { - return this.finalResultPromise; - } -} - -function createEventStream(): AssistantMessageEventStream { - return typeof piAi.createAssistantMessageEventStream === "function" - ? piAi.createAssistantMessageEventStream() - : (new LocalAssistantMessageEventStream() as unknown as AssistantMessageEventStream); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Public registry helpers -// ───────────────────────────────────────────────────────────────────────────── - -type ReleaseWsSessionOptions = { - allowPool?: boolean; - env?: NodeJS.ProcessEnv; -}; - -function resolveWsSessionPoolConfig(env: NodeJS.ProcessEnv = process.env): { - enabled: boolean; - idleMs: number; -} { - const enabled = - env.OPENCLAW_OPENAI_WS_POOL === "1" || env.OPENCLAW_OPENAI_WS_SESSION_POOL === "1"; - const rawIdleMs = Number(env.OPENCLAW_OPENAI_WS_SESSION_POOL_IDLE_MS); - const idleMs = Number.isFinite(rawIdleMs) - ? Math.min(300_000, Math.max(1_000, Math.trunc(rawIdleMs))) - : 30_000; - return { enabled, idleMs }; -} - -function clearWsSessionIdleTimer(session: WsSession): void { - if (!session.idleTimer) { - return; - } - clearTimeout(session.idleTimer); - session.idleTimer = undefined; - session.pooledUntil = undefined; -} - -function closeWsSession(sessionId: string, session: WsSession): void { - clearWsSessionIdleTimer(session); - try { - session.manager.close(); - } catch { - // Ignore close errors — connection may already be gone. - } - wsRegistry.delete(sessionId); -} - -/** - * Release and close the WebSocket session for the given sessionId. - * Call this after the agent run completes to free the connection. - */ -export function releaseWsSession(sessionId: string, options: ReleaseWsSessionOptions = {}): void { - const session = wsRegistry.get(sessionId); - if (!session) { - return; - } - const pool = resolveWsSessionPoolConfig(options.env); - if ( - options.allowPool === true && - pool.enabled && - !session.broken && - session.manager.isConnected() - ) { - clearWsSessionIdleTimer(session); - session.pooledUntil = Date.now() + pool.idleMs; - session.idleTimer = setTimeout(() => { - const current = wsRegistry.get(sessionId); - if (current === session) { - closeWsSession(sessionId, session); - } - }, pool.idleMs); - session.idleTimer.unref?.(); - log.debug(`[ws-stream] pooled websocket session=${sessionId} idleMs=${pool.idleMs}`); - return; - } - closeWsSession(sessionId, session); -} - -/** - * Returns true if a live WebSocket session exists for the given sessionId. - */ -export function hasWsSession(sessionId: string): boolean { - const s = wsRegistry.get(sessionId); - return !!(s && !s.broken && s.manager.isConnected()); -} - -export { - buildAssistantMessageFromResponse, - convertMessagesToInputItems, - convertTools, - planTurnInput, -} from "./openai-ws-message-conversion.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// StreamFn factory -// ───────────────────────────────────────────────────────────────────────────── - -export interface OpenAIWebSocketStreamOptions { - /** Manager options (url override, retry counts, etc.) */ - managerOptions?: OpenAIWebSocketManagerOptions; - /** Abort signal forwarded from the run. */ - signal?: AbortSignal; -} - -type WsTransport = "sse" | "websocket" | "auto"; -const WARM_UP_TIMEOUT_MS = 8_000; -const MAX_AUTO_WS_RUNTIME_RETRIES = 1; -const DEFAULT_WS_DEGRADE_COOLDOWN_MS = 60_000; -let wsDegradeCooldownMsOverride: number | undefined; - -class OpenAIWebSocketRuntimeError extends Error { - readonly kind: "disconnect" | "send" | "server"; - readonly retryable: boolean; - readonly closeCode?: number; - readonly closeReason?: string; - - constructor( - message: string, - params: { - kind: "disconnect" | "send" | "server"; - retryable: boolean; - closeCode?: number; - closeReason?: string; - }, - ) { - super(message); - this.name = "OpenAIWebSocketRuntimeError"; - this.kind = params.kind; - this.retryable = params.retryable; - this.closeCode = params.closeCode; - this.closeReason = params.closeReason; - } -} - -function resolveWsTransport(options: Parameters[2]): WsTransport { - const transport = (options as { transport?: unknown } | undefined)?.transport; - return transport === "sse" || transport === "websocket" || transport === "auto" - ? transport - : "auto"; -} - -type WsOptions = Parameters[2] & { openaiWsWarmup?: unknown; signal?: AbortSignal }; - -function resolveWsWarmup(options: Parameters[2]): boolean { - const warmup = (options as WsOptions | undefined)?.openaiWsWarmup; - return warmup === true; -} - -function resetWsSession(params: { - session: WsSession; - createManager: () => OpenAIWebSocketManager; - preserveDegradeUntil?: boolean; -}): void { - clearWsSessionIdleTimer(params.session); - try { - params.session.manager.close(); - } catch { - /* ignore */ - } - params.session.manager = params.createManager(); - params.session.everConnected = false; - params.session.warmUpAttempted = false; - params.session.broken = false; - params.session.lastContextLength = 0; - params.session.lastRequestPayload = undefined; - params.session.lastResponseInputItems = []; - if (!params.preserveDegradeUntil) { - params.session.degradedUntil = null; - } -} - -function markWsSessionDegraded(session: WsSession): void { - session.degradedUntil = Date.now() + session.degradeCooldownMs; -} - -function isWsSessionDegraded(session: WsSession): boolean { - if (!session.degradedUntil) { - return false; - } - if (session.degradedUntil <= Date.now()) { - session.degradedUntil = null; - return false; - } - return true; -} - -function createWsManager( - managerOptions: OpenAIWebSocketManagerOptions | undefined, - sessionHeaders?: Record, -): OpenAIWebSocketManager { - return openAIWsStreamDeps.createManager({ - ...managerOptions, - ...(sessionHeaders - ? { - headers: { - ...managerOptions?.headers, - ...sessionHeaders, - }, - } - : {}), - }); -} - -function stringifyStable(value: unknown): string { - if (value === null || typeof value !== "object") { - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map((entry) => stringifyStable(entry)).join(",")}]`; - } - const entries = Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right)); - return `{${entries - .map(([key, entry]) => `${JSON.stringify(key)}:${stringifyStable(entry)}`) - .join(",")}}`; -} - -function resolveWsManagerConfigSignature( - managerOptions: OpenAIWebSocketManagerOptions | undefined, - sessionHeaders?: Record, -): string { - return stringifyStable({ - headers: sessionHeaders, - request: managerOptions?.request, - url: managerOptions?.url, - }); -} - -function resolveWsAuthSignature(apiKey: string): string { - return createHash("sha256").update(apiKey).digest("hex"); -} - -const AZURE_OPENAI_PROVIDER_IDS = new Set(["azure-openai", "azure-openai-responses"]); -const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; - -function normalizeTransportIdentityValue(value: string, maxLength = 160): string { - const trimmed = value.trim().replace(/[\r\n]+/gu, " "); - return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed; -} - -function usesNativeOpenAIRoute(provider: string, baseUrl?: string): boolean { - const endpointClass = resolveProviderEndpoint(baseUrl).endpointClass; - const normalizedProvider = normalizeProviderId(provider); - if (!normalizedProvider) { - return false; - } - if (normalizedProvider === "openai") { - return endpointClass === "default" || endpointClass === "openai-public"; - } - if (AZURE_OPENAI_PROVIDER_IDS.has(normalizedProvider)) { - return endpointClass === "default" || endpointClass === "azure-openai"; - } - if (normalizedProvider === OPENAI_CODEX_PROVIDER_ID) { - return ( - endpointClass === "default" || - endpointClass === "openai-public" || - endpointClass === "openai-codex" - ); - } - return false; -} - -function resolveNativeOpenAISessionHeaders(params: { - provider: string; - baseUrl?: string; - sessionId?: string; -}): Record | undefined { - if (!params.sessionId || !usesNativeOpenAIRoute(params.provider, params.baseUrl)) { - return undefined; - } - const sessionId = normalizeTransportIdentityValue(params.sessionId); - if (!sessionId) { - return undefined; - } - return { - "x-client-request-id": sessionId, - "x-openclaw-session-id": sessionId, - }; -} - -function resolveNativeOpenAITransportTurnState(params: { - provider: string; - baseUrl?: string; - sessionId?: string; - turnId: string; - attempt: number; - transport: "stream" | "websocket"; -}): ProviderTransportTurnState | undefined { - const sessionHeaders = resolveNativeOpenAISessionHeaders({ - provider: params.provider, - baseUrl: params.baseUrl, - sessionId: params.sessionId, - }); - if (!sessionHeaders) { - return undefined; - } - const turnId = normalizeTransportIdentityValue(params.turnId); - const attempt = String(Math.max(1, params.attempt)); - return { - headers: { - ...sessionHeaders, - "x-openclaw-turn-id": turnId, - "x-openclaw-turn-attempt": attempt, - }, - metadata: { - openclaw_session_id: sessionHeaders["x-openclaw-session-id"] ?? "", - openclaw_turn_id: turnId, - openclaw_turn_attempt: attempt, - openclaw_transport: params.transport, - }, - }; -} - -function resolveProviderTransportTurnState( - model: Parameters[0], - params: { - sessionId?: string; - turnId: string; - attempt: number; - transport: "stream" | "websocket"; - }, -): ProviderTransportTurnState | undefined { - if (usesNativeOpenAIRoute(model.provider, (model as { baseUrl?: string }).baseUrl)) { - return resolveNativeOpenAITransportTurnState({ - provider: model.provider, - baseUrl: (model as { baseUrl?: string }).baseUrl, - sessionId: params.sessionId, - turnId: params.turnId, - attempt: params.attempt, - transport: params.transport, - }); - } - return ( - resolveProviderTransportTurnStateWithPlugin({ - provider: model.provider, - context: { - provider: model.provider, - modelId: model.id, - model: model as ProviderRuntimeModel, - sessionId: params.sessionId, - turnId: params.turnId, - attempt: params.attempt, - transport: params.transport, - }, - }) ?? undefined - ); -} - -function resolveWebSocketSessionPolicy( - model: Parameters[0], - sessionId: string, -): { headers?: Record; degradeCooldownMs: number } { - if (usesNativeOpenAIRoute(model.provider, (model as { baseUrl?: string }).baseUrl)) { - return { - headers: resolveNativeOpenAISessionHeaders({ - provider: model.provider, - baseUrl: (model as { baseUrl?: string }).baseUrl, - sessionId, - }), - degradeCooldownMs: Math.max(0, wsDegradeCooldownMsOverride ?? DEFAULT_WS_DEGRADE_COOLDOWN_MS), - }; - } - const policy = resolveProviderWebSocketSessionPolicyWithPlugin({ - provider: model.provider, - context: { - provider: model.provider, - modelId: model.id, - model: model as ProviderRuntimeModel, - sessionId, - }, - }); - return { - headers: policy?.headers, - degradeCooldownMs: Math.max( - 0, - wsDegradeCooldownMsOverride ?? policy?.degradeCooldownMs ?? DEFAULT_WS_DEGRADE_COOLDOWN_MS, - ), - }; -} - -function formatOpenAIWebSocketError( - event: Parameters[0] extends (arg: infer T) => void - ? Extract - : never, -): string { - const details = getOpenAIWebSocketErrorDetails(event); - const code = details.code ?? "unknown"; - const message = details.message ?? "Unknown error"; - const extras = [ - typeof details.status === "number" ? `status=${details.status}` : null, - details.type ? `type=${details.type}` : null, - details.param ? `param=${details.param}` : null, - ].filter(Boolean); - return extras.length > 0 - ? `${message} (code=${code}; ${extras.join(", ")})` - : `${message} (code=${code})`; -} - -function formatOpenAIWebSocketResponseFailure(response: { - error?: { code?: string; message?: string }; - incomplete_details?: { reason?: string }; -}): string { - if (response.error) { - return `${response.error.code || "unknown"}: ${response.error.message || "no message"}`; - } - if (response.incomplete_details?.reason) { - return `incomplete: ${response.incomplete_details.reason}`; - } - return "Unknown error (no error details in response)"; -} - -function normalizeWsRunError(err: unknown): OpenAIWebSocketRuntimeError { - if (err instanceof OpenAIWebSocketRuntimeError) { - return err; - } - return new OpenAIWebSocketRuntimeError(formatErrorMessage(err), { - kind: "server", - retryable: false, - }); -} - -function buildRetryableSendError(err: unknown): OpenAIWebSocketRuntimeError { - return new OpenAIWebSocketRuntimeError( - err instanceof Error ? err.message : `WebSocket send failed: ${String(err)}`, - { - kind: "send", - retryable: true, - }, - ); -} -async function runWarmUp(params: { - manager: OpenAIWebSocketManager; - modelId: string; - tools: FunctionToolDefinition[]; - instructions?: string; - metadata?: Record; - signal?: AbortSignal; -}): Promise { - if (params.signal?.aborted) { - throw new Error("aborted"); - } - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error(`warm-up timed out after ${WARM_UP_TIMEOUT_MS}ms`)); - }, WARM_UP_TIMEOUT_MS); - - const abortHandler = () => { - cleanup(); - reject(new Error("aborted")); - }; - const closeHandler = (code: number, reason: string) => { - cleanup(); - reject(new Error(`warm-up closed (code=${code}, reason=${reason || "unknown"})`)); - }; - const unsubscribe = params.manager.onMessage((event) => { - if (event.type === "response.completed") { - cleanup(); - resolve(); - } else if (event.type === "response.failed") { - cleanup(); - reject( - new Error(`warm-up failed: ${formatOpenAIWebSocketResponseFailure(event.response)}`), - ); - } else if (event.type === "error") { - cleanup(); - reject(new Error(`warm-up error: ${formatOpenAIWebSocketError(event)}`)); - } - }); - - const cleanup = () => { - clearTimeout(timeout); - params.signal?.removeEventListener("abort", abortHandler); - params.manager.off("close", closeHandler); - unsubscribe(); - }; - - params.signal?.addEventListener("abort", abortHandler, { once: true }); - params.manager.on("close", closeHandler); - params.manager.warmUp({ - model: params.modelId, - tools: params.tools.length > 0 ? params.tools : undefined, - instructions: params.instructions, - ...(params.metadata ? { metadata: params.metadata } : {}), - }); - }); -} - -/** - * Creates a `StreamFn` backed by a persistent WebSocket connection to the - * OpenAI Responses API. The first call for a given `sessionId` opens the - * connection; subsequent calls reuse it, sending only incremental tool-result - * inputs with `previous_response_id`. - * - * If the WebSocket connection is unavailable, the function falls back to an - * OpenClaw HTTP transport when available, or the standard `streamSimple` path. - * - * @param apiKey OpenAI API key - * @param sessionId Agent session ID (used as the registry key) - * @param opts Optional manager + abort signal overrides - */ -export function createOpenAIWebSocketStreamFn( - apiKey: string, - sessionId: string, - opts: OpenAIWebSocketStreamOptions = {}, -): StreamFn { - return (model, context, options) => { - const eventStream = createEventStream(); - - const run = async () => { - const transport = resolveWsTransport(options); - if (transport === "sse") { - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal); - } - - const signal = opts.signal ?? (options as WsOptions | undefined)?.signal; - let emittedStart = false; - let runtimeRetries = 0; - const turnId = randomUUID(); - let turnAttempt = 0; - const wsSessionPolicy = resolveWebSocketSessionPolicy(model, sessionId); - const sessionHeaders = wsSessionPolicy.headers; - - while (true) { - let session = wsRegistry.get(sessionId); - const authSignature = resolveWsAuthSignature(apiKey); - const managerConfigSignature = resolveWsManagerConfigSignature( - opts.managerOptions, - sessionHeaders, - ); - if (!session) { - const manager = createWsManager(opts.managerOptions, sessionHeaders); - session = { - manager, - managerConfigSignature, - authSignature, - lastContextLength: 0, - lastResponseInputItems: [], - everConnected: false, - warmUpAttempted: false, - broken: false, - degradedUntil: null, - degradeCooldownMs: wsSessionPolicy.degradeCooldownMs, - }; - wsRegistry.set(sessionId, session); - } else if ( - session.managerConfigSignature !== managerConfigSignature || - session.authSignature !== authSignature - ) { - clearWsSessionIdleTimer(session); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - }); - session.managerConfigSignature = managerConfigSignature; - session.authSignature = authSignature; - session.degradeCooldownMs = wsSessionPolicy.degradeCooldownMs; - } else { - clearWsSessionIdleTimer(session); - } - - if (transport !== "websocket" && isWsSessionDegraded(session)) { - log.debug( - `[ws-stream] session=${sessionId} in websocket cool-down; using HTTP fallback until ${new Date(session.degradedUntil!).toISOString()}`, - ); - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { - suppressStart: emittedStart, - turnState: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: Math.max(1, turnAttempt), - transport: "stream", - }), - }); - } - - if (!session.manager.isConnected() && !session.broken) { - try { - await session.manager.connect(apiKey); - session.everConnected = true; - session.degradedUntil = null; - log.debug(`[ws-stream] connected for session=${sessionId}`); - } catch (connErr) { - markWsSessionDegraded(session); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - preserveDegradeUntil: true, - }); - if (transport === "websocket") { - throw connErr instanceof Error ? connErr : new Error(String(connErr)); - } - log.warn( - `[ws-stream] WebSocket connect failed for session=${sessionId}; falling back to HTTP. error=${String(connErr)}`, - ); - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { - suppressStart: emittedStart, - turnState: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: Math.max(1, turnAttempt), - transport: "stream", - }), - }); - } - } - - if (session.broken || !session.manager.isConnected()) { - if (transport === "websocket") { - throw new Error("WebSocket session disconnected"); - } - log.warn(`[ws-stream] session=${sessionId} broken/disconnected; falling back to HTTP`); - markWsSessionDegraded(session); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - preserveDegradeUntil: true, - }); - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { - suppressStart: emittedStart, - turnState: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: Math.max(1, turnAttempt), - transport: "stream", - }), - }); - } - - if (resolveWsWarmup(options) && !session.warmUpAttempted) { - session.warmUpAttempted = true; - let warmupFailed = false; - try { - await runWarmUp({ - manager: session.manager, - modelId: model.id, - tools: convertTools(context.tools, { - strict: resolveOpenAIWebSocketStrictToolSetting(model), - }), - instructions: context.systemPrompt - ? stripSystemPromptCacheBoundary(context.systemPrompt) - : undefined, - metadata: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: Math.max(1, turnAttempt), - transport: "websocket", - })?.metadata, - signal, - }); - log.debug(`[ws-stream] warm-up completed for session=${sessionId}`); - } catch (warmErr) { - if (signal?.aborted) { - throw warmErr instanceof Error ? warmErr : new Error(String(warmErr)); - } - warmupFailed = true; - log.warn( - `[ws-stream] warm-up failed for session=${sessionId}; continuing without warm-up. error=${String(warmErr)}`, - ); - } - if (warmupFailed && !session.manager.isConnected()) { - try { - session.manager.close(); - } catch { - /* ignore */ - } - try { - session.manager = createWsManager(opts.managerOptions, sessionHeaders); - await session.manager.connect(apiKey); - session.everConnected = true; - session.degradedUntil = null; - log.debug(`[ws-stream] reconnected after warm-up failure for session=${sessionId}`); - } catch (reconnectErr) { - markWsSessionDegraded(session); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - preserveDegradeUntil: true, - }); - if (transport === "websocket") { - throw reconnectErr instanceof Error - ? reconnectErr - : new Error(String(reconnectErr)); - } - log.warn( - `[ws-stream] reconnect after warm-up failed for session=${sessionId}; falling back to HTTP. error=${String(reconnectErr)}`, - ); - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { - suppressStart: emittedStart, - turnState: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: Math.max(1, turnAttempt), - transport: "stream", - }), - }); - } - } - } - - turnAttempt++; - const turnState = resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: turnAttempt, - transport: "websocket", - }); - const fullTurnInput = { - inputItems: convertMessagesToInputItems(context.messages, model), - }; - let fullPayload = buildOpenAIWebSocketResponseCreatePayload({ - model, - context, - options: options as WsOptions | undefined, - turnInput: fullTurnInput, - tools: convertTools(context.tools, { - strict: resolveOpenAIWebSocketStrictToolSetting(model), - }), - metadata: turnState?.metadata, - }) as Record; - const nextPayload = await options?.onPayload?.(fullPayload, model); - fullPayload = mergeTransportMetadata( - (nextPayload ?? fullPayload) as Record, - turnState?.metadata, - ); - const plannedPayload = planOpenAIWebSocketRequestPayload({ - fullPayload: fullPayload as ResponseCreateEvent, - previousRequestPayload: session.lastRequestPayload, - previousResponseId: session.manager.previousResponseId, - previousResponseInputItems: session.lastResponseInputItems, - }); - const plannedInputItems = Array.isArray(plannedPayload.payload.input) - ? plannedPayload.payload.input - : []; - if (plannedPayload.mode === "incremental") { - log.debug( - `[ws-stream] session=${sessionId}: incremental send (${plannedInputItems.length} items) previous_response_id=${plannedPayload.payload.previous_response_id}`, - ); - } else { - log.debug( - `[ws-stream] session=${sessionId}: full context send (${plannedInputItems.length} items)`, - ); - } - const requestPayload = plannedPayload.payload as Parameters< - OpenAIWebSocketManager["send"] - >[0]; - - try { - session.manager.send(requestPayload); - } catch (sendErr) { - const normalizedErr = buildRetryableSendError(sendErr); - if ( - transport !== "websocket" && - !signal?.aborted && - runtimeRetries < MAX_AUTO_WS_RUNTIME_RETRIES - ) { - runtimeRetries++; - log.warn( - `[ws-stream] retrying websocket turn after send failure for session=${sessionId} (${runtimeRetries}/${MAX_AUTO_WS_RUNTIME_RETRIES}). error=${normalizedErr.message}`, - ); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - }); - continue; - } - if (transport !== "websocket") { - log.warn( - `[ws-stream] send failed for session=${sessionId}; falling back to HTTP. error=${normalizedErr.message}`, - ); - markWsSessionDegraded(session); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - preserveDegradeUntil: true, - }); - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { - suppressStart: emittedStart, - turnState: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: turnAttempt, - transport: "stream", - }), - }); - } - throw normalizedErr; - } - - if (!emittedStart) { - eventStream.push({ - type: "start", - partial: buildAssistantMessageWithZeroUsage({ - model, - content: [], - stopReason: "stop", - }), - }); - emittedStart = true; - } - - const outputItemPhaseById = new Map(); - const outputTextByPart = new Map(); - const emittedTextByPart = new Map(); - const getOutputTextKey = (itemId: string, contentIndex: number) => - `${itemId}:${contentIndex}`; - const emitTextDelta = (params: { - fullText: string; - deltaText: string; - itemId?: string; - contentIndex?: number; - }) => { - const resolvedItemId = params.itemId; - const contentIndex = params.contentIndex ?? 0; - const itemPhase = resolvedItemId - ? normalizeAssistantPhase(outputItemPhaseById.get(resolvedItemId)) - : undefined; - const partialBase = buildAssistantMessageWithZeroUsage({ - model, - content: [ - { - type: "text", - text: params.fullText, - ...(resolvedItemId - ? { - textSignature: encodeAssistantTextSignature({ - id: resolvedItemId, - ...(itemPhase ? { phase: itemPhase } : {}), - }), - } - : {}), - }, - ], - stopReason: "stop", - }); - const partialMsg: AssistantMessageWithPhase = itemPhase - ? ({ ...partialBase, phase: itemPhase } as AssistantMessageWithPhase) - : partialBase; - eventStream.push({ - type: "text_delta", - contentIndex, - delta: params.deltaText, - partial: partialMsg, - }); - }; - const emitBufferedTextDelta = (params: { itemId: string; contentIndex: number }) => { - const key = getOutputTextKey(params.itemId, params.contentIndex); - const fullText = outputTextByPart.get(key) ?? ""; - const emittedText = emittedTextByPart.get(key) ?? ""; - if (!fullText || fullText === emittedText) { - return; - } - const deltaText = fullText.startsWith(emittedText) - ? fullText.slice(emittedText.length) - : fullText; - emittedTextByPart.set(key, fullText); - emitTextDelta({ - fullText, - deltaText, - itemId: params.itemId, - contentIndex: params.contentIndex, - }); - }; - const capturedContextLength = context.messages.length; - let sawWsOutput = false; - - try { - await new Promise((resolve, reject) => { - const abortHandler = () => { - outputItemPhaseById.clear(); - outputTextByPart.clear(); - emittedTextByPart.clear(); - cleanup(); - reject(new Error("aborted")); - }; - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - signal?.addEventListener("abort", abortHandler, { once: true }); - - const closeHandler = (code: number, reason: string) => { - outputItemPhaseById.clear(); - outputTextByPart.clear(); - emittedTextByPart.clear(); - cleanup(); - const closeInfo = session.manager.lastCloseInfo; - reject( - new OpenAIWebSocketRuntimeError( - `WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`, - { - kind: "disconnect", - retryable: closeInfo?.retryable ?? true, - closeCode: closeInfo?.code ?? code, - closeReason: closeInfo?.reason ?? reason, - }, - ), - ); - }; - session.manager.on("close", closeHandler); - - const cleanup = () => { - signal?.removeEventListener("abort", abortHandler); - session.manager.off("close", closeHandler); - unsubscribe(); - }; - - const unsubscribe = session.manager.onMessage((event) => { - if ( - event.type === "response.output_item.added" || - event.type === "response.output_item.done" || - event.type === "response.content_part.added" || - event.type === "response.content_part.done" || - event.type === "response.output_text.delta" || - event.type === "response.output_text.done" || - event.type === "response.function_call_arguments.delta" || - event.type === "response.function_call_arguments.done" - ) { - sawWsOutput = true; - } - - if ( - event.type === "response.output_item.added" || - event.type === "response.output_item.done" - ) { - if (typeof event.item.id === "string") { - const itemPhase = - event.item.type === "message" - ? normalizeAssistantPhase((event.item as { phase?: unknown }).phase) - : undefined; - outputItemPhaseById.set(event.item.id, itemPhase); - if (itemPhase !== undefined) { - for (const key of outputTextByPart.keys()) { - if (key.startsWith(`${event.item.id}:`)) { - const [, contentIndexText] = key.split(":"); - emitBufferedTextDelta({ - itemId: event.item.id, - contentIndex: Number.parseInt(contentIndexText ?? "0", 10) || 0, - }); - } - } - } - } - return; - } - - if (event.type === "response.output_text.delta") { - const key = getOutputTextKey(event.item_id, event.content_index); - const nextText = `${outputTextByPart.get(key) ?? ""}${event.delta}`; - outputTextByPart.set(key, nextText); - if (outputItemPhaseById.get(event.item_id) !== undefined) { - emitBufferedTextDelta({ - itemId: event.item_id, - contentIndex: event.content_index, - }); - } - return; - } - - if (event.type === "response.output_text.done") { - const key = getOutputTextKey(event.item_id, event.content_index); - if (event.text && event.text !== outputTextByPart.get(key)) { - outputTextByPart.set(key, event.text); - } - if (outputItemPhaseById.get(event.item_id) !== undefined) { - emitBufferedTextDelta({ - itemId: event.item_id, - contentIndex: event.content_index, - }); - } - return; - } - - if (event.type === "response.completed") { - outputItemPhaseById.clear(); - outputTextByPart.clear(); - emittedTextByPart.clear(); - cleanup(); - session.lastContextLength = capturedContextLength; - session.lastRequestPayload = fullPayload as ResponseCreateEvent; - session.lastResponseInputItems = convertResponseToInputItems(event.response, { - api: model.api, - provider: model.provider, - id: model.id, - input: model.input, - }); - const assistantMsg = buildAssistantMessageFromResponse(event.response, { - api: model.api, - provider: model.provider, - id: model.id, - }); - const reason: Extract = - assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop"; - eventStream.push({ type: "done", reason, message: assistantMsg }); - resolve(); - } else if (event.type === "response.failed") { - outputItemPhaseById.clear(); - outputTextByPart.clear(); - emittedTextByPart.clear(); - cleanup(); - reject( - new OpenAIWebSocketRuntimeError( - `OpenAI WebSocket response failed: ${formatOpenAIWebSocketResponseFailure(event.response)}`, - { - kind: "server", - retryable: false, - }, - ), - ); - } else if (event.type === "error") { - outputItemPhaseById.clear(); - outputTextByPart.clear(); - emittedTextByPart.clear(); - cleanup(); - reject( - new OpenAIWebSocketRuntimeError( - `OpenAI WebSocket error: ${formatOpenAIWebSocketError(event)}`, - { - kind: "server", - retryable: false, - }, - ), - ); - } - }); - }); - return; - } catch (wsRunErr) { - const normalizedErr = normalizeWsRunError(wsRunErr); - if ( - transport !== "websocket" && - !signal?.aborted && - normalizedErr.retryable && - !sawWsOutput && - runtimeRetries < MAX_AUTO_WS_RUNTIME_RETRIES - ) { - runtimeRetries++; - log.warn( - `[ws-stream] retrying websocket turn after retryable runtime failure for session=${sessionId} (${runtimeRetries}/${MAX_AUTO_WS_RUNTIME_RETRIES}). error=${normalizedErr.message}`, - ); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - }); - continue; - } - if (transport !== "websocket" && !signal?.aborted && !sawWsOutput) { - log.warn( - `[ws-stream] session=${sessionId} runtime failure before output; falling back to HTTP. error=${normalizedErr.message}`, - ); - markWsSessionDegraded(session); - resetWsSession({ - session, - createManager: () => createWsManager(opts.managerOptions, sessionHeaders), - preserveDegradeUntil: true, - }); - return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { - suppressStart: true, - turnState: resolveProviderTransportTurnState(model, { - sessionId, - turnId, - attempt: turnAttempt, - transport: "stream", - }), - }); - } - throw normalizedErr; - } - } - }; - - queueMicrotask(() => - run().catch((err) => { - const errorMessage = formatErrorMessage(err); - log.warn(`[ws-stream] session=${sessionId} run error: ${errorMessage}`); - eventStream.push({ - type: "error", - reason: "error", - error: buildStreamErrorAssistantMessage({ - model, - errorMessage, - }), - }); - eventStream.end(); - }), - ); - - return eventStream; - }; -} - -/** - * Fall back to HTTP and pipe events into the existing stream. - * This is called when the WebSocket is broken or unavailable. - */ -async function fallbackToHttp( - model: Parameters[0], - context: Parameters[1], - streamOptions: Parameters[2], - apiKey: string, - eventStream: AssistantMessageEventStreamLike, - signal?: AbortSignal, - fallbackOptions?: { - suppressStart?: boolean; - turnState?: ProviderTransportTurnState; - }, -): Promise { - const baseOnPayload = streamOptions?.onPayload; - const mergedOptions = { - ...streamOptions, - apiKey, - ...(fallbackOptions?.turnState?.headers - ? { - headers: { - ...streamOptions?.headers, - ...fallbackOptions.turnState.headers, - }, - } - : {}), - ...(fallbackOptions?.turnState?.metadata - ? { - onPayload: async ( - payload: unknown, - payloadModel: Parameters>[1], - ) => { - const nextPayload = await baseOnPayload?.(payload, payloadModel); - const resolvedPayload = (nextPayload ?? payload) as Record; - return mergeTransportMetadata(resolvedPayload, fallbackOptions.turnState?.metadata); - }, - } - : {}), - ...(signal ? { signal } : {}), - }; - const httpStreamFn = - openAIWsStreamDeps.createHttpFallbackStreamFn(model as ProviderRuntimeModel) ?? - openAIWsStreamDeps.streamSimple; - const httpStream = await httpStreamFn(model, context, mergedOptions); - for await (const event of httpStream) { - if (fallbackOptions?.suppressStart && event.type === "start") { - continue; - } - eventStream.push(event); - } - eventStream.end(); -} - -export const __testing = { - setDepsForTest(overrides?: Partial) { - openAIWsStreamDeps = overrides - ? { - ...defaultOpenAIWsStreamDeps, - ...overrides, - } - : defaultOpenAIWsStreamDeps; - }, - getDefaultHttpFallbackStreamFnForTest(model: ProviderRuntimeModel): StreamFn | undefined { - return defaultOpenAIWsStreamDeps.createHttpFallbackStreamFn(model); - }, - setWsDegradeCooldownMsForTest(nextMs?: number) { - wsDegradeCooldownMsOverride = nextMs; - }, -}; diff --git a/src/agents/openai-ws-types.ts b/src/agents/openai-ws-types.ts deleted file mode 100644 index 224639c44e0..00000000000 --- a/src/agents/openai-ws-types.ts +++ /dev/null @@ -1,77 +0,0 @@ -export type OpenAIResponsesAssistantPhase = "commentary" | "final_answer"; - -export type ContentPart = - | { type: "input_text"; text: string } - | { type: "output_text"; text: string } - | { - type: "input_image"; - source: { type: "url"; url: string } | { type: "base64"; media_type: string; data: string }; - } - | { - type: "image_url"; - image_url: { url: string }; - }; - -export type InputItem = - | { - type: "message"; - role: "system" | "developer" | "user" | "assistant"; - content: string | ContentPart[]; - phase?: OpenAIResponsesAssistantPhase; - } - | { type: "function_call"; id?: string; call_id?: string; name: string; arguments: string } - | { type: "function_call_output"; call_id: string; output: string } - | { - type: "reasoning"; - id?: string; - content?: unknown; - encrypted_content?: string; - summary?: unknown; - } - | { type: "item_reference"; id: string }; - -export type ToolChoice = - | "auto" - | "none" - | "required" - | { type: "function"; function: { name: string } }; - -export interface FunctionToolDefinition { - type: "function"; - name: string; - description?: string; - parameters?: Record; - strict?: boolean; -} - -export interface ResponseCreateEvent { - type: "response.create"; - model: string; - store?: boolean; - stream?: boolean; - input?: string | InputItem[]; - instructions?: string; - tools?: FunctionToolDefinition[]; - tool_choice?: ToolChoice; - context_management?: unknown; - previous_response_id?: string; - max_output_tokens?: number; - temperature?: number; - top_p?: number; - metadata?: Record; - include?: string[]; - reasoning?: { - effort?: "none" | "low" | "medium" | "high" | "xhigh"; - summary?: "auto" | "concise" | "detailed"; - }; - text?: { verbosity?: "low" | "medium" | "high"; [key: string]: unknown }; - truncation?: "auto" | "disabled"; - [key: string]: unknown; -} - -/** Warm-up payload: generate: false pre-loads connection without generating output */ -export interface WarmUpEvent extends ResponseCreateEvent { - generate: false; -} - -export type ClientEvent = ResponseCreateEvent | WarmUpEvent; diff --git a/src/agents/pi-embedded-runner-extraparams-resolve.test.ts b/src/agents/pi-embedded-runner-extraparams-resolve.test.ts index f33bf6597ac..0849d49017a 100644 --- a/src/agents/pi-embedded-runner-extraparams-resolve.test.ts +++ b/src/agents/pi-embedded-runner-extraparams-resolve.test.ts @@ -22,7 +22,6 @@ describe("resolveExtraParams", () => { expect(result).toEqual({ parallel_tool_calls: true, text_verbosity: "low", - openaiWsWarmup: false, }); }); @@ -189,7 +188,6 @@ describe("resolveExtraParams", () => { }); expect(result).toEqual({ - openaiWsWarmup: false, parallel_tool_calls: true, text_verbosity: "low", }); diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 039ffc5389e..354c39ec7c1 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -455,9 +455,9 @@ afterEach(() => { describe("applyExtraParamsToAgent", () => { function createOptionsCaptureAgent() { - const calls: Array<(SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined> = []; + const calls: Array = []; const baseStreamFn: StreamFn = (_model, _context, options) => { - calls.push(options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined); + calls.push(options); return {} as ReturnType; }; return { @@ -1882,7 +1882,7 @@ describe("applyExtraParamsToAgent", () => { expect(calls[0]?.transport).toBe("auto"); }); - it("defaults OpenAI transport to auto without websocket warm-up", () => { + it("defaults OpenAI transport to auto", () => { const { calls, agent } = createOptionsCaptureAgent(); applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5"); @@ -1897,7 +1897,6 @@ describe("applyExtraParamsToAgent", () => { expect(calls).toHaveLength(1); expect(calls[0]?.transport).toBe("auto"); - expect(calls[0]?.openaiWsWarmup).toBe(false); }); it("injects GPT-5 default parallel tool calls and low verbosity for OpenAI Responses payloads", () => { @@ -2049,68 +2048,6 @@ describe("applyExtraParamsToAgent", () => { expect(calls[0]?.transport).toBe("sse"); }); - it("allows disabling OpenAI websocket warm-up via model params", () => { - const { calls, agent } = createOptionsCaptureAgent(); - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-5": { - params: { - openaiWsWarmup: false, - }, - }, - }, - }, - }, - }; - - applyExtraParamsToAgent(agent, cfg, "openai", "gpt-5"); - - const model = { - api: "openai-responses", - provider: "openai", - id: "gpt-5", - } as Model<"openai-responses">; - const context: Context = { messages: [] }; - void agent.streamFn?.(model, context, {}); - - expect(calls).toHaveLength(1); - expect(calls[0]?.openaiWsWarmup).toBe(false); - }); - - it("lets runtime options override configured OpenAI websocket warm-up", () => { - const { calls, agent } = createOptionsCaptureAgent(); - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-5": { - params: { - openaiWsWarmup: false, - }, - }, - }, - }, - }, - }; - - applyExtraParamsToAgent(agent, cfg, "openai", "gpt-5"); - - const model = { - api: "openai-responses", - provider: "openai", - id: "gpt-5", - } as Model<"openai-responses">; - const context: Context = { messages: [] }; - void agent.streamFn?.(model, context, { - openaiWsWarmup: true, - } as unknown as SimpleStreamOptions); - - expect(calls).toHaveLength(1); - expect(calls[0]?.openaiWsWarmup).toBe(true); - }); - it("allows forcing Codex transport to SSE", () => { const { calls, agent } = createOptionsCaptureAgent(); const cfg = { diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 2bfca6940ea..ff6691813e0 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -249,7 +249,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { compactTesting.prepareCompactionSessionAgent({ session: session as never, providerStreamFn: vi.fn(), - shouldUseWebSocketTransport: false, sessionId: "session-1", signal: new AbortController().signal, effectiveModel: { provider: "openai", id: "fake", api: "responses", input: [] } as never, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 6c3e06e1fec..174bc39071a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -142,12 +142,10 @@ import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-disco import { readPiModelContextTokens } from "./model-context-tokens.js"; import { buildModelAliasLines, resolveModelAsync } from "./model.js"; import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js"; -import { shouldUseOpenAIWebSocketTransport } from "./run/attempt.thread-helpers.js"; import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js"; import { - resolveEmbeddedAgentApiKey, resolveEmbeddedAgentBaseStreamFn, resolveEmbeddedAgentStreamFn, } from "./stream-resolution.js"; @@ -183,8 +181,6 @@ function createCompactionDiagId(): string { function prepareCompactionSessionAgent(params: { session: { agent: { streamFn?: unknown } }; providerStreamFn: unknown; - shouldUseWebSocketTransport: boolean; - wsApiKey?: string; sessionId: string; signal: AbortSignal; effectiveModel: ProviderRuntimeModel; @@ -202,8 +198,6 @@ function prepareCompactionSessionAgent(params: { params.session.agent.streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: resolveEmbeddedAgentBaseStreamFn({ session: params.session as never }), providerStreamFn: params.providerStreamFn as never, - shouldUseWebSocketTransport: params.shouldUseWebSocketTransport, - wsApiKey: params.wsApiKey, sessionId: params.sessionId, signal: params.signal, model: params.effectiveModel, @@ -1047,23 +1041,6 @@ async function compactEmbeddedPiSessionDirectOnce( agentDir, effectiveWorkspace, }); - const shouldUseWebSocketTransport = shouldUseOpenAIWebSocketTransport({ - provider, - modelApi: effectiveModel.api, - modelBaseUrl: effectiveModel.baseUrl, - }); - const wsApiKey = shouldUseWebSocketTransport - ? await resolveEmbeddedAgentApiKey({ - provider, - resolvedApiKey: hasRuntimeAuthExchange ? undefined : apiKeyInfo?.apiKey, - authStorage, - }) - : undefined; - if (shouldUseWebSocketTransport && !wsApiKey) { - log.warn( - `[ws-stream] no API key for provider=${provider}; keeping compaction HTTP transport`, - ); - } while (true) { // Rebuild the compaction session on retry so provider wrappers, payload // shaping, and the embedded system prompt all reflect the fallback level. @@ -1091,8 +1068,6 @@ async function compactEmbeddedPiSessionDirectOnce( prepareCompactionSessionAgent({ session, providerStreamFn, - shouldUseWebSocketTransport, - wsApiKey, sessionId: params.sessionId, signal: runAbortController.signal, effectiveModel, diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 07fe1faf575..9d0691367eb 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -130,7 +130,6 @@ export function resolveExtraParams(params: { type CacheRetentionStreamOptions = Partial & { cacheRetention?: "none" | "short" | "long"; cachedContent?: string; - openaiWsWarmup?: boolean; }; export type SupportedTransport = "sse" | "websocket" | "auto"; @@ -334,9 +333,6 @@ function applyDefaultOpenAIGptRuntimeParams( if (!Object.hasOwn(merged, "text_verbosity") && !Object.hasOwn(merged, "textVerbosity")) { merged.text_verbosity = "low"; } - if (!Object.hasOwn(merged, "openaiWsWarmup")) { - merged.openaiWsWarmup = false; - } } export function resolveAgentTransportOverride(params: { @@ -393,9 +389,6 @@ function createStreamFnWithExtraParams( : typeof extraParams.transport; log.warn(`ignoring invalid transport param: ${transportSummary}`); } - if (typeof extraParams.openaiWsWarmup === "boolean") { - streamParams.openaiWsWarmup = extraParams.openaiWsWarmup; - } const cachedContent = typeof extraParams.cachedContent === "string" ? extraParams.cachedContent @@ -836,8 +829,8 @@ export function applyExtraParamsToAgent( }, }); agent.streamFn = pluginWrappedStreamFn ?? providerStreamBase; - // Apply caller/config extra params outside provider defaults so explicit values - // like `openaiWsWarmup=false` can override provider-added defaults. + // Apply caller/config extra params outside provider defaults so explicit runtime + // transport values can override provider-added defaults. applyPrePluginStreamWrappers(wrapperContext); const providerWrapperHandled = pluginWrappedStreamFn !== undefined && pluginWrappedStreamFn !== providerStreamBase; diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index f9a75f89e6a..c7481ed623c 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -506,13 +506,9 @@ export function createCodexNativeWebSearchWrapper( export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { - const typedOptions = options as - | (SimpleStreamOptions & { openaiWsWarmup?: boolean }) - | undefined; const mergedOptions = { ...options, transport: options?.transport ?? "auto", - openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true, } as SimpleStreamOptions; return underlying(model, context, mergedOptions); }; diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index 46dd4e9ba3f..f0ceb9e7d5b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -1233,8 +1233,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { flushPendingToolResultsAfterIdle: flushMock, session: { agent: {}, dispose: disposeMock }, sessionManager: hoisted.sessionManager, - releaseWsSession: hoisted.releaseWsSessionMock, - sessionId: embeddedSessionId, bundleLspRuntime: undefined, sessionLock: { release: releaseMock }, }); @@ -1242,9 +1240,6 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => { expect(flushMock).toHaveBeenCalledTimes(1); expect(disposeMock).toHaveBeenCalledTimes(1); expect(releaseMock).toHaveBeenCalledTimes(1); - expect(hoisted.releaseWsSessionMock).toHaveBeenCalledWith("embedded-session", { - allowPool: false, - }); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 3d678109b63..af7d2d944bb 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -70,7 +70,6 @@ type AttemptSpawnWorkspaceHoisted = { installToolResultContextGuardMock: UnknownMock; installContextEngineLoopHookMock: UnknownMock; flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; - releaseWsSessionMock: UnknownMock; resolveBootstrapFilesForRunMock: Mock<(...args: unknown[]) => Promise>; resolveBootstrapContextForRunMock: Mock<() => Promise>; isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; @@ -134,7 +133,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const installToolResultContextGuardMock = vi.fn(() => () => {}); const installContextEngineLoopHookMock = vi.fn(() => () => {}); const flushPendingToolResultsAfterIdleMock = vi.fn(async () => {}); - const releaseWsSessionMock = vi.fn(() => {}); const subscribeEmbeddedPiSessionMock = vi.fn(() => createSubscriptionMock(), ); @@ -205,7 +203,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { installToolResultContextGuardMock, installContextEngineLoopHookMock, flushPendingToolResultsAfterIdleMock, - releaseWsSessionMock, resolveBootstrapFilesForRunMock, resolveBootstrapContextForRunMock, isWorkspaceBootstrapPendingMock, @@ -507,12 +504,6 @@ vi.mock("../extra-params.js", async () => { }; }); -vi.mock("../../openai-ws-stream.js", () => ({ - createOpenAIWebSocketStreamFn: vi.fn(), - releaseWsSession: (...args: unknown[]) => - (hoisted.releaseWsSessionMock as (...args: unknown[]) => unknown)(...args), -})); - vi.mock("../../anthropic-payload-log.js", () => ({ createAnthropicPayloadLogger: () => undefined, })); @@ -885,7 +876,6 @@ export function resetEmbeddedAttemptHarness( hoisted.installToolResultContextGuardMock.mockReset().mockReturnValue(() => {}); hoisted.installContextEngineLoopHookMock.mockReset().mockReturnValue(() => {}); hoisted.flushPendingToolResultsAfterIdleMock.mockReset().mockResolvedValue(undefined); - hoisted.releaseWsSessionMock.mockReset().mockReturnValue(undefined); hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({ bootstrapFiles: [], contextFiles: [], diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.websocket.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.websocket.test.ts deleted file mode 100644 index 0b73dc6b4b6..00000000000 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.websocket.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - shouldUseOpenAIWebSocketTransport, - shouldUseOpenAIWebSocketTransportForAttempt, -} from "./attempt.thread-helpers.js"; - -describe("openai websocket transport selection", () => { - it("accepts direct OpenAI Responses endpoints", () => { - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: undefined, - }), - ).toBe(true); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "https://api.openai.com/v1", - }), - ).toBe(true); - }); - - it("rejects non-public baseUrls even when the provider/api pair matches", () => { - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "http://127.0.0.1:4100/v1", - }), - ).toBe(false); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "https://example.com/v1", - }), - ).toBe(false); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "https://chatgpt.com/backend-api", - }), - ).toBe(false); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "https://example.openai.azure.com/openai/v1", - }), - ).toBe(false); - }); - - it("rejects mismatched OpenAI websocket transport pairs", () => { - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai", - modelApi: "openai-codex-responses", - }), - ).toBe(false); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai-codex", - modelApi: "openai-responses", - }), - ).toBe(false); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "openai-codex", - modelApi: "openai-codex-responses", - }), - ).toBe(false); - expect( - shouldUseOpenAIWebSocketTransport({ - provider: "anthropic", - modelApi: "openai-responses", - }), - ).toBe(false); - }); - - it("honors prepared SSE transport params before selecting websocket", () => { - expect( - shouldUseOpenAIWebSocketTransportForAttempt({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "https://api.openai.com/v1", - effectiveExtraParams: { transport: "sse" }, - }), - ).toBe(false); - - expect( - shouldUseOpenAIWebSocketTransportForAttempt({ - provider: "openai", - modelApi: "openai-responses", - modelBaseUrl: "https://api.openai.com/v1", - effectiveExtraParams: { transport: "auto" }, - }), - ).toBe(true); - }); -}); diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts index c23574d382b..ddbf96bef95 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts @@ -24,9 +24,6 @@ export async function cleanupEmbeddedAttemptResources(params: { }) => Promise; session?: { agent?: unknown; dispose(): void }; sessionManager: unknown; - releaseWsSession: (sessionId: string, options?: { allowPool?: boolean }) => void; - allowWsSessionPool?: boolean; - sessionId: string; bundleMcpRuntime?: { dispose(): Promise | void }; bundleLspRuntime?: { dispose(): Promise | void }; sessionLock: { release(): Promise | void }; @@ -56,11 +53,6 @@ export async function cleanupEmbeddedAttemptResources(params: { } catch { /* best-effort */ } - try { - params.releaseWsSession(params.sessionId, { allowPool: params.allowWsSessionPool === true }); - } catch { - /* best-effort */ - } try { await params.bundleMcpRuntime?.dispose(); } catch { diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index ecb0fe9e4ea..641ba1428a7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -679,7 +679,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, providerStreamFn, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-completions", @@ -704,7 +703,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, providerStreamFn, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-completions", @@ -729,7 +727,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { it("routes supported default streamSimple fallbacks through boundary-aware transports", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-responses", @@ -745,7 +742,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const currentStreamFn = vi.fn(); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: currentStreamFn as never, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-responses", @@ -761,7 +757,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const currentStreamFn = vi.fn(); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: currentStreamFn as never, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "anthropic-messages", diff --git a/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts index 314e39f6b82..ef87fa03fc3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.thread-helpers.ts @@ -1,7 +1,6 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { normalizeStructuredPromptSection } from "../../prompt-cache-stability.js"; -import { resolveProviderEndpoint } from "../../provider-attribution.js"; export const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl"; @@ -38,46 +37,6 @@ export function resolveAttemptSpawnWorkspaceDir(params: { : undefined; } -export function shouldUseOpenAIWebSocketTransport(params: { - provider: string; - modelApi?: string | null; - modelBaseUrl?: string | null; -}): boolean { - if (params.modelApi !== "openai-responses" || params.provider !== "openai") { - return false; - } - - // openai-codex normalizes to the ChatGPT backend HTTP path, not the public - // OpenAI Responses websocket endpoint. Local mocks, proxies, and custom - // baseUrls must stay on HTTP because the websocket runtime targets the - // native api.openai.com endpoint directly. - const endpointClass = resolveProviderEndpoint(params.modelBaseUrl).endpointClass; - return endpointClass === "default" || endpointClass === "openai-public"; -} - -function hasExplicitSseTransport(sources: Array | undefined>): boolean { - return sources.some((source) => { - const transport = typeof source?.transport === "string" ? source.transport : ""; - return transport.trim().toLowerCase() === "sse"; - }); -} - -export function shouldUseOpenAIWebSocketTransportForAttempt(params: { - provider: string; - modelApi?: string | null; - modelBaseUrl?: string | null; - streamParams?: Record; - effectiveExtraParams?: Record; - modelParams?: Record; -}): boolean { - if ( - hasExplicitSseTransport([params.streamParams, params.effectiveExtraParams, params.modelParams]) - ) { - return false; - } - return shouldUseOpenAIWebSocketTransport(params); -} - function shouldAppendAttemptCacheTtl(params: { timedOutDuringCompaction: boolean; compactionOccurredThisAttempt: boolean; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d0356a4a662..9bda02156b3 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -94,7 +94,6 @@ import { buildModelAliasLines } from "../../model-alias-lines.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; import { supportsModelTools } from "../../model-tool-support.js"; -import { releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; import { @@ -305,7 +304,6 @@ import { composeSystemPromptWithHookContext, resolveAttemptSpawnWorkspaceDir, shouldPersistCompletedBootstrapTurn, - shouldUseOpenAIWebSocketTransportForAttempt, } from "./attempt.thread-helpers.js"; import { shouldRepairMalformedToolCallArguments, @@ -1908,39 +1906,15 @@ export async function runEmbeddedAttempt( agentDir, workspaceDir: effectiveWorkspace, }); - const shouldUseWebSocketTransport = shouldUseOpenAIWebSocketTransportForAttempt({ - provider: params.provider, - modelApi: params.model.api, - modelBaseUrl: params.model.baseUrl, - streamParams: params.streamParams, - effectiveExtraParams, - modelParams: (params.model as { params?: Record }).params, - }); - const wsApiKey = shouldUseWebSocketTransport - ? await resolveEmbeddedAgentApiKey({ - provider: params.provider, - resolvedApiKey: params.resolvedApiKey, - authStorage: params.authStorage, - }) - : undefined; - if (shouldUseWebSocketTransport && !wsApiKey) { - log.warn( - `[ws-stream] no API key for provider=${params.provider}; keeping session-managed HTTP transport`, - ); - } const streamStrategy = describeEmbeddedAgentStreamStrategy({ currentStreamFn: defaultSessionStreamFn, providerStreamFn, - shouldUseWebSocketTransport, - wsApiKey, model: params.model, resolvedApiKey: params.resolvedApiKey, }); activeSession.agent.streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: defaultSessionStreamFn, providerStreamFn, - shouldUseWebSocketTransport, - wsApiKey, sessionId: params.sessionId, signal: runAbortController.signal, model: params.model, @@ -3857,10 +3831,6 @@ export async function runEmbeddedAttempt( flushPendingToolResultsAfterIdle, session, sessionManager, - releaseWsSession, - allowWsSessionPool: - !promptError && !aborted && !timedOut && !idleTimedOut && !timedOutDuringCompaction, - sessionId: params.sessionId, bundleMcpRuntime, bundleLspRuntime, sessionLock, diff --git a/src/agents/pi-embedded-runner/stream-resolution.test.ts b/src/agents/pi-embedded-runner/stream-resolution.test.ts index 8625bd90619..5d77d9fbfcd 100644 --- a/src/agents/pi-embedded-runner/stream-resolution.test.ts +++ b/src/agents/pi-embedded-runner/stream-resolution.test.ts @@ -1,8 +1,10 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { getApiProvider, streamSimple } from "@mariozechner/pi-ai"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as providerTransportStream from "../provider-transport-stream.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js"; import { + __testing, describeEmbeddedAgentStreamStrategy, resolveEmbeddedAgentApiKey, resolveEmbeddedAgentStreamFn, @@ -27,13 +29,16 @@ const overrideBoundaryAwareStreamFnOnce = (streamFn: StreamFn): void => { ); }; +afterEach(() => { + __testing.resetPiNativeCodexResponsesStreamFnForTest(); +}); + describe("describeEmbeddedAgentStreamStrategy", () => { it("describes provider-owned stream paths explicitly", () => { expect( describeEmbeddedAgentStreamStrategy({ currentStreamFn: undefined, providerStreamFn: vi.fn() as never, - shouldUseWebSocketTransport: false, model: { api: "openai-completions", provider: "ollama", @@ -47,7 +52,6 @@ describe("describeEmbeddedAgentStreamStrategy", () => { expect( describeEmbeddedAgentStreamStrategy({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, model: { api: "openai-responses", provider: "openai", @@ -57,25 +61,23 @@ describe("describeEmbeddedAgentStreamStrategy", () => { ).toBe("boundary-aware:openai-responses"); }); - it("describes default Codex fallback shaping", () => { + it("describes default Codex fallback as PI native", () => { expect( describeEmbeddedAgentStreamStrategy({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, model: { api: "openai-codex-responses", provider: "openai-codex", id: "codex-mini-latest", } as never, }), - ).toBe("boundary-aware:openai-codex-responses"); + ).toBe("pi-native-codex-responses"); }); it("keeps custom session streams labeled as custom", () => { expect( describeEmbeddedAgentStreamStrategy({ currentStreamFn: vi.fn() as never, - shouldUseWebSocketTransport: false, model: { api: "openai-responses", provider: "openai", @@ -89,7 +91,6 @@ describe("describeEmbeddedAgentStreamStrategy", () => { expect( describeEmbeddedAgentStreamStrategy({ currentStreamFn: vi.fn() as never, - shouldUseWebSocketTransport: false, model: { api: "anthropic-messages", provider: "cloudflare-ai-gateway", @@ -120,7 +121,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { it("still routes supported streamSimple fallbacks through boundary-aware transports", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-responses", @@ -132,25 +132,37 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(streamFn).not.toBe(streamSimple); }); - it("routes Codex responses fallbacks through boundary-aware transports", () => { + it("routes Codex responses fallbacks through PI native transport", async () => { + const nativeStreamFn = vi.fn(async (_model, context, options) => ({ context, options })); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-codex-responses", provider: "openai-codex", id: "codex-mini-latest", } as never, + resolvedApiKey: "oauth-bearer-token", }); expect(streamFn).not.toBe(streamSimple); + await expect( + streamFn( + { provider: "openai-codex", id: "codex-mini-latest" } as never, + { systemPrompt: `intro${SYSTEM_PROMPT_CACHE_BOUNDARY}tail` } as never, + {}, + ), + ).resolves.toMatchObject({ + context: { systemPrompt: "intro\ntail" }, + options: { apiKey: "oauth-bearer-token" }, + }); + expect(nativeStreamFn).toHaveBeenCalledTimes(1); }); it("routes GitHub Copilot fallbacks through boundary-aware transports", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-responses", @@ -172,7 +184,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: nativeStreamFn, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-completions", @@ -196,7 +207,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: currentStreamFn as never, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "anthropic-messages", @@ -226,7 +236,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, providerStreamFn, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-completions", @@ -252,7 +261,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, providerStreamFn, - shouldUseWebSocketTransport: false, sessionId: "session-1", signal, model: { @@ -277,7 +285,6 @@ describe("resolveEmbeddedAgentStreamFn", () => { const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, providerStreamFn, - shouldUseWebSocketTransport: false, sessionId: "session-1", signal: runSignal, model: { @@ -296,12 +303,11 @@ describe("resolveEmbeddedAgentStreamFn", () => { }); }); - it("injects the resolved run api key into the boundary-aware Codex Responses fallback", async () => { - const innerStreamFn = vi.fn(async (_model, _context, options) => options); - overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + it("injects the resolved run api key into the PI native Codex Responses fallback", async () => { + const nativeStreamFn = vi.fn(async (_model, _context, options) => options); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-codex-responses", @@ -314,18 +320,17 @@ describe("resolveEmbeddedAgentStreamFn", () => { await expect( streamFn({ provider: "openai-codex", id: "gpt-5.5" } as never, {} as never, {}), ).resolves.toMatchObject({ apiKey: "oauth-bearer-token" }); - expect(innerStreamFn).toHaveBeenCalledTimes(1); + expect(nativeStreamFn).toHaveBeenCalledTimes(1); }); - it("falls back to authStorage when no resolved api key is available for boundary-aware fallback", async () => { - const innerStreamFn = vi.fn(async (_model, _context, options) => options); + it("falls back to authStorage when no resolved api key is available for PI native fallback", async () => { + const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const authStorage = { getApiKey: vi.fn(async () => "stored-bearer-token"), }; - overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-codex-responses", @@ -341,13 +346,12 @@ describe("resolveEmbeddedAgentStreamFn", () => { expect(authStorage.getApiKey).toHaveBeenCalledWith("openai-codex"); }); - it("forwards the run abort signal into the boundary-aware fallback when callers omit one", async () => { - const innerStreamFn = vi.fn(async (_model, _context, options) => options); + it("forwards the run abort signal into the PI native fallback when callers omit one", async () => { + const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const runSignal = new AbortController().signal; - overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", signal: runSignal, model: { @@ -363,14 +367,13 @@ describe("resolveEmbeddedAgentStreamFn", () => { ).resolves.toMatchObject({ signal: runSignal, apiKey: "oauth-bearer-token" }); }); - it("does not overwrite an explicit signal on the boundary-aware fallback path", async () => { - const innerStreamFn = vi.fn(async (_model, _context, options) => options); + it("does not overwrite an explicit signal on the PI native fallback path", async () => { + const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const runSignal = new AbortController().signal; const explicitSignal = new AbortController().signal; - overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", signal: runSignal, model: { @@ -388,13 +391,12 @@ describe("resolveEmbeddedAgentStreamFn", () => { ).resolves.toMatchObject({ signal: explicitSignal }); }); - it("forwards the run signal on the sync boundary-aware fallback path without auth credentials", async () => { - const innerStreamFn = vi.fn(async (_model, _context, options) => options); + it("forwards the run signal on the sync PI native fallback path without auth credentials", async () => { + const nativeStreamFn = vi.fn(async (_model, _context, options) => options); const runSignal = new AbortController().signal; - overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", signal: runSignal, model: { @@ -409,12 +411,11 @@ describe("resolveEmbeddedAgentStreamFn", () => { ).resolves.toMatchObject({ signal: runSignal }); }); - it("does not strip cache boundary markers on the boundary-aware fallback path", async () => { - const innerStreamFn = vi.fn(async (_model, context, _options) => context); - overrideBoundaryAwareStreamFnOnce(innerStreamFn as never); + it("strips cache boundary markers on the PI native fallback path", async () => { + const nativeStreamFn = vi.fn(async (_model, context, _options) => context); + __testing.setPiNativeCodexResponsesStreamFnForTest(nativeStreamFn as never); const streamFn = resolveEmbeddedAgentStreamFn({ currentStreamFn: undefined, - shouldUseWebSocketTransport: false, sessionId: "session-1", model: { api: "openai-codex-responses", @@ -424,9 +425,9 @@ describe("resolveEmbeddedAgentStreamFn", () => { resolvedApiKey: "oauth-bearer-token", }); - const systemPrompt = "intro<>tail"; + const systemPrompt = `intro${SYSTEM_PROMPT_CACHE_BOUNDARY}tail`; await expect( streamFn({ provider: "openai-codex", id: "gpt-5.5" } as never, { systemPrompt } as never, {}), - ).resolves.toMatchObject({ systemPrompt }); + ).resolves.toMatchObject({ systemPrompt: "intro\ntail" }); }); }); diff --git a/src/agents/pi-embedded-runner/stream-resolution.ts b/src/agents/pi-embedded-runner/stream-resolution.ts index 6c9ad15874c..56aa2df2991 100644 --- a/src/agents/pi-embedded-runner/stream-resolution.ts +++ b/src/agents/pi-embedded-runner/stream-resolution.ts @@ -1,13 +1,12 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { getApiProvider, streamSimple } from "@mariozechner/pi-ai"; import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js"; -import { createOpenAIWebSocketStreamFn } from "../openai-ws-stream.js"; -import { getModelProviderRequestTransport } from "../provider-request-config.js"; import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js"; import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import type { EmbeddedRunAttemptParams } from "./run/types.js"; let embeddedAgentBaseStreamFnCache = new WeakMap(); +let piNativeCodexResponsesStreamFnForTest: StreamFn | undefined; export function resolveEmbeddedAgentBaseStreamFn(params: { session: { agent: { streamFn?: StreamFn } }; @@ -44,23 +43,43 @@ function hasResolvedRuntimeApiKey(apiKey: string | undefined): boolean { return typeof apiKey === "string" && apiKey.trim().length > 0; } +function isOpenAICodexResponsesModel(model: EmbeddedRunAttemptParams["model"]): boolean { + return model.provider === "openai-codex" && model.api === "openai-codex-responses"; +} + +function resolvePiNativeCodexResponsesStreamFn(params: { + model: EmbeddedRunAttemptParams["model"]; + currentStreamFn: StreamFn | undefined; +}): StreamFn | undefined { + if (!isOpenAICodexResponsesModel(params.model)) { + return undefined; + } + if (!isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) { + return undefined; + } + return piNativeCodexResponsesStreamFnForTest ?? params.currentStreamFn ?? streamSimple; +} + export function describeEmbeddedAgentStreamStrategy(params: { currentStreamFn: StreamFn | undefined; providerStreamFn?: StreamFn; - shouldUseWebSocketTransport: boolean; - wsApiKey?: string; model: EmbeddedRunAttemptParams["model"]; resolvedApiKey?: string; }): string { if (params.providerStreamFn) { return "provider"; } - if (params.shouldUseWebSocketTransport) { - return params.wsApiKey ? "openai-websocket" : "session-http-fallback"; - } if (params.model.provider === "anthropic-vertex") { return "anthropic-vertex"; } + if ( + resolvePiNativeCodexResponsesStreamFn({ + model: params.model, + currentStreamFn: params.currentStreamFn, + }) + ) { + return "pi-native-codex-responses"; + } if (isDefaultPiStreamFnForModel(params.model, params.currentStreamFn)) { return createBoundaryAwareStreamFnForModel(params.model) ? `boundary-aware:${params.model.api}` @@ -90,8 +109,6 @@ export async function resolveEmbeddedAgentApiKey(params: { export function resolveEmbeddedAgentStreamFn(params: { currentStreamFn: StreamFn | undefined; providerStreamFn?: StreamFn; - shouldUseWebSocketTransport: boolean; - wsApiKey?: string; sessionId: string; signal?: AbortSignal; model: EmbeddedRunAttemptParams["model"]; @@ -115,21 +132,31 @@ export function resolveEmbeddedAgentStreamFn(params: { } const currentStreamFn = params.currentStreamFn ?? streamSimple; - if (params.shouldUseWebSocketTransport) { - return params.wsApiKey - ? createOpenAIWebSocketStreamFn(params.wsApiKey, params.sessionId, { - signal: params.signal, - managerOptions: { - request: getModelProviderRequestTransport(params.model), - }, - }) - : currentStreamFn; - } - if (params.model.provider === "anthropic-vertex") { return createAnthropicVertexStreamFnForModel(params.model); } + const piNativeCodexResponsesStreamFn = resolvePiNativeCodexResponsesStreamFn({ + model: params.model, + currentStreamFn: params.currentStreamFn, + }); + if (piNativeCodexResponsesStreamFn) { + return wrapEmbeddedAgentStreamFn(piNativeCodexResponsesStreamFn, { + runSignal: params.signal, + resolvedApiKey: params.resolvedApiKey, + authStorage: params.authStorage, + providerId: params.model.provider, + sessionId: params.sessionId, + transformContext: (context) => + context.systemPrompt + ? { + ...context, + systemPrompt: stripSystemPromptCacheBoundary(context.systemPrompt), + } + : context, + }); + } + if ( isDefaultPiStreamFnForModel(params.model, params.currentStreamFn) || hasResolvedRuntimeApiKey(params.resolvedApiKey) @@ -157,6 +184,15 @@ export function resolveEmbeddedAgentStreamFn(params: { return currentStreamFn; } +export const __testing = { + setPiNativeCodexResponsesStreamFnForTest(streamFn: StreamFn | undefined): void { + piNativeCodexResponsesStreamFnForTest = streamFn; + }, + resetPiNativeCodexResponsesStreamFnForTest(): void { + piNativeCodexResponsesStreamFnForTest = undefined; + }, +}; + function wrapEmbeddedAgentStreamFn( inner: StreamFn, params: { @@ -164,6 +200,7 @@ function wrapEmbeddedAgentStreamFn( resolvedApiKey: string | undefined; authStorage: { getApiKey(provider: string): Promise } | undefined; providerId: string; + sessionId?: string; transformContext?: (context: Parameters[1]) => Parameters[1]; }, ): StreamFn { @@ -171,7 +208,11 @@ function wrapEmbeddedAgentStreamFn( params.transformContext ?? ((context: Parameters[1]) => context); const mergeRunSignal = (options: Parameters[2]) => { const signal = options?.signal ?? params.runSignal; - return signal ? { ...options, signal } : options; + const merged = + params.sessionId && !options?.sessionId + ? { ...options, sessionId: params.sessionId } + : options; + return signal ? { ...merged, signal } : merged; }; if (!params.authStorage && !params.resolvedApiKey) { return (m, context, options) => inner(m, transformContext(context), mergeRunSignal(options)); diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 0a4b8eee15b..8b26ef09ec0 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -68,7 +68,6 @@ describe("AgentRuntimePlan", () => { expect(plan.transport.extraParams).toMatchObject({ parallel_tool_calls: true, text_verbosity: "low", - openaiWsWarmup: false, }); expect(prepareProviderExtraParamsMock).toHaveBeenCalledTimes(1); void plan.transport.extraParams; @@ -108,7 +107,6 @@ describe("AgentRuntimePlan", () => { expect(plan.transport.extraParams).toMatchObject({ parallel_tool_calls: true, text_verbosity: "low", - openaiWsWarmup: false, }); expect( plan.transport.resolveExtraParams({ @@ -118,7 +116,6 @@ describe("AgentRuntimePlan", () => { ).toMatchObject({ parallel_tool_calls: false, text_verbosity: "low", - openaiWsWarmup: false, }); expect( plan.prompt.resolveSystemPromptContribution({ diff --git a/src/agents/schema-normalization-runtime-contract.test.ts b/src/agents/schema-normalization-runtime-contract.test.ts index bb16bf715ea..34d8f366e25 100644 --- a/src/agents/schema-normalization-runtime-contract.test.ts +++ b/src/agents/schema-normalization-runtime-contract.test.ts @@ -9,40 +9,34 @@ import { import { describe, expect, it } from "vitest"; import { buildProviderToolCompatFamilyHooks } from "../plugin-sdk/provider-tools.js"; import { buildOpenAIResponsesParams } from "./openai-transport-stream.js"; -import { convertTools as convertWebSocketTools } from "./openai-ws-message-conversion.js"; import { createOpenAIResponsesContextManagementWrapper } from "./pi-embedded-runner/openai-stream-wrappers.js"; describe("OpenAI transport schema normalization runtime contract", () => { - it("keeps HTTP Responses and WebSocket strict decisions aligned for the same tool set", () => { + it("keeps HTTP Responses strict decisions stable for the same tool set", () => { const tools = [createStrictCompatibleTool(), createPermissiveTool()] as never; const httpParams = buildOpenAIResponsesParams( createNativeOpenAIResponsesModel() as never, { systemPrompt: "system", messages: [], tools } as never, undefined, ) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> }; - const wsTools = convertWebSocketTools(tools, { strict: true }); expect(httpParams.tools?.map((tool) => tool.strict)).toEqual([false, false]); - expect(wsTools.map((tool) => tool.strict)).toEqual([false, false]); }); - it("normalizes parameter-free tool schemas to the same strict-compatible object shape for HTTP Responses and WebSocket", () => { + it("normalizes parameter-free tool schemas to the strict-compatible HTTP Responses shape", () => { const tools = [createParameterFreeTool()] as never; const httpParams = buildOpenAIResponsesParams( createNativeOpenAIResponsesModel() as never, { systemPrompt: "system", messages: [], tools } as never, undefined, ) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> }; - const wsTools = convertWebSocketTools(tools, { strict: true }); const normalizedSchema = normalizedParameterFreeSchema(); expect(httpParams.tools?.[0]?.strict).toBe(true); - expect(wsTools[0]?.strict).toBe(true); expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema); - expect(wsTools[0]?.parameters).toEqual(normalizedSchema); }); - it("keeps provider-prepared parameter-free schemas strict-compatible across HTTP Responses and WebSocket", () => { + it("keeps provider-prepared parameter-free schemas strict-compatible for HTTP Responses", () => { const hooks = buildProviderToolCompatFamilyHooks("openai"); const tools = hooks.normalizeToolSchemas({ provider: "openai", @@ -55,13 +49,10 @@ describe("OpenAI transport schema normalization runtime contract", () => { { systemPrompt: "system", messages: [], tools } as never, undefined, ) as { tools?: Array<{ strict?: boolean; parameters?: unknown }> }; - const wsTools = convertWebSocketTools(tools, { strict: true }); const normalizedSchema = normalizedParameterFreeSchema(); expect(httpParams.tools?.[0]?.strict).toBe(true); - expect(wsTools[0]?.strict).toBe(true); expect(httpParams.tools?.[0]?.parameters).toEqual(normalizedSchema); - expect(wsTools[0]?.parameters).toEqual(normalizedSchema); }); it("passes prepared executable schemas through compaction-triggered Responses requests", () => { diff --git a/src/agents/transport-params-runtime-contract.test.ts b/src/agents/transport-params-runtime-contract.test.ts index 5ad50ca9638..3fe221fa6e2 100644 --- a/src/agents/transport-params-runtime-contract.test.ts +++ b/src/agents/transport-params-runtime-contract.test.ts @@ -1,5 +1,5 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; +import type { Context, Model } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GPT_PARALLEL_TOOL_CALLS_PAYLOAD_APIS, @@ -55,7 +55,6 @@ describe("transport params runtime contract (Pi/OpenAI path)", () => { parallelToolCalls: false, textVerbosity: "medium", cached_content: "conversation-cache", - openaiWsWarmup: true, }, }, }, @@ -67,7 +66,6 @@ describe("transport params runtime contract (Pi/OpenAI path)", () => { parallel_tool_calls: false, text_verbosity: "medium", cachedContent: "conversation-cache", - openaiWsWarmup: true, }); }); @@ -99,27 +97,6 @@ describe("transport params runtime contract (Pi/OpenAI path)", () => { expect(payload.parallel_tool_calls).toBe(true); }); - it("propagates OpenAI GPT-5 warmup default through stream options", () => { - const { agent, calls } = createOptionsCaptureAgent(); - applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4"); - - void agent.streamFn?.( - { - api: "openai-responses", - provider: "openai", - id: "gpt-5.4", - } as Model<"openai-responses">, - { messages: [] }, - {}, - ); - - expect(calls).toEqual([ - expect.objectContaining({ - openaiWsWarmup: false, - }), - ]); - }); - it("maps OpenAI GPT-5 thinking level into Responses reasoning effort payloads", () => { extraParamsTesting.setProviderRuntimeDepsForTest({ prepareProviderExtraParams: () => undefined, @@ -214,7 +191,7 @@ function runPayloadMutation(params: { params.thinkingLevel, ); const context: Context = { messages: [] }; - void agent.streamFn?.(params.model, context, {} as SimpleStreamOptions); + void agent.streamFn?.(params.model, context, {}); return payload; } @@ -225,15 +202,3 @@ function installNoopProviderRuntimeDeps() { wrapProviderStreamFn: (params) => params.context.streamFn, }); } - -function createOptionsCaptureAgent() { - const calls: Array<(SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined> = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { - calls.push(options as (SimpleStreamOptions & { openaiWsWarmup?: boolean }) | undefined); - return {} as ReturnType; - }; - return { - calls, - agent: { streamFn: baseStreamFn }, - }; -} diff --git a/src/proxy-capture/coverage.ts b/src/proxy-capture/coverage.ts index 4c0285b59cb..4a235bcd4fc 100644 --- a/src/proxy-capture/coverage.ts +++ b/src/proxy-capture/coverage.ts @@ -30,15 +30,6 @@ const DEBUG_PROXY_COVERAGE_ENTRIES: readonly DebugProxyCoverageEntry[] = [ notes: "Central provider fetch seam routes through explicit proxy overrides and records request/response payloads.", }, - { - id: "openai-ws-manager", - label: "OpenAI websocket manager", - modulePath: "src/agents/openai-ws-connection.ts", - protocols: ["ws", "wss"], - status: "captured", - notes: - "Central OpenAI websocket path records open/frame/close/error events with proxy agent support.", - }, { id: "discord-rest", label: "Discord REST monitor fetch", diff --git a/test/helpers/agents/transport-params-runtime-contract.ts b/test/helpers/agents/transport-params-runtime-contract.ts index 05b200fbb43..89457a57420 100644 --- a/test/helpers/agents/transport-params-runtime-contract.ts +++ b/test/helpers/agents/transport-params-runtime-contract.ts @@ -1,7 +1,6 @@ export const OPENAI_GPT5_TRANSPORT_DEFAULTS = { parallel_tool_calls: true, text_verbosity: "low", - openaiWsWarmup: false, } as const; export const OPENAI_GPT5_TRANSPORT_DEFAULT_CASES = [