diff --git a/packages/ai/src/providers/anthropic.fetch-loopback.test.ts b/packages/ai/src/providers/anthropic.fetch-loopback.test.ts deleted file mode 100644 index 9b28ca8818f6..000000000000 --- a/packages/ai/src/providers/anthropic.fetch-loopback.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { configureAiTransportHost } from "../host.js"; -import type { Context, Model } from "../types.js"; -import { streamAnthropic } from "./anthropic.js"; - -type CapturedRequest = { - method: string; - path: string; - authorization?: string; - apiKey?: string; -}; - -const context = { - messages: [{ role: "user", content: "hello", timestamp: 1 }], -} satisfies Context; - -function makeModel(overrides: Partial>) { - return { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - provider: "anthropic", - api: "anthropic-messages", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 4_096, - ...overrides, - } satisfies Model<"anthropic-messages">; -} - -afterEach(() => { - configureAiTransportHost({}); -}); - -describe("Anthropic SDK host fetch wiring", () => { - it("routes every non-Cloudflare client branch through the host fetch", async () => { - const requests: CapturedRequest[] = []; - const server = createServer((request, response) => { - requests.push({ - method: request.method ?? "", - path: request.url ?? "", - authorization: request.headers.authorization, - apiKey: request.headers["x-api-key"] as string | undefined, - }); - response.writeHead(401, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - type: "error", - error: { type: "authentication_error", message: "test rejection" }, - }), - ); - }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - - const address = server.address() as AddressInfo; - const baseUrl = `http://127.0.0.1:${address.port}`; - const hostFetch = vi.fn((input, init) => globalThis.fetch(input, init)); - const buildModelFetch = vi.fn(() => hostFetch); - configureAiTransportHost({ buildModelFetch }); - - const cases = [ - { - model: makeModel({ provider: "github-copilot", baseUrl }), - apiKey: "copilot-token", - }, - { - model: makeModel({ provider: "microsoft-foundry", baseUrl, authHeader: true }), - apiKey: "foundry-token", - }, - { - model: makeModel({ baseUrl }), - apiKey: "sk-ant-oat01-oauth-token", // pragma: allowlist secret - }, - { - model: makeModel({ baseUrl }), - apiKey: "sk-ant-api03-api-key", // pragma: allowlist secret - }, - { - model: makeModel({ provider: "kimi-coding", baseUrl }), - apiKey: "kimi-api-key", - thinkingEnabled: true, - }, - ]; - - try { - for (const testCase of cases) { - const result = await streamAnthropic(testCase.model, context, { - apiKey: testCase.apiKey, - thinkingEnabled: testCase.thinkingEnabled, - }).result(); - expect(result.stopReason).toBe("error"); - } - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - - expect(hostFetch).toHaveBeenCalledTimes(cases.length); - expect(requests).toEqual([ - { - method: "POST", - path: "/v1/messages", - authorization: "Bearer copilot-token", - apiKey: undefined, - }, - { - method: "POST", - path: "/v1/messages", - authorization: "Bearer foundry-token", - apiKey: undefined, - }, - { - method: "POST", - path: "/v1/messages", - authorization: "Bearer sk-ant-oat01-oauth-token", // pragma: allowlist secret - apiKey: undefined, - }, - { - method: "POST", - path: "/v1/messages", - authorization: undefined, - apiKey: "sk-ant-api03-api-key", // pragma: allowlist secret - }, - { - method: "POST", - path: "/v1/messages", - authorization: undefined, - apiKey: "kimi-api-key", - }, - ]); - expect(buildModelFetch).toHaveBeenLastCalledWith(cases.at(-1)?.model, undefined, { - sanitizeSse: false, - }); - }); -}); diff --git a/packages/ai/src/providers/anthropic.sse-parse-error.test.ts b/packages/ai/src/providers/anthropic.sse-parse-error.test.ts index 37662e706ebb..f070141aa71a 100644 --- a/packages/ai/src/providers/anthropic.sse-parse-error.test.ts +++ b/packages/ai/src/providers/anthropic.sse-parse-error.test.ts @@ -1,8 +1,9 @@ -import { createServer } from "node:http"; +import { createServer, type RequestListener } from "node:http"; import type { AddressInfo } from "node:net"; import Anthropic from "@anthropic-ai/sdk"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { streamWithIdleTimeout } from "../../../../src/agents/embedded-agent-runner/run/llm-idle-timeout.js"; +import { configureAiTransportHost } from "../host.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transports/transport-utils.js"; import type { Context, Model } from "../types.js"; import { streamAnthropic } from "./anthropic.js"; @@ -43,7 +44,10 @@ const context = { messages: [{ role: "user", content: "hello", timestamp: 1 }], } satisfies Context; -function makeModel(baseUrl: string): Model<"anthropic-messages"> { +function makeModel( + baseUrl: string, + overrides: Partial> = {}, +): Model<"anthropic-messages"> { return { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", @@ -55,39 +59,52 @@ function makeModel(baseUrl: string): Model<"anthropic-messages"> { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 4_096, + ...overrides, } satisfies Model<"anthropic-messages">; } +async function withLoopback( + handler: RequestListener, + run: (baseUrl: string) => Promise, +): Promise { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address() as AddressInfo; + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + // Real loopback socket speaking Anthropic's event stream. Only the far end of the // socket is ours; the SDK, its SSE reader, and the provider stream are production code. async function streamAnthropicSseFrames( frames: readonly (readonly [string, string])[], ): Promise<{ stopReason: string; errorMessage?: string }> { - const server = createServer((request, response) => { - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - }); - for (const [event, data] of frames) { - response.write(`event: ${event}\ndata: ${data}\n\n`); - } - response.end(); - void request.resume(); - }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address() as AddressInfo; - try { - const result = await streamAnthropic(makeModel(`http://127.0.0.1:${address.port}`), context, { - apiKey: "test-api-key", - }).result(); - return { stopReason: result.stopReason, errorMessage: result.errorMessage }; - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } + return withLoopback( + (request, response) => { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + for (const [event, data] of frames) { + response.write(`event: ${event}\ndata: ${data}\n\n`); + } + response.end(); + void request.resume(); + }, + async (baseUrl) => { + const result = await streamAnthropic(makeModel(baseUrl), context, { + apiKey: "test-api-key", + }).result(); + return { stopReason: result.stopReason, errorMessage: result.errorMessage }; + }, + ); } describe("Anthropic malformed SSE frames", () => { @@ -99,82 +116,61 @@ describe("Anthropic malformed SSE frames", () => { expect(result.errorMessage).not.toContain(SENTINEL); }); - it("still completes a well-formed stream", async () => { - const result = await streamAnthropicSseFrames(WELL_FORMED_FRAMES); - - expect(result.stopReason).toBe("stop"); - expect(result.errorMessage).toBeUndefined(); - }); - it("keeps a response alive while Anthropic sends protocol pings", async () => { const idleTimeoutMs = 1_000; const finalResponseDelayMs = 1_200; let pingCount = 0; - const server = createServer((request, response) => { - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - }); - const writeFrame = ([event, data]: (typeof WELL_FORMED_FRAMES)[number]) => { - response.write(`event: ${event}\ndata: ${data}\n\n`); - }; - writeFrame(WELL_FORMED_FRAMES[0]); - const pingTimer = setInterval(() => { - pingCount += 1; - response.write('event: ping\ndata: {"type":"ping"}\n\n'); - }, 20); - const finalTimer = setTimeout(() => { - clearInterval(pingTimer); - for (const frame of WELL_FORMED_FRAMES.slice(1)) { - writeFrame(frame); + await withLoopback( + (request, response) => { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + const writeFrame = ([event, data]: (typeof WELL_FORMED_FRAMES)[number]) => { + response.write(`event: ${event}\ndata: ${data}\n\n`); + }; + writeFrame(WELL_FORMED_FRAMES[0]); + const pingTimer = setInterval(() => { + pingCount += 1; + response.write('event: ping\ndata: {"type":"ping"}\n\n'); + }, 20); + const finalTimer = setTimeout(() => { + clearInterval(pingTimer); + for (const frame of WELL_FORMED_FRAMES.slice(1)) { + writeFrame(frame); + } + response.end(); + }, finalResponseDelayMs); + response.on("close", () => { + clearInterval(pingTimer); + clearTimeout(finalTimer); + }); + void request.resume(); + }, + async (baseUrl) => { + const onIdleTimeout = vi.fn(); + const stream = (await Promise.resolve( + streamWithIdleTimeout(streamAnthropic as never, idleTimeoutMs, onIdleTimeout)( + makeModel(baseUrl), + context, + { apiKey: "test-api-key" }, + ), + )) as ReturnType; + for await (const event of stream) { + // The idle watchdog guards consumer waits between provider events. + void event; } - response.end(); - }, finalResponseDelayMs); - response.on("close", () => { - clearInterval(pingTimer); - clearTimeout(finalTimer); - }); - void request.resume(); - }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()); - }); - const address = server.address() as AddressInfo; - try { - const onIdleTimeout = vi.fn(); - const stream = (await Promise.resolve( - streamWithIdleTimeout(streamAnthropic as never, idleTimeoutMs, onIdleTimeout)( - makeModel(`http://127.0.0.1:${address.port}`), - context, - { apiKey: "test-api-key" }, - ), - )) as ReturnType; - for await (const event of stream) { - // The idle watchdog guards consumer waits between provider events. - void event; - } - const result = await stream.result(); - - expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([expect.objectContaining({ type: "text", text: "ok" })]); - expect(onIdleTimeout).not.toHaveBeenCalled(); - expect(pingCount).toBeGreaterThan(0); - expect(finalResponseDelayMs).toBeGreaterThan(idleTimeoutMs); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } + const result = await stream.result(); + expect(result.stopReason).toBe("stop"); + expect(result.content).toEqual([expect.objectContaining({ type: "text", text: "ok" })]); + expect(onIdleTimeout).not.toHaveBeenCalled(); + expect(pingCount).toBeGreaterThan(0); + expect(finalResponseDelayMs).toBeGreaterThan(idleTimeoutMs); + }, + ); }); it.each([ - { - label: "rejects an empty first-party stream", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - frames: [], - stopReason: "error", - }, { label: "rejects a ping-only first-party stream", provider: "anthropic", @@ -182,20 +178,6 @@ describe("Anthropic malformed SSE frames", () => { frames: [["ping", '{"type":"ping"}']] as const, stopReason: "error", }, - { - label: "still rejects a started first-party stream without message_stop", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - frames: WELL_FORMED_FRAMES.slice(0, -1), - stopReason: "error", - }, - { - label: "still accepts an empty compatible provider stream", - provider: "openrouter", - baseUrl: "https://proxy.example.com/v1", - frames: [], - stopReason: "stop", - }, { label: "still accepts a ping-only Anthropic-compatible custom endpoint", provider: "anthropic", @@ -239,3 +221,97 @@ describe("Anthropic malformed SSE frames", () => { } }); }); + +type CapturedRequest = { + method: string; + path: string; + authorization?: string; + apiKey?: string; + resourceKey?: string; +}; +afterEach(() => configureAiTransportHost({})); +describe("Anthropic SDK host fetch wiring", () => { + it("routes every non-Cloudflare client branch through the host fetch", async () => { + const requests: CapturedRequest[] = []; + await withLoopback( + (request, response) => { + requests.push({ + method: request.method ?? "", + path: request.url ?? "", + authorization: request.headers.authorization, + apiKey: request.headers["x-api-key"] as string | undefined, + resourceKey: request.headers["api-key"] as string | undefined, + }); + response.writeHead(401, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + type: "error", + error: { type: "authentication_error", message: "test rejection" }, + }), + ); + }, + async (baseUrl) => { + const hostFetch = vi.fn((input, init) => globalThis.fetch(input, init)); + const buildModelFetch = vi.fn(() => hostFetch); + configureAiTransportHost({ buildModelFetch }); + const cases = [ + { + model: makeModel(baseUrl, { provider: "github-copilot" }), + apiKey: "copilot-token", + }, + { + model: makeModel(baseUrl, { + provider: "microsoft-foundry", + authHeader: true, + headers: { "api-key": "stale-foundry-key", "x-api-key": "stale-resource-key" }, + }), + apiKey: "foundry-token", + }, + { + model: makeModel(baseUrl), + apiKey: "sk-ant-oat01-oauth-token", // pragma: allowlist secret + }, + { + model: makeModel(baseUrl, { + provider: "microsoft-foundry", + headers: { "api-key": "foundry-resource-key" }, + }), + apiKey: "foundry-resource-key", + }, + { + model: makeModel(baseUrl, { provider: "kimi-coding" }), + apiKey: "kimi-api-key", + thinkingEnabled: true, + }, + ]; + for (const testCase of cases) { + const result = await streamAnthropic(testCase.model, context, { + apiKey: testCase.apiKey, + thinkingEnabled: testCase.thinkingEnabled, + }).result(); + expect(result.stopReason).toBe("error"); + } + expect(hostFetch).toHaveBeenCalledTimes(cases.length); + for (const request of requests) { + expect([request.method, request.path]).toEqual(["POST", "/v1/messages"]); + } + expect( + requests.map(({ authorization, apiKey, resourceKey }) => [ + authorization, + apiKey, + resourceKey, + ]), + ).toEqual([ + ["Bearer copilot-token", undefined, undefined], + ["Bearer foundry-token", undefined, undefined], + ["Bearer sk-ant-oat01-oauth-token", undefined, undefined], // pragma: allowlist secret + [undefined, "foundry-resource-key", "foundry-resource-key"], + [undefined, "kimi-api-key", undefined], + ]); + expect(buildModelFetch).toHaveBeenLastCalledWith(cases.at(-1)?.model, undefined, { + sanitizeSse: false, + }); + }, + ); + }); +}); diff --git a/packages/ai/src/providers/anthropic.test.ts b/packages/ai/src/providers/anthropic.test.ts index da89a8fe1042..f2d4deb5ca0c 100644 --- a/packages/ai/src/providers/anthropic.test.ts +++ b/packages/ai/src/providers/anthropic.test.ts @@ -7,7 +7,6 @@ import { SYSTEM_PROMPT_RELOCATABLE_BOUNDARY, SYSTEM_PROMPT_RELOCATABLE_BOUNDARY_END, } from "../utils/system-prompt-cache-boundary.js"; -import { anthropicServerSideFallbackCases } from "./anthropic-server-fallback.test-support.js"; const anthropicMockState = vi.hoisted(() => ({ configs: [] as unknown[], @@ -33,6 +32,22 @@ import { makeTextToolResult } from "../../../../test/helpers/text-tool-result.js import { createZeroUsage } from "../usage.test-support.js"; import { streamAnthropic, streamSimpleAnthropic } from "./anthropic.js"; +function user( + content: Extract["content"], + timestamp = 0, +): Extract { + return { role: "user", content, timestamp }; +} +function conversation(...messages: Context["messages"]): Context { + return { messages }; +} +function thinking( + text: string, + thinkingSignature: string, +): Extract { + return { type: "thinking", thinking: text, thinkingSignature }; +} + function createSseResponse(events: Record[] = []): Response { const body = events .map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`) @@ -53,6 +68,51 @@ function createAnthropicSseClient(events: Record[]) { }; } +function startStream( + client: ReturnType, + model: Partial> = {}, + context: Context = { messages: [user("hello")] }, + options: NonNullable[2]> = {}, +) { + return streamAnthropic(makeAnthropicModel(model), context, { + apiKey: "sk-ant-provider", + client: client as never, + ...options, + }); +} +async function consumeStream(stream: ReturnType) { + const eventTypes: string[] = []; + for await (const event of stream) { + eventTypes.push(event.type); + } + return { eventTypes, result: await stream.result() }; +} + +function wireUsage(input: number, output: number, cacheRead: number, cacheWrite: number | null) { + return { + input_tokens: input, + output_tokens: output, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheWrite, + }; +} + +function messageStart(usage: Record, model?: string) { + return { type: "message_start", message: { id: "msg_test", model, usage } }; +} +function blockStart(index: number, content_block: Record) { + return { type: "content_block_start", index, content_block }; +} +function blockDelta(index: number, delta: Record) { + return { type: "content_block_delta", index, delta }; +} +function blockStop(index: number) { + return { type: "content_block_stop", index }; +} +function messageDelta(delta: Record, usage?: Record) { + return { type: "message_delta", delta, usage }; +} + function makeAnthropicModel(overrides: Partial> = {}) { return { id: "claude-sonnet-4-6", @@ -86,6 +146,41 @@ function makeAnthropicAssistantMessage( }; } +type ToolResultContent = Extract["content"]; +function toolContext(content: ToolResultContent, isError = false, provider = "anthropic"): Context { + return { + messages: [ + makeAnthropicAssistantMessage( + [{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} }], + { provider, stopReason: "toolUse" }, + ), + { + role: "toolResult", + toolCallId: "call_1", + toolName: "lookup", + content, + isError, + timestamp: 0, + }, + ], + }; +} +function wireMessages(payload: Record) { + return payload.messages as Array<{ role: string; content: Array> }>; +} +function assistantContent(payload: Record) { + return wireMessages(payload).find((message) => message.role === "assistant")?.content; +} +function getToolResult(payload: Record) { + const result = wireMessages(payload) + .find((message) => message.role === "user") + ?.content.find((block) => block.type === "tool_result"); + if (!result) { + throw new Error("Expected an Anthropic tool result"); + } + return result; +} + type SimpleAnthropicTestOptions = Omit< NonNullable[2]>, "onPayload" @@ -104,30 +199,10 @@ type RawAnthropicTestOptions = Omit< stopBeforeNetwork?: boolean; }; -type AnthropicAuthenticationTestCase = { - name: string; - model: Partial>; - key: string; - expected: { apiKey: string | null; authToken: string | null }; - headers?: Record; - absent?: string[]; - useHostFetch?: boolean; - resolveSentinel?: boolean; -}; - -type AnthropicAdaptiveThinkingTestCase = { - name: string; - model: Partial>; - options: SimpleAnthropicTestOptions; - context?: Context; - expected: Record; - absent?: string[]; -}; - async function captureSimpleAnthropicPayload( model: Partial>, options: SimpleAnthropicTestOptions | RawAnthropicTestOptions = {}, - context: Context = { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + context: Context = { messages: [user("hello")] }, ) { const { injectPayload, stopBeforeNetwork, mode, ...streamOptions } = options; let capturedPayload: unknown; @@ -153,19 +228,6 @@ async function captureSimpleAnthropicPayload( return { payload: capturedPayload as Record, result }; } -function makeSonnet5PrefillContext(): Context { - return { - messages: [ - { role: "user", content: "Return JSON.", timestamp: 0 }, - makeAnthropicAssistantMessage([{ type: "text", text: "{" }], { - model: "claude-sonnet-5", - timestamp: 1, - }), - ], - tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }], - }; -} - function tinyJpegBase64(): string { return Buffer.from([ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, @@ -191,100 +253,52 @@ describe("Anthropic provider", () => { configureAiTransportHost({}); }); - const foundrySentinel = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end"; - - const authenticationCases: AnthropicAuthenticationTestCase[] = [ - { - name: "keeps Cloudflare AI Gateway upstream provider auth on the Anthropic API key", - model: { + it("keeps Cloudflare upstream auth on the Anthropic API key", async () => { + const hostFetch: typeof fetch = async () => new Response(null, { status: 500 }); + configureAiTransportHost({ buildModelFetch: () => hostFetch }); + await streamAnthropic( + makeAnthropicModel({ provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic/v1/messages", headers: { "cf-aig-authorization": "Bearer gateway-token" }, - }, - key: "sk-ant-provider", - expected: { apiKey: "sk-ant-provider", authToken: null }, - headers: { "cf-aig-authorization": "Bearer gateway-token" }, - absent: ["x-api-key"], - useHostFetch: true, - }, - { - name: "uses bearer auth for Microsoft Foundry Anthropic requests", - model: { + }), + conversation(user("hello", 1)), + { apiKey: "sk-ant-provider" }, + ).result(); + expect(anthropicMockState.configs).toHaveLength(1); + expect(anthropicMockState.configs[0]).toMatchObject({ + apiKey: "sk-ant-provider", + authToken: null, + fetch: hostFetch, + defaultHeaders: { "cf-aig-authorization": "Bearer gateway-token" }, + }); + expect(anthropicMockState.configs[0]).not.toHaveProperty("defaultHeaders.x-api-key"); + }); + it("keeps sentinel-backed Foundry headers on bearer routing", async () => { + const sentinel = "oc-sent-v2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.end"; + configureAiTransportHost({ + buildModelFetch: () => async () => new Response(null, { status: 500 }), + resolveSecretSentinel: (value) => value.replaceAll(sentinel, "Bearer entra-access-token"), + }); + await streamAnthropic( + makeAnthropicModel({ provider: "microsoft-foundry", baseUrl: "https://example.services.ai.azure.com/anthropic", - authHeader: true, - headers: { "api-key": "stale-foundry-key", "x-api-key": "stale-resource-key" }, - }, - key: "entra-access-token", - expected: { apiKey: null, authToken: "entra-access-token" }, - absent: ["Authorization", "api-key", "x-api-key"], - }, - { - name: "keeps sentinel-backed Foundry Authorization headers on bearer routing", - model: { - provider: "microsoft-foundry", - baseUrl: "https://example.services.ai.azure.com/anthropic", - headers: { Authorization: foundrySentinel }, - }, - key: foundrySentinel, - expected: { apiKey: null, authToken: foundrySentinel }, - resolveSentinel: true, - }, - { - name: "keeps Microsoft Foundry API-key profiles on Anthropic API key auth", - model: { - provider: "microsoft-foundry", - baseUrl: "https://example.services.ai.azure.com/anthropic", - headers: { "api-key": "foundry-resource-key" }, - }, - key: "foundry-resource-key", - expected: { apiKey: "foundry-resource-key", authToken: null }, - }, - ]; - - it.each(authenticationCases)("$name", async (testCase) => { - const hostFetch: typeof fetch = async () => new Response(null, { status: 500 }); - if (testCase.useHostFetch || testCase.resolveSentinel) { - configureAiTransportHost({ - buildModelFetch: () => hostFetch, - ...(testCase.resolveSentinel - ? { - resolveSecretSentinel: (value: string) => - value.replaceAll(foundrySentinel, "Bearer entra-access-token"), - } - : {}), - }); - } - streamAnthropic( - makeAnthropicModel(testCase.model), - { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, - { apiKey: testCase.key }, - ); - await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1)); - const config = anthropicMockState.configs[0] as { - apiKey?: string | null; - authToken?: string | null; - defaultHeaders?: Record; - fetch?: unknown; - }; - expect(config).toMatchObject(testCase.expected); - for (const [key, value] of Object.entries(testCase.headers ?? {})) { - expect(config.defaultHeaders?.[key]).toBe(value); - } - for (const key of testCase.absent ?? []) { - expect(config.defaultHeaders?.[key]).toBeUndefined(); - } - if (testCase.useHostFetch) { - expect(config.fetch).toBe(hostFetch); - } + headers: { Authorization: sentinel }, + }), + conversation(user("hello", 1)), + { apiKey: sentinel }, + ).result(); + expect(anthropicMockState.configs).toHaveLength(1); + expect(anthropicMockState.configs[0]).toMatchObject({ apiKey: null, authToken: sentinel }); }); - it.each(["none", "short", "long"] as const)( + it.each(["long"] as const)( "sends the OpenCode session header with %s cache retention", async (cacheRetention) => { streamAnthropic( makeAnthropicModel({ baseUrl: "https://opencode.ai/zen/go" }), - { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + conversation(user("hello", 1)), { apiKey: "sk-ant-provider", sessionId: "session-123", cacheRetention }, ); await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1)); @@ -305,7 +319,7 @@ describe("Anthropic provider", () => { const { payload: capturedPayload, result } = await captureSimpleAnthropicPayload( {}, { apiKey: "sk-ant-oat01-test-token" }, - { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + conversation(user("hello", 1)), ); expect(result.stopReason).toBe("error"); @@ -324,38 +338,14 @@ describe("Anthropic provider", () => { it("includes compaction iterations in billed usage while keeping final context usage", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_usage", - model: "claude-fable-5", - usage: { - input_tokens: 12, - output_tokens: 0, - cache_read_input_tokens: 120_000, - cache_creation_input_tokens: null, - }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "Done." }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { - input_tokens: 12, - output_tokens: 15_104, - cache_read_input_tokens: 819_661, - cache_creation_input_tokens: 93_130, + messageStart(wireUsage(12, 0, 120_000, null), "claude-fable-5"), + blockStart(0, { type: "text", text: "" }), + blockDelta(0, { type: "text_delta", text: "Done." }), + blockStop(0), + messageDelta( + { stop_reason: "end_turn" }, + { + ...wireUsage(12, 15_104, 819_661, 93_130), iterations: [ { type: "compaction", @@ -373,19 +363,15 @@ describe("Anthropic provider", () => { }, ], }, - }, + ), { type: "message_stop" }, ]); - const result = await streamAnthropic( - makeAnthropicModel({ - id: "claude-fable-5", - name: "Claude Fable 5", - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ).result(); + const result = await startStream(client, { + id: "claude-fable-5", + name: "Claude Fable 5", + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }).result(); expect(result.usage).toMatchObject({ input: 24, @@ -402,62 +388,31 @@ describe("Anthropic provider", () => { expect(result.usage.cost.total).toBeCloseTo(1.469044, 6); }); - it.each(["opaque-final-compaction", null])( + it.each([null])( "captures streamed Anthropic compaction deltas and replays final opaque metadata %s", async (encryptedContent) => { const firstClient = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_compaction", - model: "claude-sonnet-4-6", - usage: { input_tokens: 50_001, output_tokens: 0 }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "compaction", - content: null, - encrypted_content: "opaque-initial-compaction", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "compaction_delta", - content: "summary ", - encrypted_content: "opaque-partial-compaction", - }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "compaction_delta", - content: "checkpoint", - encrypted_content: encryptedContent, - }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "content_block_start", - index: 1, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 1, - delta: { type: "text_delta", text: "Done." }, - }, - { type: "content_block_stop", index: 1 }, - { - type: "message_delta", - delta: { stop_reason: "compaction" }, - usage: { input_tokens: 1, output_tokens: 1 }, - }, + messageStart({ input_tokens: 50_001, output_tokens: 0 }, "claude-sonnet-4-6"), + blockStart(0, { + type: "compaction", + content: null, + encrypted_content: "opaque-initial-compaction", + }), + blockDelta(0, { + type: "compaction_delta", + content: "summary ", + encrypted_content: "opaque-partial-compaction", + }), + blockDelta(0, { + type: "compaction_delta", + content: "checkpoint", + encrypted_content: encryptedContent, + }), + blockStop(0), + blockStart(1, { type: "text", text: "" }), + blockDelta(1, { type: "text_delta", text: "Done." }), + blockStop(1), + messageDelta({ stop_reason: "compaction" }, { input_tokens: 1, output_tokens: 1 }), { type: "message_stop" }, ]); const replayOptions = { @@ -466,15 +421,9 @@ describe("Anthropic provider", () => { sessionId: "session-1", } as const; const firstUser = { role: "user" as const, content: "old question", timestamp: 0 }; - const first = await streamAnthropic( - makeAnthropicModel(), - { messages: [firstUser] }, - { - apiKey: "sk-ant-provider", - client: firstClient as never, - ...replayOptions, - }, - ).result(); + const first = await startStream(firstClient, {}, conversation(firstUser), { + ...replayOptions, + }).result(); expect(first.stopReason).toBe("stop"); expect(first.providerReplay).toMatchObject({ @@ -485,31 +434,19 @@ describe("Anthropic provider", () => { let replayPayload: Record | undefined; const secondClient = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_replay", - model: "claude-sonnet-4-6", - usage: { input_tokens: 1 }, - }, - }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { input_tokens: 1, output_tokens: 1 }, - }, + messageStart({ input_tokens: 1 }, "claude-sonnet-4-6"), + messageDelta({ stop_reason: "end_turn" }, { input_tokens: 1, output_tokens: 1 }), { type: "message_stop" }, ]); // oxlint-disable-next-line unicorn/prefer-structured-clone -- Verify persisted provider replay after JSON transcript reload. const savedMessages: Context["messages"] = JSON.parse( - JSON.stringify([firstUser, first, { role: "user", content: "new question", timestamp: 2 }]), + JSON.stringify([firstUser, first, user("new question", 2)]), ); - await streamAnthropic( - makeAnthropicModel(), + await startStream( + secondClient, + {}, { messages: savedMessages }, { - apiKey: "sk-ant-provider", - client: secondClient as never, ...replayOptions, onPayload: (payload) => { replayPayload = payload as Record; @@ -530,32 +467,63 @@ describe("Anthropic provider", () => { }, ); + it.each([ + { + name: "keeps final context unavailable for a malformed final iteration", + initial: wireUsage(12, 0, 120_000, 0), + final: { + ...wireUsage(12, 15_104, 819_661, 93_130), + iterations: [ + { type: "message", ...wireUsage(12, 15_104, 148_862, 0), input_tokens: "malformed" }, + ], + }, + expectedUsage: { totalTokens: 927_907 }, + contextUsage: { state: "unavailable" }, + }, + { + name: "settles complete final usage after zero message-start placeholders", + initial: wireUsage(0, 0, 0, 0), + final: wireUsage(12, 15_104, 148_862, 0), + expectedUsage: { totalTokens: 163_978 }, + contextUsage: { state: "available", promptTokens: 148_874, totalTokens: 163_978 }, + }, + { + name: "preserves valid initial billing buckets when a sibling is malformed", + initial: { ...wireUsage(12, 0, 0, 500), cache_read_input_tokens: "malformed" }, + final: { input_tokens: 12, output_tokens: 15_104, cache_creation_input_tokens: null }, + expectedUsage: { + input: 12, + output: 15_104, + cacheRead: 0, + cacheWrite: 500, + totalTokens: 15_616, + }, + contextUsage: { state: "unavailable" }, + }, + ])("$name", async ({ initial, final, expectedUsage, contextUsage }) => { + const client = createAnthropicSseClient([ + messageStart(initial, "claude-fable-5"), + messageDelta({ stop_reason: "end_turn" }, final), + { type: "message_stop" }, + ]); + const result = await startStream(client, { + id: "claude-fable-5", + name: "Claude Fable 5", + }).result(); + expect(result.usage).toMatchObject(expectedUsage); + expect(result.usage.contextUsage).toEqual(contextUsage); + }); + it("ignores a message_delta whose usage object is omitted", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_no_delta_usage", - model: "claude-sonnet-4-6", - usage: { - input_tokens: 12, - output_tokens: 0, - cache_read_input_tokens: 3, - cache_creation_input_tokens: 4, - }, - }, - }, - { type: "message_delta", delta: { stop_reason: "end_turn" } }, + messageStart(wireUsage(12, 0, 3, 4), "claude-sonnet-4-6"), + messageDelta({ stop_reason: "end_turn" }), { type: "message_stop" }, ]); - const result = await streamAnthropic( - makeAnthropicModel({ - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { client: client as never }, - ).result(); + const result = await startStream(client, { + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }).result(); expect(result.stopReason).toBe("stop"); expect(result.usage).toMatchObject({ @@ -572,38 +540,23 @@ describe("Anthropic provider", () => { it("prices reported 1-hour cache writes at twice the input rate", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_cache_ttl_usage", - model: "claude-sonnet-4-6", - usage: { - input_tokens: 100, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 1_000_000, - cache_creation: { - ephemeral_5m_input_tokens: 600_000, - ephemeral_1h_input_tokens: 400_000, - }, + messageStart( + { + ...wireUsage(100, 0, 0, 1_000_000), + cache_creation: { + ephemeral_5m_input_tokens: 600_000, + ephemeral_1h_input_tokens: 400_000, }, }, - }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 5 }, - }, + "claude-sonnet-4-6", + ), + messageDelta({ stop_reason: "end_turn" }, { output_tokens: 5 }), { type: "message_stop" }, ]); - const result = await streamAnthropic( - makeAnthropicModel({ - cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { client: client as never }, - ).result(); + const result = await startStream(client, { + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + }).result(); expect(result.usage).toMatchObject({ cacheWrite: 1_000_000, @@ -614,9 +567,7 @@ describe("Anthropic provider", () => { it("pins Anthropic SDK retries to zero", async () => { const model = makeAnthropicModel(); - await streamAnthropic(model, { - messages: [{ role: "user", content: "hello", timestamp: 0 }], - }).result(); + await streamAnthropic(model, conversation(user("hello"))).result(); expect(anthropicMockState.requestOptions).toEqual([expect.objectContaining({ maxRetries: 0 })]); expect(anthropicMockState.configs.at(-1)).toMatchObject({ maxRetries: 0 }); @@ -626,214 +577,52 @@ describe("Anthropic provider", () => { { allowEmptySignature: undefined, expectedType: "text", expectedSignature: undefined }, { allowEmptySignature: true, expectedType: "thinking", expectedSignature: "" }, ])( - "replays empty thinking signatures as $expectedType when allowEmptySignature=$allowEmptySignature", + "replays empty thinking signatures as $expectedType", async ({ allowEmptySignature, expectedType, expectedSignature }) => { - let capturedPayload: unknown; - const model = makeAnthropicModel( + const { payload } = await captureSimpleAnthropicPayload( allowEmptySignature === undefined ? {} : { compat: { allowEmptySignature } }, + { mode: "raw", thinkingEnabled: true }, + conversation( + user("first"), + makeAnthropicAssistantMessage([thinking("private analysis", " ")]), + user("second"), + ), ); - const assistantMessage = makeAnthropicAssistantMessage( - [{ type: "thinking", thinking: "private analysis", thinkingSignature: " " }], - { model: model.id }, - ); - - await streamAnthropic( - model, - { - messages: [ - { role: "user", content: "first", timestamp: 0 }, - assistantMessage, - { role: "user", content: "second", timestamp: 0 }, - ], - }, - { - thinkingEnabled: true, - onPayload: (payload) => { - capturedPayload = payload; - }, - }, - ).result(); - - const messages = (capturedPayload as { messages: Array<{ role: string; content: unknown }> }) - .messages; - const assistant = messages.find((message) => message.role === "assistant") as { - content: Array<{ type: string; signature?: string }>; - }; - expect(assistant.content[0]).toMatchObject({ type: expectedType }); - expect(assistant.content[0]?.signature).toBe(expectedSignature); + const [block] = assistantContent(payload) ?? []; + expect(block).toMatchObject({ type: expectedType }); + expect(block?.signature).toBe(expectedSignature); }, ); - it.each([ - { - name: "does not fall back to aggregate usage when the final iteration is malformed", - id: "msg_invalid_iteration", - model: "claude-fable-5", - initial: { - input_tokens: 12, - output_tokens: 0, - cache_read_input_tokens: 120_000, - cache_creation_input_tokens: 0, - }, - final: { - input_tokens: 12, - output_tokens: 15_104, - cache_read_input_tokens: 819_661, - cache_creation_input_tokens: 93_130, - iterations: [ - { - type: "message", - input_tokens: "malformed", - output_tokens: 15_104, - cache_read_input_tokens: 148_862, - cache_creation_input_tokens: 0, - }, - ], - }, - expected: { totalTokens: 927_907 }, - context: { state: "unavailable" }, - }, - { - name: "uses complete final usage when message-start prompt buckets are zero placeholders", - id: "msg_zero_start", - model: "claude-fable-5", - initial: { - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - final: { - input_tokens: 12, - output_tokens: 15_104, - cache_read_input_tokens: 148_862, - cache_creation_input_tokens: 0, - }, - context: { state: "available", promptTokens: 148_874, totalTokens: 163_978 }, - }, - { - name: "does not treat zero start placeholders as complete final prompt usage", - id: "msg_zero_start_partial_delta", - model: "claude-fable-5", - initial: { - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - final: { output_tokens: 15_104 }, - context: { state: "unavailable" }, - }, - { - name: "uses accumulated prompt buckets when the final usage update is partial", - id: "msg_partial_final_usage", - model: "claude-sonnet-4-6", - initial: { - input_tokens: 12, - output_tokens: 0, - cache_read_input_tokens: 120_000, - cache_creation_input_tokens: 500, - }, - final: { - input_tokens: 12, - output_tokens: 15_104, - cache_read_input_tokens: 148_862, - cache_creation_input_tokens: null, - }, - context: { state: "available", promptTokens: 149_374, totalTokens: 164_478 }, - }, - { - name: "preserves valid message-start billing buckets when a sibling is malformed", - id: "msg_malformed_usage", - model: "claude-sonnet-4-6", - initial: { - input_tokens: 12, - output_tokens: 0, - cache_read_input_tokens: "malformed", - cache_creation_input_tokens: 500, - }, - final: { input_tokens: 12, output_tokens: 15_104, cache_creation_input_tokens: null }, - expected: { input: 12, output: 15_104, cacheRead: 0, cacheWrite: 500, totalTokens: 15_616 }, - context: { state: "unavailable" }, - }, - ])("$name", async (testCase) => { - const client = createAnthropicSseClient([ - { - type: "message_start", - message: { id: testCase.id, model: testCase.model, usage: testCase.initial }, - }, - { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: testCase.final }, - { type: "message_stop" }, - ]); - const result = await streamAnthropic( - makeAnthropicModel({ - id: testCase.model, - name: testCase.model === "claude-fable-5" ? "Claude Fable 5" : "Claude Sonnet 4.6", - }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ).result(); - if (testCase.expected) { - expect(result.usage).toMatchObject(testCase.expected); - } - expect(result.usage.contextUsage).toEqual(testCase.context); - }); - it("preserves provider-signed Anthropic thinking and drops reasoning_content placeholders", async () => { const highSurrogate = String.fromCharCode(0xd83d); const signedThinking = `keep${highSurrogate}signed`; let capturedPayload: unknown; const client = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_1", - model: "claude-fable-5", - usage: { input_tokens: 1, output_tokens: 0 }, - }, - }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { input_tokens: 1, output_tokens: 1 }, - }, + messageStart({ input_tokens: 1, output_tokens: 0 }, "claude-fable-5"), + messageDelta({ stop_reason: "end_turn" }, { input_tokens: 1, output_tokens: 1 }), { type: "message_stop" }, ]); - const stream = streamAnthropic( - makeAnthropicModel({ + const stream = startStream( + client, + { id: "claude-fable-5", name: "Claude Fable 5", - }), - { - messages: [ - { role: "user", content: "hello", timestamp: 0 }, - makeAnthropicAssistantMessage( - [ - { - type: "thinking", - thinking: signedThinking, - thinkingSignature: "sig_1", - }, - { - type: "thinking", - thinking: "", - thinkingSignature: "sig_omitted", - }, - { - type: "thinking", - thinking: `sanitize${highSurrogate}synthetic`, - thinkingSignature: "reasoning_content", - }, - ], - { model: "claude-fable-5" }, - ), - { role: "user", content: "again", timestamp: 0 }, - ], }, + conversation( + user("hello"), + makeAnthropicAssistantMessage( + [ + thinking(signedThinking, "sig_1"), + thinking("", "sig_omitted"), + thinking(`sanitize${highSurrogate}synthetic`, "reasoning_content"), + ], + { model: "claude-fable-5" }, + ), + user("again"), + ), { - apiKey: "sk-ant-provider", - client: client as never, onPayload: (payload) => { capturedPayload = payload; }, @@ -860,81 +649,45 @@ describe("Anthropic provider", () => { expect(result.responseModel).toBe("claude-fable-5"); }); - it.each([ - { - label: "omitted", - thinkingEnabled: undefined, - expectedThinking: undefined, - visibleText: undefined, - expectedContent: [{ type: "text", text: "[assistant reasoning omitted]" }], - }, - { - label: "explicitly disabled", - thinkingEnabled: false, - expectedThinking: { type: "disabled" }, - visibleText: "Visible answer.", - expectedContent: [{ type: "text", text: "Visible answer." }], - }, - ])( - "omits completed-turn thinking when thinking is $label", - async ({ thinkingEnabled, expectedThinking, visibleText, expectedContent }) => { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - {}, - { mode: "raw", thinkingEnabled, stopBeforeNetwork: true }, - { - messages: [ - { role: "user", content: "hello", timestamp: 0 }, - makeAnthropicAssistantMessage([ - { - type: "thinking", - thinking: "private reasoning", - thinkingSignature: "sig_1", - }, - { - type: "thinking", - thinking: "[Reasoning redacted]", - thinkingSignature: "opaque_1", - redacted: true, - }, - ...(visibleText ? [{ type: "text" as const, text: visibleText }] : []), - ]), - { role: "user", content: "again", timestamp: 0 }, - ], - }, - ); - - const payload = capturedPayload as { - messages: Array<{ role: string; content: unknown[] }>; - thinking?: unknown; - }; - expect(payload.thinking).toEqual(expectedThinking); - expect(payload.messages.find((message) => message.role === "assistant")?.content).toEqual( - expectedContent, - ); - }, - ); + it("omits completed-turn thinking when no thinking mode is requested", async () => { + const { payload } = await captureSimpleAnthropicPayload( + {}, + { mode: "raw", stopBeforeNetwork: true }, + conversation( + user("hello"), + makeAnthropicAssistantMessage([ + thinking("private reasoning", "sig_1"), + { + type: "thinking", + thinking: "[Reasoning redacted]", + thinkingSignature: "opaque_1", + redacted: true, + }, + ]), + user("again"), + ), + ); + expect(payload.thinking).toBeUndefined(); + expect(assistantContent(payload)).toEqual([ + { type: "text", text: "[assistant reasoning omitted]" }, + ]); + }); it("preserves signed thinking for an active tool turn when new thinking is disabled", async () => { const { payload: capturedPayload } = await captureSimpleAnthropicPayload( {}, { mode: "raw", thinkingEnabled: false, stopBeforeNetwork: true }, - { - messages: [ - { role: "user", content: "look it up", timestamp: 0 }, - makeAnthropicAssistantMessage( - [ - { - type: "thinking", - thinking: "call lookup", - thinkingSignature: "sig_tool", - }, - { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, - ], - { stopReason: "toolUse" }, - ), - makeTextToolResult("call_1", "lookup", "42", false, 0), - ], - }, + conversation( + user("look it up"), + makeAnthropicAssistantMessage( + [ + thinking("call lookup", "sig_tool"), + { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, + ], + { stopReason: "toolUse" }, + ), + makeTextToolResult("call_1", "lookup", "42", false, 0), + ), ); const payload = capturedPayload as { @@ -946,42 +699,6 @@ describe("Anthropic provider", () => { ]); }); - it("does not infer prompt tokens when clamping the output limit", async () => { - const model = makeAnthropicModel({ - id: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - contextWindow: 4_000, - maxTokens: 512, - }); - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - model, - { - apiKey: "test-api-key", - maxTokens: model.maxTokens, - reasoning: "off", - stopBeforeNetwork: true, - }, - { - messages: [ - makeAnthropicAssistantMessage( - [ - { - type: "thinking", - thinking: "private reasoning ".repeat(1_000), - thinkingSignature: "sig_old", - }, - { type: "text", text: "Visible answer." }, - ], - { model: model.id }, - ), - { role: "user", content: "again", timestamp: 0 }, - ], - }, - ); - - expect((capturedPayload as { max_tokens?: number }).max_tokens).toBe(model.maxTokens); - }); - it.each([ { modelMaxTokens: 512, expectedMaxTokens: 512 }, { modelMaxTokens: undefined, expectedMaxTokens: 5_000 }, @@ -997,29 +714,23 @@ describe("Anthropic provider", () => { const { payload: capturedPayload } = await captureSimpleAnthropicPayload( model, { apiKey: "test-api-key", maxTokens: 5_000, reasoning: "off", stopBeforeNetwork: true }, - { - messages: [ - makeAnthropicAssistantMessage( - [ - { - type: "thinking", - thinking: "private reasoning ".repeat(1_000), - thinkingSignature: "sig_old", - }, - { type: "text", text: "Visible answer." }, - ], - { model: model.id }, - ), - { role: "user", content: "again", timestamp: 0 }, - ], - }, + conversation( + makeAnthropicAssistantMessage( + [ + thinking("private reasoning ".repeat(1_000), "sig_old"), + { type: "text", text: "Visible answer." }, + ], + { model: model.id }, + ), + user("again"), + ), ); expect(capturedPayload.max_tokens).toBe(expectedMaxTokens); }, ); - it.each([500, undefined])( + it.each([500])( "restores the caller output cap when thinking cannot fit with model limit %s", async (maxTokens) => { const model = makeAnthropicModel({ @@ -1031,22 +742,16 @@ describe("Anthropic provider", () => { const { payload: capturedPayload } = await captureSimpleAnthropicPayload( model, { apiKey: "test-api-key", maxTokens: 32, reasoning: "low", stopBeforeNetwork: true }, - { - messages: [ - makeAnthropicAssistantMessage( - [ - { - type: "thinking", - thinking: "private reasoning ".repeat(1_000), - thinkingSignature: "sig_tool", - }, - { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, - ], - { model: model.id, stopReason: "toolUse" }, - ), - makeTextToolResult("call_1", "lookup", "42", false, 0), - ], - }, + conversation( + makeAnthropicAssistantMessage( + [ + thinking("private reasoning ".repeat(1_000), "sig_tool"), + { type: "toolCall", id: "call_1", name: "lookup", arguments: {} }, + ], + { model: model.id, stopReason: "toolUse" }, + ), + makeTextToolResult("call_1", "lookup", "42", false, 0), + ), ); expect(capturedPayload as { max_tokens?: number; thinking?: unknown }).toMatchObject({ @@ -1061,39 +766,21 @@ describe("Anthropic provider", () => { const { payload: capturedPayload } = await captureSimpleAnthropicPayload( { input: ["text", "image"] }, { mode: "raw", stopBeforeNetwork: true }, - { - messages: [ - makeAnthropicAssistantMessage( - [{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} }], - { stopReason: "toolUse" }, - ), + toolContext( + [ + { type: "text", text: "before image" }, + { type: "image", data: imageData, mimeType: "image/png" }, { - role: "toolResult", - toolCallId: "call_1", - toolName: "lookup", - content: [ - { type: "text", text: "before image" }, - { type: "image", data: imageData, mimeType: "image/png" }, - { - type: "resource" as const, - resource: { uri: "https://example.com/data.json", text: '{"key":"value"}' }, - }, - { type: "text", text: "after image" }, - ], - isError: false, - timestamp: 0, + type: "resource" as const, + resource: { uri: "https://example.com/data.json", text: '{"key":"value"}' }, }, - ], - } as unknown as Context, + { type: "text", text: "after image" }, + ] as unknown as ToolResultContent, + false, + ), ); - const payload = capturedPayload as { - messages: Array<{ role: string; content: Array> }>; - }; - const userMessage = payload.messages.find((message) => message.role === "user"); - const toolResult = userMessage?.content.find((entry) => entry.type === "tool_result") as { - content: unknown[]; - }; + const toolResult = getToolResult(capturedPayload); expect(toolResult.content).toEqual([ { type: "text", text: "before image" }, @@ -1116,18 +803,12 @@ describe("Anthropic provider", () => { const { payload: capturedPayload, result } = await captureSimpleAnthropicPayload( { input: ["text", "image"] }, { mode: "raw", apiKey: "test-api-key", stopBeforeNetwork: true }, - { - messages: [ - { - role: "user", - content: [ - { type: "text", text: "look" }, - { type: "image", mimeType: "image/heic", data: imageData }, - ], - timestamp: 0, - }, - ], - }, + conversation( + user([ + { type: "text", text: "look" }, + { type: "image", mimeType: "image/heic", data: imageData }, + ]), + ), ); expect(result.stopReason).toBe("error"); @@ -1148,18 +829,12 @@ describe("Anthropic provider", () => { const { payload: capturedPayload, result } = await captureSimpleAnthropicPayload( { input: ["text"] }, { mode: "raw", apiKey: "test-api-key", stopBeforeNetwork: true }, - { - messages: [ - { - role: "user", - content: [ - { type: "text", text: "look" }, - { type: "image", mimeType: "image/heic", data: "not-base64" }, - ], - timestamp: 0, - }, - ], - }, + conversation( + user([ + { type: "text", text: "look" }, + { type: "image", mimeType: "image/heic", data: "not-base64" }, + ]), + ), ); expect(result.stopReason).toBe("error"); @@ -1176,22 +851,7 @@ describe("Anthropic provider", () => { const { payload: capturedPayload, result } = await captureSimpleAnthropicPayload( { input: ["text", "image"] }, { mode: "raw", apiKey: "test-api-key", stopBeforeNetwork: true }, - { - messages: [ - makeAnthropicAssistantMessage( - [{ type: "toolCall", id: "tool_1", name: "screenshot", arguments: {} }], - { stopReason: "toolUse" }, - ), - { - role: "toolResult", - toolCallId: "tool_1", - toolName: "screenshot", - content: [{ type: "image", data: imageData, mimeType: "image/tiff" }], - isError: false, - timestamp: 0, - }, - ], - } as unknown as Context, + toolContext([{ type: "image", data: imageData, mimeType: "image/tiff" }], false), ); expect(result.stopReason).toBe("error"); @@ -1210,146 +870,104 @@ describe("Anthropic provider", () => { const { payload: capturedPayload } = await captureSimpleAnthropicPayload( { input: ["text", "image"] }, { mode: "raw", apiKey: "fixture", stopBeforeNetwork: true }, - { - messages: [ - makeAnthropicAssistantMessage( - [{ type: "toolCall", id: "call_husk", name: "screenshot", arguments: {} }], - { stopReason: "toolUse" }, - ), - { - role: "toolResult", - toolCallId: "call_husk", - toolName: "screenshot", - content: [{ type: "image", data: "", mimeType: "image/png" }], - isError: false, - timestamp: 0, - }, - ], - }, + toolContext([{ type: "image", data: "", mimeType: "image/png" }], false), ); - const payload = capturedPayload as { - messages: Array<{ role: string; content: Array> }>; - }; - const userMessage = payload.messages.find((message) => message.role === "user"); - const toolResult = userMessage?.content.find((entry) => entry.type === "tool_result"); + const toolResult = getToolResult(capturedPayload); expect(toolResult?.content).toBe(""); expect(JSON.stringify(toolResult)).not.toContain('"source"'); expect(JSON.stringify(toolResult)).not.toContain("see attached image"); }); - it.each([ - ["empty", ""], - ["whitespace-only", " \n\t "], - ["invalid-surrogate-only", String.fromCharCode(0xd83d)], - ])("replaces %s error tool results with non-empty content", async (_label, text) => { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - { provider: "github-copilot" }, - { mode: "raw", apiKey: "copilot-token", stopBeforeNetwork: true }, - { - messages: [ - makeAnthropicAssistantMessage( - [{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} }], - { provider: "github-copilot", stopReason: "toolUse" }, - ), + it.each([["invalid-surrogate-only", String.fromCharCode(0xd83d)]])( + "replaces %s error tool results with non-empty content", + async (_label, text) => { + const { payload: capturedPayload } = await captureSimpleAnthropicPayload( + { provider: "github-copilot" }, + { mode: "raw", apiKey: "copilot-token", stopBeforeNetwork: true }, + toolContext([{ type: "text", text }], true, "github-copilot"), + ); + + const payload = capturedPayload as { + messages: Array<{ role: string; content: Array> }>; + }; + const userMessage = payload.messages.find((message) => message.role === "user"); + const toolResult = userMessage?.content.find((entry) => entry.type === "tool_result"); + expect(toolResult).toMatchObject({ + content: "[tool error with no output]", + is_error: true, + }); + }, + ); + + it.each([["claude-mythos-5", "Claude Mythos 5", "anthropic-vertex", "vertex-token"]])( + "surfaces structured %s streaming refusals for %s", + async (id, name, provider) => { + const client = createAnthropicSseClient([ + messageStart({ input_tokens: 3, output_tokens: 0 }), + blockStart(0, { type: "text", text: "" }), + blockDelta(0, { type: "text_delta", text: "discard this partial output" }), + blockStop(0), + messageDelta( { - role: "toolResult", - toolCallId: "call_1", - toolName: "lookup", - content: [{ type: "text", text }], - isError: true, - timestamp: 0, + stop_reason: "refusal", + stop_details: { + type: "refusal", + category: "cyber", + explanation: "This request is not allowed.", + }, }, - ], - }, - ); + { input_tokens: 3, output_tokens: 2 }, + ), + { type: "message_stop" }, + ]); - const payload = capturedPayload as { - messages: Array<{ role: string; content: Array> }>; - }; - const userMessage = payload.messages.find((message) => message.role === "user"); - const toolResult = userMessage?.content.find((entry) => entry.type === "tool_result"); - expect(toolResult).toMatchObject({ - content: "[tool error with no output]", - is_error: true, - }); - }); - - it.each([ - ["claude-fable-5", "Claude Fable 5", "anthropic", "sk-ant-provider"], - ["claude-mythos-5", "Claude Mythos 5", "anthropic", "sk-ant-provider"], - ["claude-mythos-5", "Claude Mythos 5", "anthropic-vertex", "vertex-token"], - ["claude-opus-5", "Claude Opus 5", "anthropic", "sk-ant-provider"], - ["claude-opus-5", "Claude Opus 5", "anthropic-vertex", "vertex-token"], - ["claude-sonnet-5", "Claude Sonnet 5", "anthropic", "sk-ant-provider"], - ["claude-sonnet-5", "Claude Sonnet 5", "anthropic-vertex", "vertex-token"], - ])("surfaces structured %s streaming refusals for %s", async (id, name, provider, apiKey) => { - const client = createAnthropicSseClient([ - { - type: "message_start", - message: { id: "msg_refusal", usage: { input_tokens: 3, output_tokens: 0 } }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "discard this partial output" }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { - stop_reason: "refusal", - stop_details: { - type: "refusal", - category: "cyber", - explanation: "This request is not allowed.", - }, - }, - usage: { input_tokens: 3, output_tokens: 2 }, - }, - { type: "message_stop" }, - ]); - - const stream = streamAnthropic( - makeAnthropicModel({ + const stream = startStream(client, { id, name, provider, - }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey, client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + }); + const eventTypes: string[] = []; + for await (const event of stream) { + eventTypes.push(event.type); + } + const result = await stream.result(); - expect(eventTypes).toEqual(["error"]); - expect(result.stopReason).toBe("error"); - expect(result.content).toEqual([]); - expect(result.errorMessage).toBe( - "Anthropic refusal (category: cyber): This request is not allowed.", - ); - expect(result.usage).toMatchObject({ input: 3, output: 2 }); - expect(result.diagnostics).toEqual([ - expect.objectContaining({ - type: "provider_refusal", - details: { - provider, - category: "cyber", - explanation: "This request is not allowed.", - }, - }), - ]); - }); + expect(eventTypes).toEqual(["error"]); + expect(result.stopReason).toBe("error"); + expect(result.content).toEqual([]); + expect(result.errorMessage).toBe( + "Anthropic refusal (category: cyber): This request is not allowed.", + ); + expect(result.usage).toMatchObject({ input: 3, output: 2 }); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + type: "provider_refusal", + details: { + provider, + category: "cyber", + explanation: "This request is not allowed.", + }, + }), + ]); + }, + ); - it.each(anthropicServerSideFallbackCases)( + it.each([ + { id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5" }, + { + id: "claude-opus-5-5", + name: "Opus 5.5 with model betas", + headers: { "Anthropic-Beta": "files-api-2025-04-14" }, + customBeta: true, + }, + { + id: "claude-opus-5-5", + name: "Opus 5.5 with request betas", + optionHeaders: { "anthropic-beta": "files-api-2025-04-14" }, + customBeta: true, + }, + ])( "sends default server-side fallback params for direct $name API-key requests", async ({ optionHeaders, customBeta, ...model }) => { const { payload: capturedPayload } = await captureSimpleAnthropicPayload(model, { @@ -1369,113 +987,36 @@ describe("Anthropic provider", () => { }, ); - it.each([ - { label: "OAuth tokens", overrides: {}, apiKey: "sk-ant-oat01-token" }, - { - label: "custom proxy endpoints", - overrides: { baseUrl: "https://proxy.example.com/v1" }, - apiKey: "sk-ant-provider", - }, - { - label: "Anthropic Vertex models", - overrides: { provider: "anthropic-vertex" }, - apiKey: "vertex-token", - }, - { - label: "unsupported models", - overrides: { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - apiKey: "sk-ant-provider", - }, - ])("omits server-side fallback params for $label", async ({ overrides, apiKey }) => { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - { id: "claude-fable-5", name: "Claude Fable 5", ...overrides }, - { mode: "raw", apiKey, stopBeforeNetwork: true }, - ); - - expect((capturedPayload as { fallbacks?: unknown }).fallbacks).toBeUndefined(); - }); - it("rebuilds Fable output at a mid-stream server-side fallback boundary", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_fallback", - model: "claude-fable-5", - usage: { input_tokens: 5, output_tokens: 0 }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "thinking", thinking: "" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "thinking_delta", thinking: "pre-boundary reasoning" }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "content_block_start", - index: 1, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 1, - delta: { type: "text_delta", text: "partial " }, - }, - { type: "content_block_stop", index: 1 }, - { - type: "content_block_start", - index: 2, - content_block: { type: "tool_use", id: "call_1", name: "lookup", input: {} }, - }, - { type: "content_block_stop", index: 2 }, - { - type: "content_block_start", - index: 3, - content_block: { - type: "fallback", - from: { model: "claude-fable-5" }, - to: { model: "claude-opus-4-8" }, - }, - }, - { type: "content_block_stop", index: 3 }, - { - type: "content_block_start", - index: 4, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 4, - delta: { type: "text_delta", text: "continued" }, - }, - { type: "content_block_stop", index: 4 }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { input_tokens: 5, output_tokens: 9 }, - }, + messageStart({ input_tokens: 5, output_tokens: 0 }, "claude-fable-5"), + blockStart(0, { type: "thinking", thinking: "" }), + blockDelta(0, { type: "thinking_delta", thinking: "pre-boundary reasoning" }), + blockStop(0), + blockStart(1, { type: "text", text: "" }), + blockDelta(1, { type: "text_delta", text: "partial " }), + blockStop(1), + blockStart(2, { type: "tool_use", id: "call_1", name: "lookup", input: {} }), + blockStop(2), + blockStart(3, { + type: "fallback", + from: { model: "claude-fable-5" }, + to: { model: "claude-opus-4-8" }, + }), + blockStop(3), + blockStart(4, { type: "text", text: "" }), + blockDelta(4, { type: "text_delta", text: "continued" }), + blockStop(4), + messageDelta({ stop_reason: "end_turn" }, { input_tokens: 5, output_tokens: 9 }), { type: "message_stop" }, ]); - const stream = streamAnthropic( - makeAnthropicModel({ - id: "claude-fable-5", - name: "Claude Fable 5", - cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, - }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client, { + id: "claude-fable-5", + name: "Claude Fable 5", + cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + }); + const { eventTypes, result } = await consumeStream(stream); // Pre-boundary thinking/tool blocks must not replay or execute; text is // the continuation prefix the fallback model built on. @@ -1505,109 +1046,20 @@ describe("Anthropic provider", () => { expect(result.usage.cost.total).toBeCloseTo(0.00025, 10); }); - it("records a pre-output server-side fallback and keeps the continuation", async () => { - const client = createAnthropicSseClient([ - { - type: "message_start", - message: { - id: "msg_fallback", - model: "claude-fable-5", - usage: { input_tokens: 5, output_tokens: 0 }, - }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "fallback", - from: { model: "claude-fable-5" }, - to: { model: "claude-opus-4-8" }, - }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "content_block_start", - index: 1, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 1, - delta: { type: "text_delta", text: "Hi!" }, - }, - { type: "content_block_stop", index: 1 }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { input_tokens: 5, output_tokens: 2 }, - }, - { type: "message_stop" }, - ]); - - const stream = streamAnthropic( - makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const result = await stream.result(); - - expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Hi!" }]); - expect(result.responseModel).toBe("claude-opus-4-8"); - expect(result.diagnostics).toEqual([ - expect.objectContaining({ - type: "provider_fallback", - details: { - provider: "anthropic", - fromModel: "claude-fable-5", - toModel: "claude-opus-4-8", - }, - }), - ]); - expect(result.usage.cost.total).toBeCloseTo(0.000075, 10); - }); - it("routes interleaved active content blocks by their event indexes", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { id: "msg_interleaved", usage: { input_tokens: 1, output_tokens: 0 } }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "text" }, - }, - { - type: "content_block_start", - index: 1, - content_block: { type: "text" }, - }, - { - type: "content_block_delta", - index: 1, - delta: { type: "text_delta", text: "second" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "first" }, - }, - { type: "content_block_stop", index: 1 }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { input_tokens: 1, output_tokens: 2 }, - }, + messageStart({ input_tokens: 1, output_tokens: 0 }), + blockStart(0, { type: "text" }), + blockStart(1, { type: "text" }), + blockDelta(1, { type: "text_delta", text: "second" }), + blockDelta(0, { type: "text_delta", text: "first" }), + blockStop(1), + blockStop(0), + messageDelta({ stop_reason: "end_turn" }, { input_tokens: 1, output_tokens: 2 }), { type: "message_stop" }, ]); - const result = await streamAnthropic( - makeAnthropicModel(), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ).result(); + const result = await startStream(client).result(); expect(result.content).toEqual([ { type: "text", text: "first" }, @@ -1617,49 +1069,18 @@ describe("Anthropic provider", () => { it("rejects a malformed later tool before any sibling becomes executable", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { id: "msg_tools", usage: { input_tokens: 1, output_tokens: 0 } }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "tool_use", id: "call_valid", name: "read", input: {} }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "input_json_delta", partial_json: '{"path":"README.md"}' }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "content_block_start", - index: 1, - content_block: { type: "tool_use", id: "call_invalid", name: "read", input: {} }, - }, - { - type: "content_block_delta", - index: 1, - delta: { type: "input_json_delta", partial_json: '{"path":"SECRET.md"' }, - }, - { type: "content_block_stop", index: 1 }, - { - type: "message_delta", - delta: { stop_reason: "tool_use" }, - usage: { input_tokens: 1, output_tokens: 2 }, - }, + messageStart({ input_tokens: 1, output_tokens: 0 }), + blockStart(0, { type: "tool_use", id: "call_valid", name: "read", input: {} }), + blockDelta(0, { type: "input_json_delta", partial_json: '{"path":"README.md"}' }), + blockStop(0), + blockStart(1, { type: "tool_use", id: "call_invalid", name: "read", input: {} }), + blockDelta(1, { type: "input_json_delta", partial_json: '{"path":"SECRET.md"' }), + blockStop(1), + messageDelta({ stop_reason: "tool_use" }, { input_tokens: 1, output_tokens: 2 }), { type: "message_stop" }, ]); - const stream = streamAnthropic( - makeAnthropicModel(), - { messages: [{ role: "user", content: "read", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client); + const { eventTypes, result } = await consumeStream(stream); expect(result.stopReason).toBe("error"); expect(result.errorMessage).toBe("Provider completed tool call with malformed JSON arguments"); @@ -1670,37 +1091,14 @@ describe("Anthropic provider", () => { it("rejects an active tool call that never receives content_block_stop", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { id: "msg_unsealed", usage: { input_tokens: 1, output_tokens: 0 } }, - }, - { - type: "content_block_start", - index: 0, - content_block: { type: "tool_use", id: "call_unsealed", name: "read", input: {} }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "input_json_delta", partial_json: '{"path":"README.md"' }, - }, - { - type: "message_delta", - delta: { stop_reason: "tool_use" }, - usage: { input_tokens: 1, output_tokens: 1 }, - }, + messageStart({ input_tokens: 1, output_tokens: 0 }), + blockStart(0, { type: "tool_use", id: "call_unsealed", name: "read", input: {} }), + blockDelta(0, { type: "input_json_delta", partial_json: '{"path":"README.md"' }), + messageDelta({ stop_reason: "tool_use" }, { input_tokens: 1, output_tokens: 1 }), { type: "message_stop" }, ]); - const stream = streamAnthropic( - makeAnthropicModel(), - { messages: [{ role: "user", content: "read", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client); + const { eventTypes, result } = await consumeStream(stream); expect(result.stopReason).toBe("error"); expect(eventTypes.at(-1)).toBe("error"); @@ -1711,34 +1109,19 @@ describe("Anthropic provider", () => { it("uses a complete tool input seeded at block start when no deltas arrive", async () => { const client = createAnthropicSseClient([ - { - type: "message_start", - message: { id: "msg_seeded", usage: { input_tokens: 1, output_tokens: 0 } }, - }, - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "call_seeded", - name: "read", - input: { path: "README.md" }, - }, - }, - { type: "content_block_stop", index: 0 }, - { - type: "message_delta", - delta: { stop_reason: "tool_use" }, - usage: { input_tokens: 1, output_tokens: 1 }, - }, + messageStart({ input_tokens: 1, output_tokens: 0 }), + blockStart(0, { + type: "tool_use", + id: "call_seeded", + name: "read", + input: { path: "README.md" }, + }), + blockStop(0), + messageDelta({ stop_reason: "tool_use" }, { input_tokens: 1, output_tokens: 1 }), { type: "message_stop" }, ]); - const result = await streamAnthropic( - makeAnthropicModel(), - { messages: [{ role: "user", content: "read", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ).result(); + const result = await startStream(client).result(); expect(result.content).toContainEqual( expect.objectContaining({ type: "toolCall", arguments: { path: "README.md" } }), @@ -1747,27 +1130,11 @@ describe("Anthropic provider", () => { it("discards buffered Fable output when the stream fails before terminal status", async () => { const client = createAnthropicSseClient([ - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: "unsafe partial output" }, - }, + blockStart(0, { type: "text", text: "" }), + blockDelta(0, { type: "text_delta", text: "unsafe partial output" }), ]); - const stream = streamAnthropic( - makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client, { id: "claude-fable-5", name: "Claude Fable 5" }); + const { eventTypes, result } = await consumeStream(stream); expect(eventTypes).toEqual(["error"]); expect(result.stopReason).toBe("error"); @@ -1788,16 +1155,8 @@ describe("Anthropic provider", () => { create: vi.fn(() => ({ asResponse })), }, }; - const stream = streamAnthropic( - makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client, { id: "claude-fable-5", name: "Claude Fable 5" }); + const { eventTypes, result } = await consumeStream(stream); expect(eventTypes).toEqual(["error"]); expect(result.stopReason).toBe("error"); @@ -1832,16 +1191,8 @@ describe("Anthropic provider", () => { }); const asResponse = vi.fn().mockRejectedValue(hostile); const client = { messages: { create: vi.fn(() => ({ asResponse })) } }; - const stream = streamAnthropic( - makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client, { id: "claude-fable-5", name: "Claude Fable 5" }); + const { eventTypes, result } = await consumeStream(stream); expect(eventTypes).toEqual(["error"]); expect(result).toMatchObject({ @@ -1862,103 +1213,35 @@ describe("Anthropic provider", () => { create: vi.fn(() => ({ asResponse })), }, }; - const stream = streamAnthropic( - makeAnthropicModel({ id: "claude-fable-5", name: "Claude Fable 5" }), - { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, - { apiKey: "sk-ant-provider", client: client as never }, - ); - const eventTypes: string[] = []; - for await (const event of stream) { - eventTypes.push(event.type); - } - const result = await stream.result(); + const stream = startStream(client, { id: "claude-fable-5", name: "Claude Fable 5" }); + const { eventTypes, result } = await consumeStream(stream); expect(eventTypes).toEqual(["error"]); expect(result.errorMessage).toBe("529: Overloaded"); }); it("strips Fable thinking when replay targets Anthropic Vertex", async () => { - let capturedPayload: unknown; - const stream = streamAnthropic( - makeAnthropicModel({ - provider: "anthropic-vertex", - id: "claude-opus-4-8", - name: "Claude Opus 4.8", - }), - { - messages: [ - { role: "user", content: "hello", timestamp: 0 }, - { - role: "assistant", - provider: "anthropic", - api: "anthropic-messages", - model: "claude-fable-5", - stopReason: "stop", - timestamp: 0, - content: [ - { - type: "thinking", - thinking: "model-bound thought", - thinkingSignature: "sig_model_bound", - }, - { type: "text", text: "visible answer" }, - ], - }, - { role: "user", content: "continue", timestamp: 0 }, - ], - } as Context, - { - apiKey: "vertex-token", - onPayload: (payload) => { - capturedPayload = payload; - }, - }, - ); - - await stream.result(); - - const payload = capturedPayload as { messages: Array<{ role: string; content: unknown[] }> }; - const assistantMessage = payload.messages.find((message) => message.role === "assistant"); - expect(assistantMessage?.content).toEqual([{ type: "text", text: "visible answer" }]); - expect(JSON.stringify(assistantMessage)).not.toContain("sig_model_bound"); - }); - - it.each([ - { reasoning: "xhigh", expectedEffort: "high" }, - { reasoning: "max", expectedEffort: "max" }, - ] as const)("maps Claude 4.6 $reasoning effort", async ({ reasoning, expectedEffort }) => { const { payload } = await captureSimpleAnthropicPayload( - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - { reasoning }, + { provider: "anthropic-vertex", id: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { mode: "raw", apiKey: "vertex-token" }, + conversation( + user("hello"), + makeAnthropicAssistantMessage( + [ + thinking("model-bound thought", "sig_model_bound"), + { type: "text", text: "visible answer" }, + ], + { model: "claude-fable-5" }, + ), + user("continue"), + ), ); - expect(payload.output_config).toEqual({ effort: expectedEffort }); + const assistant = wireMessages(payload).find((message) => message.role === "assistant"); + expect(assistant?.content).toEqual([{ type: "text", text: "visible answer" }]); + expect(JSON.stringify(assistant)).not.toContain("sig_model_bound"); }); - it.each([ - { - id: "claude-opus-4.6-1m", - reasoning: "xhigh", - thinkingLevelMap: { xhigh: null, max: null }, - expectedEffort: "high", - }, - { - id: "claude-opus-4.7-1m-internal", - reasoning: "max", - thinkingLevelMap: { xhigh: "xhigh" }, - expectedEffort: "xhigh", - }, - ] as const)( - "honors proxy effort restrictions for $id", - async ({ id, reasoning, thinkingLevelMap, expectedEffort }) => { - const { payload } = await captureSimpleAnthropicPayload( - { id, provider: "github-copilot", thinkingLevelMap }, - { apiKey: "copilot-token", reasoning }, - ); - expect(payload.output_config).toEqual({ effort: expectedEffort }); - }, - ); - - it.each([undefined, "low", "medium", "high", "xhigh", "max"] as const)( + it.each([undefined] as const)( "sends pooled Fable %s effort and preserves its routed model id", async (reasoning) => { const id = "Claude Gateway/claude-fable-5-1"; @@ -1972,10 +1255,9 @@ describe("Anthropic provider", () => { }, ); - const adaptiveThinkingCases: AnthropicAdaptiveThinkingTestCase[] = [ - { - name: "uses the Claude Opus 5 adaptive-thinking request contract", - model: { + it("normalizes adaptive requests and post-hook sampling for canonical deployment aliases", async () => { + const { payload } = await captureSimpleAnthropicPayload( + { id: "prod-opus", name: "Production Claude", provider: "microsoft-foundry", @@ -1984,134 +1266,40 @@ describe("Anthropic provider", () => { baseUrl: "https://example.services.ai.azure.com/anthropic", maxTokens: 128_000, }, - options: { temperature: 0.2, injectPayload: { service_tier: "auto", top_p: 0.9, top_k: 40 } }, - context: { - messages: [ - { role: "user", content: "hello", timestamp: 0 }, - { role: "assistant", content: [{ type: "text", text: "prefill" }], timestamp: 0 }, - ], - } as unknown as Context, - expected: { - messages: [{ role: "user" }], - thinking: { type: "adaptive", display: "summarized" }, - output_config: { effort: "high" }, + { + temperature: 0.2, + injectPayload: { temperature: 0.2, service_tier: "auto", top_p: 0.9, top_k: 40 }, }, - absent: ["temperature", "top_p", "top_k", "service_tier"], - }, - { - name: "uses always-on adaptive thinking for Claude Fable 5", - model: { - id: "prod-primary", - name: "Production Claude", - provider: "microsoft-foundry", - params: { canonicalModelId: "claude-fable-5" }, - reasoning: false, - baseUrl: "https://example.services.ai.azure.com/anthropic", - }, - options: { temperature: 0.2 }, - expected: { - thinking: { type: "adaptive", display: "summarized" }, - output_config: { effort: "medium" }, - }, - absent: ["temperature"], - }, - { - name: "uses mandatory adaptive thinking and default sampling for Claude Mythos 5", - model: { - id: "prod-mythos", - name: "Production Claude", - provider: "microsoft-foundry", - params: { canonicalModelId: "claude-mythos-5" }, - reasoning: false, - baseUrl: "https://example.services.ai.azure.com/anthropic", - maxTokens: 128_000, - }, - options: { reasoning: "off", temperature: 0.2, injectPayload: { top_p: 0.9, top_k: 40 } }, - expected: { - thinking: { type: "adaptive", display: "summarized" }, - output_config: { effort: "low" }, - }, - absent: ["temperature", "top_p", "top_k"], - }, - { - name: "preserves native max effort for Claude Mythos Preview", - model: { - id: "claude-mythos-preview", - name: "Claude Mythos Preview", - reasoning: true, - maxTokens: 128_000, - thinkingLevelMap: { max: "max" }, - }, - options: { reasoning: "max", stopBeforeNetwork: true }, - expected: { output_config: { effort: "max" } }, - }, - { - name: "uses mandatory adaptive thinking for Foundry Mythos Preview", - model: { - id: "prod-mythos-preview", - name: "Production Claude", - provider: "microsoft-foundry", - params: { canonicalModelId: "claude-mythos-preview" }, - reasoning: false, - }, - options: { stopBeforeNetwork: true }, - expected: { thinking: { type: "adaptive" }, output_config: { effort: "high" } }, - }, - { - name: "uses adaptive high effort for Foundry Mythos Preview without native max metadata", - model: { - id: "prod-mythos-preview", - name: "Production Claude", - provider: "microsoft-foundry", - params: { canonicalModelId: "claude-mythos-preview" }, - reasoning: true, - }, - options: { reasoning: "max", stopBeforeNetwork: true }, - expected: { thinking: { type: "adaptive" }, output_config: { effort: "high" } }, - }, - { - name: "does not infer adaptive thinking from forward-compatible effort maps", - model: { + conversation( + user("hello"), + makeAnthropicAssistantMessage([{ type: "text", text: "prefill" }]), + ), + ); + expect(payload).toMatchObject({ + messages: [{ role: "user" }], + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "high" }, + }); + for (const key of ["temperature", "top_p", "top_k", "service_tier"]) { + expect(payload).not.toHaveProperty(key); + } + }); + it("does not infer adaptive thinking from forward-compatible effort maps", async () => { + const { payload } = await captureSimpleAnthropicPayload( + { id: "claude-future", name: "Future Claude", provider: "github-copilot", reasoning: true, thinkingLevelMap: { xhigh: null, max: "max" }, }, - options: { apiKey: "copilot-token", reasoning: "max", stopBeforeNetwork: true }, - expected: { thinking: { type: "enabled" } }, - absent: ["output_config"], - }, - { - name: "resolves thinking as disabled when the legacy budget collapses below 1024", - model: { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", reasoning: true, maxTokens: 1024 }, - options: { reasoning: "minimal", stopBeforeNetwork: true }, - expected: { thinking: { type: "disabled" } }, - }, - { - name: "resolves thinking as disabled when the legacy budget is positive but sub-minimum", - model: { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", reasoning: true, maxTokens: 1500 }, - options: { reasoning: "low", stopBeforeNetwork: true }, - expected: { thinking: { type: "disabled" } }, - }, - ]; - - it.each(adaptiveThinkingCases)("$name", async (testCase) => { - const { payload } = await captureSimpleAnthropicPayload( - testCase.model, - testCase.options, - testCase.context, + { apiKey: "copilot-token", reasoning: "max", stopBeforeNetwork: true }, ); - expect(payload).toMatchObject(testCase.expected); - for (const property of testCase.absent ?? []) { - expect(payload).not.toHaveProperty(property); - } + expect(payload).toMatchObject({ thinking: { type: "enabled" } }); + expect(payload).not.toHaveProperty("output_config"); }); - it.each([ - { budgetTokens: 512, maxTokens: 8192 }, - { budgetTokens: 1024, maxTokens: 1024 }, - ])( + it.each([{ budgetTokens: 512, maxTokens: 8192 }])( "normalizes raw manual thinking budget $budgetTokens below max $maxTokens", async ({ budgetTokens, maxTokens }) => { const model = makeAnthropicModel({ @@ -2131,7 +1319,7 @@ describe("Anthropic provider", () => { stopBeforeNetwork: true, }, { - messages: [{ role: "user", content: "hello", timestamp: 0 }], + messages: [user("hello")], tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }], }, ); @@ -2144,127 +1332,9 @@ describe("Anthropic provider", () => { }, ); - it.each(["claude-opus-5", "claude-opus-4-8", "claude-mythos-preview"])( - "restores default sampling for %s after payload hooks", - async (modelId) => { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - { - id: modelId, - name: modelId, - maxTokens: 128_000, - }, - { - reasoning: "high", - temperature: 0.2, - injectPayload: { temperature: 0.2, top_p: 0.9, top_k: 40 }, - }, - ); - - expect(capturedPayload).not.toHaveProperty("temperature"); - expect(capturedPayload).not.toHaveProperty("top_p"); - expect(capturedPayload).not.toHaveProperty("top_k"); - }, - ); - - it.each([ - { - id: "prod-primary", - name: "Claude Fable 5", - params: undefined, - }, - ])("does not infer the Fable contract from noncanonical metadata", async (overrides) => { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - { ...overrides, reasoning: false }, - { temperature: 0.2 }, - ); - - expect(capturedPayload).toMatchObject({ temperature: 0.2 }); - expect(capturedPayload).not.toHaveProperty("thinking"); - }); - - it("uses canonical Claude policy for deployment aliases", async () => { - const { payload } = await captureSimpleAnthropicPayload( - { - id: "production-claude", - name: "Production Claude", - params: { canonicalModelId: "claude-opus-4-8" }, - reasoning: false, - thinkingLevelMap: { xhigh: "xhigh", max: "max" }, - }, - { reasoning: "xhigh", temperature: 0.2 }, - ); - expect(payload).toMatchObject({ - model: "production-claude", - thinking: { type: "adaptive" }, - output_config: { effort: "xhigh" }, - }); - expect(payload).not.toHaveProperty("temperature"); - }); - - it.each([ - { canonicalModelId: "claude-opus-5", expectedTemperature: undefined }, - { canonicalModelId: "claude-opus-4-8", expectedTemperature: undefined }, - { canonicalModelId: "claude-opus-4-6", expectedTemperature: 0.2 }, - ] as const)( - "normalizes temperature for canonical $canonicalModelId aliases when thinking is off", - async ({ canonicalModelId, expectedTemperature }) => { - const { payload } = await captureSimpleAnthropicPayload( - { - id: "production-claude", - params: { canonicalModelId }, - reasoning: false, - thinkingLevelMap: { xhigh: "xhigh", max: "max" }, - }, - { temperature: 0.2 }, - ); - expect(payload.temperature).toBe(expectedTemperature); - }, - ); - - it("normalizes forced Fable tool choice to auto", async () => { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload( - { id: "claude-fable-5", name: "Claude Fable 5" }, - { - mode: "raw", - thinkingEnabled: true, - effort: "high", - toolChoice: "any", - }, - { messages: [{ role: "user", content: "Use a tool.", timestamp: 0 }] }, - ); - - expect(capturedPayload).toMatchObject({ - thinking: { type: "adaptive", display: "summarized" }, - tool_choice: { type: "auto" }, - }); - }); - - it("preserves Claude Fable 5 high effort when catalog reasoning is false", async () => { - const model = makeAnthropicModel({ - id: "claude-fable-5", - name: "Claude Fable 5", - reasoning: false, - }); - for (const testCase of [ - { reasoning: "off", effort: "low" }, - { reasoning: "high", effort: "high" }, - { reasoning: "xhigh", effort: "xhigh" }, - ] as const) { - const { payload: capturedPayload } = await captureSimpleAnthropicPayload(model, { - reasoning: testCase.reasoning, - }); - - expect(capturedPayload).toMatchObject({ - thinking: { type: "adaptive", display: "summarized" }, - output_config: { effort: testCase.effort }, - }); - } - }); - it.each([ { reasoning: "xhigh", thinkingLevelMap: { xhigh: null, max: null }, effort: "high" }, { reasoning: undefined, thinkingLevelMap: { medium: null }, effort: "high" }, - { reasoning: undefined, thinkingLevelMap: { medium: "low" }, effort: "low" }, ] as const)("honors provider effort restrictions for Claude Fable 5: %j", async (testCase) => { const { payload } = await captureSimpleAnthropicPayload( { @@ -2282,17 +1352,6 @@ describe("Anthropic provider", () => { }); }); - it("uses the Claude Fable 5 contract on Anthropic Vertex", async () => { - const { payload } = await captureSimpleAnthropicPayload( - { id: "claude-fable-5", name: "Claude Fable 5", provider: "anthropic-vertex" }, - { apiKey: "vertex-token", reasoning: "high" }, - ); - expect(payload).toMatchObject({ - thinking: { type: "adaptive", display: "summarized" }, - output_config: { effort: "high" }, - }); - }); - it("forwards simple stop sequences to Anthropic stop_sequences", async () => { const { payload, result } = await captureSimpleAnthropicPayload( {}, @@ -2314,7 +1373,7 @@ describe("Anthropic provider", () => { {}, { mode: "raw", stopBeforeNetwork: true }, { - messages: [{ role: "user", content: "hello", timestamp: 0 }], + messages: [user("hello")], tools: [ unreadableTool, { @@ -2368,7 +1427,7 @@ describe("Anthropic provider", () => { const stream = streamAnthropic( makeAnthropicModel(), { - messages: [{ role: "user", content: "hello", timestamp: 0 }], + messages: [user("hello")], tools: [ unreadableTool, { @@ -2413,7 +1472,7 @@ describe("Anthropic provider", () => { { stopBeforeNetwork: true }, { systemPrompt: "stable system", - messages: [{ role: "user", content: "hello", timestamp: 0 }], + messages: [user("hello")], tools: orderedTools, }, ); @@ -2434,30 +1493,6 @@ describe("Anthropic provider", () => { expect(first[0]).not.toHaveProperty("cache_control"); }); - it("splits the system prompt cache boundary into cached and uncached Anthropic blocks", async () => { - const { payload: capturedPayload, result } = await captureSimpleAnthropicPayload( - {}, - { stopBeforeNetwork: true }, - { - systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`, - messages: [{ role: "user", content: "hello", timestamp: 0 }], - }, - ); - - expect(result.stopReason).toBe("error"); - expect((capturedPayload as { system?: unknown }).system).toEqual([ - { - type: "text", - text: "Stable prefix", - cache_control: { type: "ephemeral" }, - }, - { - type: "text", - text: "Dynamic suffix", - }, - ]); - }); - it("keeps the relocatable marker out of native Anthropic system blocks", async () => { // Native Anthropic relocates nothing, so the marker must not survive into // the payload while the cache breakpoint still lands on the stable prefix. @@ -2466,7 +1501,7 @@ describe("Anthropic provider", () => { { stopBeforeNetwork: true }, { systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Reactions guidance${SYSTEM_PROMPT_RELOCATABLE_BOUNDARY}Runtime: session=alpha${SYSTEM_PROMPT_RELOCATABLE_BOUNDARY_END}`, - messages: [{ role: "user", content: "hello", timestamp: 0 }], + messages: [user("hello")], }, ); @@ -2495,7 +1530,7 @@ describe("Anthropic provider", () => { { systemPrompt: "system", messages: [ - { role: "user", content: "stable question", timestamp: 0 }, + user("stable question"), { role: "user", content: "transient current-turn metadata", @@ -2544,11 +1579,7 @@ describe("Anthropic provider", () => { }, }; - const stream = streamAnthropic( - makeAnthropicModel(), - { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, - { apiKey: "sk-ant-key", client: client as never }, - ); + const stream = startStream(client, {}, conversation(user("hi"))); const eventTypes: string[] = []; for await (const event of stream as AsyncIterable<{ type: string }>) { @@ -2561,84 +1592,16 @@ describe("Anthropic provider", () => { }); it("strips the internal cache boundary when Anthropic cache control is disabled", async () => { - let capturedPayload: unknown; - const stream = streamSimpleAnthropic( - makeAnthropicModel(), + const { payload, result } = await captureSimpleAnthropicPayload( + {}, + { cacheRetention: "none", stopBeforeNetwork: true }, { systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`, - messages: [{ role: "user", content: "hello", timestamp: 0 }], - }, - { - apiKey: "sk-ant-provider", - cacheRetention: "none", - onPayload: (payload) => { - capturedPayload = payload; - throw new Error("stop before network"); - }, + messages: [user("hello")], }, ); - - const result = await stream.result(); - expect(result.stopReason).toBe("error"); - expect((capturedPayload as { system?: unknown }).system).toEqual([ - { - type: "text", - text: "Stable prefix\nDynamic suffix", - }, - ]); - }); - - it.each([ - { - name: "defaults to adaptive high", - reasoning: undefined, - thinking: { type: "adaptive", display: "summarized" }, - effort: { effort: "high" }, - toolChoice: { type: "auto" }, - }, - { - name: "allows explicit off", - reasoning: "off" as const, - thinking: { type: "disabled" }, - effort: undefined, - toolChoice: { type: "any" }, - }, - ])("supports Claude Sonnet 5: $name", async ({ reasoning, thinking, effort, toolChoice }) => { - let capturedPayload: Record | undefined; - const stream = streamSimpleAnthropic( - makeAnthropicModel({ - id: "claude-sonnet-5", - name: "Claude Sonnet 5", - maxTokens: 128_000, - }), - makeSonnet5PrefillContext(), - { - apiKey: "sk-ant-provider", - reasoning, - temperature: 0.2, - toolChoice: "any", - onPayload: (payload) => { - capturedPayload = payload as unknown as Record; - throw new Error("stop before network"); - }, - }, - ); - - await stream.result(); - - expect(capturedPayload).toMatchObject({ - max_tokens: 128_000, - messages: [{ role: "user" }], - thinking, - tool_choice: toolChoice, - }); - expect(capturedPayload).not.toHaveProperty("temperature"); - if (effort) { - expect(capturedPayload).toMatchObject({ output_config: effort }); - } else { - expect(capturedPayload).not.toHaveProperty("output_config"); - } + expect(payload.system).toEqual([{ type: "text", text: "Stable prefix\nDynamic suffix" }]); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/packages/ai/src/providers/openai-responses-shared.test.ts b/packages/ai/src/providers/openai-responses-shared.test.ts index 26d73560b99b..a43960a37c22 100644 --- a/packages/ai/src/providers/openai-responses-shared.test.ts +++ b/packages/ai/src/providers/openai-responses-shared.test.ts @@ -5,10 +5,7 @@ import type { Tool as OpenAIResponsesTool, } from "openai/resources/responses/responses.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - createInterleavedResponsesToolEvents, - createResponsesDoneArgumentEvents, -} from "../../../../test/helpers/openai-responses-events.js"; +import { createResponsesDoneArgumentEvents } from "../../../../test/helpers/openai-responses-events.js"; import { makeTextToolResult } from "../../../../test/helpers/text-tool-result.js"; import { configureAiTransportHost, getAiTransportHost } from "../host.js"; import { @@ -17,8 +14,7 @@ import { } from "../transports/openai-responses-compaction-replay.js"; import { isInvalidEncryptedContentError } from "../transports/openai-responses-replay-internal.js"; import { processResponsesStream } from "../transports/openai-responses-stream-internal.js"; -import type { AssistantMessage, AssistantMessageEvent, Context, Model, Tool } from "../types.js"; -import { createZeroUsage } from "../usage.test-support.js"; +import type { AssistantMessage, AssistantMessageEvent, Context, Model } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; import { resolveOpenAISimpleReasoningEffort } from "./openai-request-reasoning.js"; @@ -31,20 +27,11 @@ import { import { convertResponsesToolPayload } from "./openai-responses-tools.js"; type ResponsesFunctionTool = Extract; -type OpenAIResponsesStreamEvent = - Parameters[0] extends AsyncIterable ? Event : never; - -async function* streamResponsesEvents( - events: readonly T[], -): AsyncGenerator { - for (const event of events) { - yield event; - } +async function* streamResponsesEvents(events: readonly T[]): AsyncGenerator { + yield* events; } -function createNeverYieldingResponsesStream< - T extends OpenAIResponsesStreamEvent = OpenAIResponsesStreamEvent, ->(): AsyncIterable { +function createNeverYieldingResponsesStream(): AsyncIterable { return { [Symbol.asyncIterator]() { return { @@ -105,14 +92,88 @@ const gpt56SolModel = { const testAllowedToolCallProviders = new Set(["openai", "openai-codex", "opencode"]); const reasoningReplayIdentity = { sessionId: "session-a", authProfileId: "profile-a" }; -function createAssistantOutput(): AssistantMessage { - return { ...createResponsesAssistantOutput(nativeOpenAIModel), timestamp: 0 }; +type LifecycleParams = Parameters[0]; +type RequestOptions = Parameters< + ReturnType["responses"]["create"] +>[1]; + +function startLifecycle( + send: ( + attempt: number, + options: RequestOptions, + ) => AsyncIterable | Promise>, + params: Pick & Partial> = {}, +) { + const output = createAssistantOutput(); + const { stream, events } = createCapturedAssistantMessageEventStream(); + const requests: ResponseCreateParamsStreaming[] = []; + const done = runResponsesStreamLifecycle({ + model: nativeOpenAIModel, + output, + stream, + buildParams: () => ({ model: nativeOpenAIModel.id, input: [], stream: true }), + ...params, + createClient: () => ({ + responses: { + create: (request, options) => { + requests.push(structuredClone(request)); + return { + withResponse: async () => ({ + data: await send(requests.length, options), + response: new Response(null, { status: 200 }), + }), + }; + }, + }, + }), + }); + return { output, events, requests, done }; } -async function* responseEvents(events: Array>) { - for (const event of events) { - yield event as never; - } +async function collectResponses(input: readonly unknown[]) { + const output = createAssistantOutput(); + const { stream, events } = createCapturedAssistantMessageEventStream(); + await processResponsesStream(streamResponsesEvents(input), output, stream, nativeOpenAIModel); + return { output, events }; +} + +function argumentsDelta(delta: string, outputIndex?: number, itemId?: string) { + return { + type: "response.function_call_arguments.delta", + delta, + output_index: outputIndex, + item_id: itemId, + }; +} + +function completed(id: string) { + return { type: "response.completed", response: { id, status: "completed" } }; +} + +function itemEvent

( + phase: P, + item: T, + outputIndex?: number, +) { + return { + type: `response.output_item.${phase}` as const, + item, + output_index: outputIndex, + }; +} + +function toolItem(id: string, args = "", name = "computer") { + return { + type: "function_call" as const, + id: `fc_${id}`, + call_id: `call_${id}`, + name, + arguments: args, + }; +} + +function createAssistantOutput(): AssistantMessage { + return { ...createResponsesAssistantOutput(nativeOpenAIModel), timestamp: 0 }; } describe("convertResponsesToolPayload", () => { @@ -186,21 +247,12 @@ describe("convertResponsesToolPayload", () => { }); it("keeps tool order deterministic", () => { - const zeta = { - name: "zeta", - description: "Z", - parameters: {}, - } satisfies Tool; - const alpha = { - name: "alpha", - description: "A", - parameters: {}, - } satisfies Tool; - + const tools = [ + { name: "zeta", description: "Z", parameters: {} }, + { name: "alpha", description: "A", parameters: {} }, + ]; expect( - convertResponsesToolPayload([zeta, alpha]).map( - (tool) => expectResponsesFunctionTool(tool).name, - ), + convertResponsesToolPayload(tools).map((tool) => expectResponsesFunctionTool(tool).name), ).toEqual(["alpha", "zeta"]); }); @@ -269,37 +321,18 @@ describe("Responses temperature support", () => { expect(params).not.toHaveProperty("temperature"); }); - - it("keeps temperature for models that accept it", () => { - const params = {} as never; - applyCommonResponsesParams(params, nativeOpenAIModel, { messages: [] }, { temperature: 0.3 }); - - expect(params).toMatchObject({ temperature: 0.3 }); - }); }); describe("Responses output token limits", () => { - it.each([ - [1, 16], - [15, 16], - [16, 16], - [32, 32], - ])("normalizes maxTokens %i to %i", (maxTokens, expected) => { + it("clamps maxTokens to the Responses minimum", () => { const params = {} as ResponseCreateParamsStreaming; - applyCommonResponsesParams(params, nativeOpenAIModel, { messages: [] }, { maxTokens }); + applyCommonResponsesParams(params, nativeOpenAIModel, { messages: [] }, { maxTokens: 1 }); - expect(params.max_output_tokens).toBe(expected); + expect(params.max_output_tokens).toBe(16); }); }); describe("Responses reasoning effort", () => { - it("omits unsupported default-off reasoning for GPT-5.6 Sol", () => { - const params = {} as never; - applyCommonResponsesParams(params, gpt56SolModel, { messages: [] }); - - expect(params).not.toHaveProperty("reasoning"); - }); - it("passes max through for GPT-5.6 Sol", () => { expect(resolveOpenAISimpleReasoningEffort(gpt56SolModel, "max")).toBe("max"); @@ -341,67 +374,25 @@ describe("Responses reasoning effort", () => { expect(params.reasoning).toEqual({ effort: expected, summary: "auto" }); }, ); - - it("keeps max clamped to xhigh for earlier models", () => { - const gpt55WithXHigh = { - ...nativeOpenAIModel, - thinkingLevelMap: { xhigh: "xhigh" }, - } satisfies Model<"openai-responses">; - - expect(resolveOpenAISimpleReasoningEffort(gpt55WithXHigh, "max")).toBe("xhigh"); - }); }); describe("convertResponsesMessages", () => { - const allowedToolCallProviders = testAllowedToolCallProviders; - - it("adds explicit message item types for system and user input items", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, - { - systemPrompt: "system", - messages: [{ role: "user", content: "hello", timestamp: 1 }], - } satisfies Context, - allowedToolCallProviders, - ); - - expect(input[0]).toMatchObject({ - type: "message", - role: "developer", - content: [{ type: "input_text", text: "system" }], - }); - expect(input[1]).toMatchObject({ - type: "message", - role: "user", - content: [{ type: "input_text", text: "hello" }], - }); - }); - - it("uses the system role when a Responses-compatible provider opts out of developer", () => { - const compatModel = { - ...nativeOpenAIModel, - compat: { supportsDeveloperRole: false }, - } satisfies Model<"openai-responses">; - - const input = convertResponsesMessages( - compatModel, - { systemPrompt: "system", messages: [] }, - allowedToolCallProviders, - ); - - expect(input[0]).toMatchObject({ type: "message", role: "system" }); - }); + function replay( + context: Context, + options?: Parameters[3], + model: Model = nativeOpenAIModel, + ) { + return convertResponsesMessages(model, context, testAllowedToolCallProviders, options); + } + function assistant(content: AssistantMessage["content"]): AssistantMessage { + return { ...createAssistantOutput(), content }; + } it("strips the internal cache boundary marker from the system prompt message", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, - { - systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`, - messages: [], - } satisfies Context, - allowedToolCallProviders, - ); - + const input = replay({ + systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`, + messages: [], + }); expect(input[0]).toMatchObject({ type: "message", role: "developer", @@ -411,118 +402,31 @@ describe("convertResponsesMessages", () => { }); it("omits phase-tagged assistant replay ids without reasoning", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, + const input = replay( { - systemPrompt: "system", messages: [ - { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), - stopReason: "stop", - timestamp: 1, - content: [ - { - type: "text", - text: "Working...", - textSignature: JSON.stringify({ - v: 1, - id: "msg_commentary", - phase: "commentary", - }), - }, - ], - }, + assistant([ + { + type: "text", + text: "Working...", + textSignature: JSON.stringify({ v: 1, id: "msg_commentary", phase: "commentary" }), + }, + ]), ], - } satisfies Context, - allowedToolCallProviders, + }, { includeSystemPrompt: false }, ); - - expect( - input.find( - (item) => - item && - typeof item === "object" && - "role" in item && - item.role === "assistant" && - "phase" in item && - item.phase === "commentary", - ), - ).toMatchObject({ - phase: "commentary", - }); - expect( - input.find( - (item) => - item && - typeof item === "object" && - "role" in item && - item.role === "assistant" && - "phase" in item && - item.phase === "commentary", - ), - ).not.toHaveProperty("id"); - }); - - it("omits raw signed assistant ids when the paired reasoning item is absent", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, - { - systemPrompt: "system", - messages: [ - { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), - stopReason: "stop", - timestamp: 1, - content: [ - { - type: "text", - text: "Earlier answer", - textSignature: "msg_real_response_item_requiring_reasoning", - }, - ], - }, - ], - } satisfies Context, - allowedToolCallProviders, - { includeSystemPrompt: false }, - ); - - expect( - input.find( - (item) => - item && - typeof item === "object" && - "role" in item && - item.role === "assistant" && - "content" in item, - ), - ).not.toHaveProperty("id"); + const message = input.find((item) => item.type === "message" && item.role === "assistant"); + expect(message).toMatchObject({ phase: "commentary" }); + expect(message).not.toHaveProperty("id"); }); it("omits Responses replay item ids when requested by store-disabled callers", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, + const input = replay( { - systemPrompt: "system", messages: [ { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), - stopReason: "toolUse", - timestamp: 1, - content: [ + ...assistant([ { type: "thinking", thinking: "Need a tool.", @@ -547,15 +451,14 @@ describe("convertResponsesMessages", () => { name: "price_lookup", arguments: { symbol: "SOL" }, }, - ], + ]), + stopReason: "toolUse", }, makeTextToolResult("call_abc|fc_prior", "price_lookup", "$83.95", false, 2), ], - } satisfies Context, - allowedToolCallProviders, + }, { includeSystemPrompt: false, replayResponsesItemIds: false }, - ) as unknown as Array>; - + ); const reasoningItem = input.find((item) => item.type === "reasoning"); expect(reasoningItem).toMatchObject({ type: "reasoning", @@ -563,80 +466,23 @@ describe("convertResponsesMessages", () => { summary: [], }); expect(reasoningItem).not.toHaveProperty("id"); - - const assistantMessage = input.find( - (item) => item.type === "message" && item.role === "assistant", - ); - expect(assistantMessage).toMatchObject({ - type: "message", - role: "assistant", - phase: "commentary", - }); - expect(assistantMessage).not.toHaveProperty("id"); - - const functionCall = input.find((item) => item.type === "function_call"); - expect(functionCall).toMatchObject({ - type: "function_call", - call_id: "call_abc", - }); - expect(functionCall).not.toHaveProperty("id"); - }); - - it("replays update_plan-style empty non-image tool results as no output", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, - { - systemPrompt: "system", - messages: [ - { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), - stopReason: "toolUse", - timestamp: 1, - content: [{ type: "toolCall", id: "call_plan", name: "update_plan", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_plan", - toolName: "update_plan", - content: [], - isError: false, - timestamp: 2, - }, - ], - } satisfies Context, - allowedToolCallProviders, - { includeSystemPrompt: false }, - ) as unknown as Array>; - - const functionOutput = input.find((item) => item.type === "function_call_output"); - expect(functionOutput).toMatchObject({ - type: "function_call_output", - call_id: "call_plan", - output: "(no output)", - }); + const message = input.find((item) => item.type === "message" && item.role === "assistant"); + expect(message).toMatchObject({ type: "message", role: "assistant", phase: "commentary" }); + expect(message).not.toHaveProperty("id"); + const call = input.find((item) => item.type === "function_call"); + expect(call).toMatchObject({ type: "function_call", call_id: "call_abc" }); + expect(call).not.toHaveProperty("id"); }); it("preserves image-bearing tool results instead of using no-output text", () => { - const input = convertResponsesMessages( - { ...nativeOpenAIModel, input: ["text", "image"] }, + const input = replay( { - systemPrompt: "system", messages: [ { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), - stopReason: "toolUse", - timestamp: 1, - content: [ + ...assistant([ { type: "toolCall", id: "call_screenshot", name: "screenshot", arguments: {} }, - ], + ]), + stopReason: "toolUse", }, { role: "toolResult", @@ -647,36 +493,23 @@ describe("convertResponsesMessages", () => { timestamp: 2, }, ], - } satisfies Context, - allowedToolCallProviders, - { includeSystemPrompt: false }, - ) as unknown as Array<{ type?: string; output?: unknown }>; - - const functionOutput = input.find((item) => item.type === "function_call_output"); - expect(functionOutput?.output).toEqual([ - { - type: "input_image", - detail: "auto", - image_url: "data:image/png;base64,aW1n", }, + { includeSystemPrompt: false }, + { ...nativeOpenAIModel, input: ["text", "image"] }, + ); + const result = input.find((item) => item.type === "function_call_output"); + expect(result?.output).toEqual([ + { type: "input_image", detail: "auto", image_url: "data:image/png;base64,aW1n" }, ]); }); it("uses audio placeholder for audio-only tool results instead of image or no-output text", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, + const input = replay( { - systemPrompt: "system", messages: [ { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), + ...assistant([{ type: "toolCall", id: "call_audio", name: "audio", arguments: {} }]), stopReason: "toolUse", - timestamp: 1, - content: [{ type: "toolCall", id: "call_audio", name: "audio", arguments: {} }], }, { role: "toolResult", @@ -688,25 +521,21 @@ describe("convertResponsesMessages", () => { }, ], } as unknown as Context, - allowedToolCallProviders, { includeSystemPrompt: false }, - ) as unknown as Array>; - - const functionOutput = input.find((item) => item.type === "function_call_output"); - expect(functionOutput).toMatchObject({ + ); + const result = input.find((item) => item.type === "function_call_output"); + expect(result).toMatchObject({ type: "function_call_output", call_id: "call_audio", output: "(see attached audio)", }); - expect(functionOutput?.output).not.toBe("(see attached image)"); - expect(functionOutput?.output).not.toBe("(no output)"); + expect(result?.output).not.toBe("(see attached image)"); + expect(result?.output).not.toBe("(no output)"); }); it("does not emit image parts or placeholders for payload-less tool media", () => { - const input = convertResponsesMessages( - { ...nativeOpenAIModel, input: ["text", "image"] }, + const input = replay( { - systemPrompt: "system", messages: [ { role: "toolResult", @@ -717,143 +546,79 @@ describe("convertResponsesMessages", () => { timestamp: 2, }, ], - } as unknown as Context, - allowedToolCallProviders, + }, { includeSystemPrompt: false }, - ) as unknown as Array>; - - const functionOutput = input.find((item) => item.type === "function_call_output"); - expect(functionOutput?.output).toBe("(no output)"); - expect(JSON.stringify(functionOutput)).not.toContain("input_image"); - expect(JSON.stringify(functionOutput)).not.toContain("see attached image"); + { ...nativeOpenAIModel, input: ["text", "image"] }, + ); + const result = input.find((item) => item.type === "function_call_output"); + expect(result?.output).toBe("(no output)"); + expect(JSON.stringify(result)).not.toContain("input_image"); + expect(JSON.stringify(result)).not.toContain("see attached image"); }); - it("keeps encrypted reasoning replay item ids when requested", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, - { - systemPrompt: "system", - messages: [ - { - role: "assistant", - api: nativeOpenAIModel.api, - provider: nativeOpenAIModel.provider, - model: nativeOpenAIModel.id, - usage: createZeroUsage(), - stopReason: "stop", - timestamp: 1, - content: [ - { - type: "thinking", - thinking: "Need continuity.", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_foundry_prior", - encrypted_content: "ciphertext", - }), - }, - ], - }, - ], - } satisfies Context, - allowedToolCallProviders, - { includeSystemPrompt: false, replayResponsesItemIds: true }, - ) as unknown as Array>; - - expect(input.find((item) => item.type === "reasoning")).toMatchObject({ - type: "reasoning", - id: "rs_foundry_prior", - encrypted_content: "ciphertext", - summary: [], - }); - }); - - const sameRouteReplayMetadata = buildOpenAIResponsesReasoningReplayMetadata( + const sameRoute = buildOpenAIResponsesReasoningReplayMetadata( nativeOpenAIModel, reasoningReplayIdentity, ); - const sessionMismatchReplayMetadata = buildOpenAIResponsesReasoningReplayMetadata( - nativeOpenAIModel, - { ...reasoningReplayIdentity, sessionId: "session-b" }, - ); - const authMismatchReplayMetadata = buildOpenAIResponsesReasoningReplayMetadata( - nativeOpenAIModel, - { ...reasoningReplayIdentity, authProfileId: "profile-b" }, - ); - const endpointMismatchReplayMetadata = buildOpenAIResponsesReasoningReplayMetadata( - { ...nativeOpenAIModel, baseUrl: "https://proxy.example.com/v1" }, - reasoningReplayIdentity, - ); - + const otherSession = buildOpenAIResponsesReasoningReplayMetadata(nativeOpenAIModel, { + ...reasoningReplayIdentity, + sessionId: "session-b", + }); it.each([ - ["matching block metadata", sameRouteReplayMetadata, sessionMismatchReplayMetadata, true], - ["mismatched block metadata", sessionMismatchReplayMetadata, sameRouteReplayMetadata, false], - ["auth-mismatched block metadata", authMismatchReplayMetadata, undefined, false], - ["endpoint-mismatched block metadata", endpointMismatchReplayMetadata, undefined, false], - ["malformed block metadata", null, sameRouteReplayMetadata, false], - ["matching embedded metadata", undefined, sameRouteReplayMetadata, true], - ["mismatched embedded metadata", undefined, sessionMismatchReplayMetadata, false], - ["malformed embedded metadata", undefined, null, false], + ["matching block metadata", sameRoute, otherSession, true], + ["mismatched block metadata", otherSession, sameRoute, false], + ["malformed block metadata", null, sameRoute, false], + ["mismatched embedded metadata", undefined, otherSession, false], ])( "fences encrypted reasoning with %s", (_name, blockMetadata, embeddedMetadata, preservesCiphertext) => { - const input = convertResponsesMessages( - nativeOpenAIModel, + const input = replay( { messages: [ - { - ...createAssistantOutput(), - content: [ - { - type: "thinking", - thinking: "Safe visible reasoning.", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_route_fenced", - summary: [{ type: "summary_text", text: "safe summary" }], - content: [{ type: "reasoning_text", text: "safe content" }], - encrypted_content: "route-bound-ciphertext", - ...(embeddedMetadata !== undefined - ? { __openclaw_replay: embeddedMetadata } - : {}), - }), - ...(blockMetadata !== undefined - ? { openclawReasoningReplay: blockMetadata } + assistant([ + { + type: "thinking", + thinking: "Safe visible reasoning.", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id: "rs_route_fenced", + summary: [{ type: "summary_text", text: "safe summary" }], + content: [{ type: "reasoning_text", text: "safe content" }], + encrypted_content: "route-bound-ciphertext", + ...(embeddedMetadata !== undefined + ? { __openclaw_replay: embeddedMetadata } : {}), - }, - ] as unknown as AssistantMessage["content"], - }, + }), + ...(blockMetadata !== undefined ? { openclawReasoningReplay: blockMetadata } : {}), + }, + ] as unknown as AssistantMessage["content"]), ], }, - allowedToolCallProviders, { includeSystemPrompt: false, replayResponsesItemIds: true, ...reasoningReplayIdentity, }, - ) as unknown as Array>; - - const reasoningItem = input.find((item) => item.type === "reasoning"); + ); + const item = input.find((entry) => entry.type === "reasoning"); if (!preservesCiphertext) { - expect(reasoningItem).toBeUndefined(); + expect(item).toBeUndefined(); return; } - expect(reasoningItem).toMatchObject({ + expect(item).toMatchObject({ type: "reasoning", id: "rs_route_fenced", summary: [{ type: "summary_text", text: "safe summary" }], content: [{ type: "reasoning_text", text: "safe content" }], }); - expect(reasoningItem).not.toHaveProperty("__openclaw_replay"); - expect(reasoningItem).toHaveProperty("encrypted_content", "route-bound-ciphertext"); + expect(item).not.toHaveProperty("__openclaw_replay"); + expect(item).toHaveProperty("encrypted_content", "route-bound-ciphertext"); }, ); it("serializes structured tool results as text instead of image placeholders", () => { - const input = convertResponsesMessages( - nativeOpenAIModel, + const input = replay( { - systemPrompt: "system", messages: [ { role: "toolResult", @@ -870,9 +635,8 @@ describe("convertResponsesMessages", () => { }, ], } as unknown as Context, - testAllowedToolCallProviders, { includeSystemPrompt: false, replayResponsesItemIds: false }, - ) as unknown as Array>; + ); expect(input).toContainEqual({ type: "function_call_output", call_id: "call_structured", @@ -883,76 +647,26 @@ describe("convertResponsesMessages", () => { describe("processResponsesStream", () => { it.each([ - { - message: "400 The encrypted content could not be verified.", - status: undefined, - expected: true, - }, - { - message: "The encrypted content could not be decrypted or parsed.", - status: 400, - expected: true, - }, - { - message: "400 The encrypted content could not be verified.", - status: 500, - expected: false, - }, - { - message: "Could not decrypt the provided encrypted_content.", - status: 400, - expected: true, - }, - { - message: "Could not decrypt the provided encrypted_content.", - status: 500, - expected: false, - }, - { - message: "Could not decrypt encrypted_content metadata for the OAuth sidecar.", - status: 400, - expected: false, - }, - { message: "400 The certificate could not be verified.", status: undefined, expected: false }, - { message: "400 The upload could not be decrypted.", status: undefined, expected: false }, - ])( - "classifies encrypted replay rejection $expected: $message", - ({ message, status, expected }) => { - expect(isInvalidEncryptedContentError({ message, status })).toBe(expected); - }, - ); + ["400 The encrypted content could not be verified.", 500, false], + ["Could not decrypt encrypted_content metadata for the OAuth sidecar.", 400, false], + ])("classifies encrypted replay rejection: %s", (message, status, expected) => { + expect(isInvalidEncryptedContentError({ message, status })).toBe(expected); + }); it("aborts the Responses request signal when the first SSE event never arrives", async () => { vi.useFakeTimers(); try { let requestSignal: AbortSignal | undefined; - const output = createAssistantOutput(); - const stream = new AssistantMessageEventStream(); const onFirstEventTimeout = vi.fn(); - const resultPromise = runResponsesStreamLifecycle({ - stream, - model: nativeOpenAIModel, - output, - options: { firstEventTimeoutMs: 5, onFirstEventTimeout }, - createClient: () => ({ - responses: { - create: (_params, requestOptions) => { - requestSignal = requestOptions.signal; - return { - withResponse: async () => ({ - data: createNeverYieldingResponsesStream(), - response: new Response(null, { status: 200 }), - }), - }; - }, - }, - }), - buildParams: () => ({ model: nativeOpenAIModel.id, input: [], stream: true }), - }); - + const { output, done } = startLifecycle( + (_attempt, options) => { + requestSignal = options.signal; + return createNeverYieldingResponsesStream(); + }, + { options: { firstEventTimeoutMs: 5, onFirstEventTimeout } }, + ); await vi.advanceTimersByTimeAsync(5); - await resultPromise; - + await done; expect(output.stopReason).toBe("error"); expect(requestSignal?.aborted).toBe(true); expect(requestSignal?.reason).toBeInstanceOf(Error); @@ -962,139 +676,78 @@ describe("processResponsesStream", () => { } }); - it("fails when streaming headers arrive but no first SSE event follows", async () => { - vi.useFakeTimers(); - try { - const output = createAssistantOutput(); - const stream = new AssistantMessageEventStream(); - const abortFirstEventStream = vi.fn(); - const onFirstEventTimeout = vi.fn(); - const resultPromise = processResponsesStream( - createNeverYieldingResponsesStream(), - output, - stream, - nativeOpenAIModel, - { firstEventTimeoutMs: 5, abortFirstEventStream, onFirstEventTimeout }, - ); - const rejection = expect(resultPromise).rejects.toThrow( - /responses HTTP stream opened but did not deliver a first SSE event within 5ms/, - ); - - await vi.advanceTimersByTimeAsync(5); - await rejection; - expect(abortFirstEventStream).toHaveBeenCalledTimes(1); - expect(abortFirstEventStream.mock.calls[0]?.[0]).toBeInstanceOf(Error); - expect(onFirstEventTimeout).toHaveBeenCalledWith(abortFirstEventStream.mock.calls[0]?.[0]); - } finally { - vi.useRealTimers(); - } - }); - - it.each([ - "create", - "iterator", - "response.failed", - "response.failed-status", - "error", - "nested-error", - ] as const)( + it.each(["create", "response.failed-status", "error", "nested-error"] as const)( "recovers code-less encrypted reasoning rejected during %s stream drain", async (failureShape) => { const failureMessage = - "400 The encrypted content [REDACTED] could not be verified. " + - "Reason: Encrypted content could not be decrypted or parsed."; - const requests: ResponseCreateParamsStreaming[] = []; - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await runResponsesStreamLifecycle({ - stream, - model: nativeOpenAIModel, - output, - createClient: () => ({ - responses: { - create: (request) => { - requests.push(structuredClone(request)); - const attempt = requests.length; - return { - withResponse: async () => { - if (attempt === 1 && failureShape === "create") { - throw Object.assign(new Error(failureMessage), { status: 400 }); - } - return { - data: (async function* () { - if (attempt === 1) { - yield { - type: "response.created", - sequence_number: 0, - response: { id: "resp_rejected" }, - } as ResponseStreamEvent; - if (failureShape === "iterator") { - throw new Error(failureMessage); - } - yield ( - failureShape === "response.failed" || - failureShape === "response.failed-status" - ? { - type: "response.failed", - sequence_number: 1, - response: { - id: "resp_rejected", - status: "failed", - error: - failureShape === "response.failed-status" - ? { - code: null, - status: 400, - message: "The encrypted content could not be verified.", - } - : { code: null, message: failureMessage }, - }, - } - : failureShape === "nested-error" - ? { - type: "error", - sequence_number: 1, - error: { code: null, message: failureMessage }, - } - : { - type: "error", - sequence_number: 1, - code: null, - message: failureMessage, - param: null, - } - ) as ResponseStreamEvent; - return; - } - yield { - type: "response.completed", + "400 The encrypted content [REDACTED] could not be verified. Reason: Encrypted content could not be decrypted or parsed."; + const { output, requests, events, done } = startLifecycle( + (attempt) => { + if (attempt === 1 && failureShape === "create") { + throw Object.assign(new Error(failureMessage), { status: 400 }); + } + return (async function* () { + if (attempt === 1) { + yield { + type: "response.created", + sequence_number: 0, + response: { id: "resp_rejected" }, + } as ResponseStreamEvent; + yield ( + failureShape === "response.failed-status" + ? { + type: "response.failed", + sequence_number: 1, + response: { + id: "resp_rejected", + status: "failed", + error: { + code: null, + status: 400, + message: "The encrypted content could not be decrypted or parsed.", + }, + }, + } + : failureShape === "nested-error" + ? { + type: "error", sequence_number: 1, - response: { id: "resp_recovered", status: "completed" }, - } as ResponseStreamEvent; - })(), - response: new Response(null, { status: 200 }), - }; - }, - }; - }, - }, - }), - buildParams: () => ({ - model: nativeOpenAIModel.id, - stream: true, - input: [ - { - type: "reasoning", - id: "rs_replay", - encrypted_content: "stale-reasoning", - summary: [], - }, - { type: "compaction", id: "cmp_keep", encrypted_content: "valid-compaction" }, - ], - }), - }); - + error: { code: null, message: failureMessage }, + } + : { + type: "error", + sequence_number: 1, + code: null, + message: "400 Could not decrypt the provided encrypted_content.", + param: null, + } + ) as ResponseStreamEvent; + } else { + yield { + type: "response.completed", + sequence_number: 1, + response: { id: "resp_recovered", status: "completed" }, + } as ResponseStreamEvent; + } + })(); + }, + { + buildParams: () => ({ + model: nativeOpenAIModel.id, + stream: true, + input: [ + { + type: "reasoning", + id: "rs_replay", + encrypted_content: "stale-reasoning", + summary: [], + }, + { type: "compaction", id: "cmp_keep", encrypted_content: "valid-compaction" }, + ], + }), + }, + ); + await done; expect(requests).toHaveLength(2); expect(requests[0]?.input).toEqual( expect.arrayContaining([expect.objectContaining({ encrypted_content: "stale-reasoning" })]), @@ -1113,101 +766,44 @@ describe("processResponsesStream", () => { ); it("never retries an encrypted-looking response hook rejection", async () => { - let requests = 0; - const output = createAssistantOutput(); const onResponse = vi.fn(() => { throw new Error("400 The encrypted content could not be verified."); }); - - await runResponsesStreamLifecycle({ - stream: new AssistantMessageEventStream(), - model: nativeOpenAIModel, - output, + const { output, requests, done } = startLifecycle(() => streamResponsesEvents([]), { options: { onResponse }, - createClient: () => ({ - responses: { - create: () => { - requests += 1; - return { - withResponse: async () => ({ - data: streamResponsesEvents([]), - response: new Response(null, { status: 200 }), - }), - }; - }, - }, - }), buildParams: () => ({ model: nativeOpenAIModel.id, stream: true, input: [{ type: "reasoning", id: "rs_replay", encrypted_content: "stale", summary: [] }], }), }); - - expect(requests).toBe(1); + await done; + expect(requests).toHaveLength(1); expect(onResponse).toHaveBeenCalledOnce(); expect(output.errorMessage).toBe("400 The encrypted content could not be verified."); }); - it.each([ - { name: "thinking", item: { type: "reasoning", id: "rs_started", summary: [] } }, - { - name: "text", - item: { - type: "message", - id: "msg_started", - role: "assistant", - status: "in_progress", - content: [], + it("never retries encrypted replay after tool output starts", async () => { + const { output, requests, events, done } = startLifecycle( + () => + (async function* () { + yield { + type: "response.output_item.added", + output_index: 0, + sequence_number: 0, + item: toolItem("started", "", "lookup"), + } as ResponseStreamEvent; + throw new Error("400 The encrypted content could not be verified."); + })(), + { + buildParams: () => ({ + model: nativeOpenAIModel.id, + stream: true, + input: [{ type: "reasoning", id: "rs_replay", encrypted_content: "stale", summary: [] }], + }), }, - }, - { - name: "tool", - item: { - type: "function_call", - id: "fc_started", - call_id: "call_started", - name: "lookup", - arguments: "", - }, - }, - ])("never retries encrypted replay after $name output starts", async ({ item }) => { - const requests: ResponseCreateParamsStreaming[] = []; - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await runResponsesStreamLifecycle({ - stream, - model: nativeOpenAIModel, - output, - createClient: () => ({ - responses: { - create: (request) => { - requests.push(request); - return { - withResponse: async () => ({ - data: (async function* () { - yield { - type: "response.output_item.added", - output_index: 0, - sequence_number: 0, - item, - } as ResponseStreamEvent; - throw new Error("400 The encrypted content could not be verified."); - })(), - response: new Response(null, { status: 200 }), - }), - }; - }, - }, - }), - buildParams: () => ({ - model: nativeOpenAIModel.id, - stream: true, - input: [{ type: "reasoning", id: "rs_replay", encrypted_content: "stale", summary: [] }], - }), - }); - + ); + await done; expect(requests).toHaveLength(1); expect(output.content).toHaveLength(1); expect(output.stopReason).toBe("error"); @@ -1236,54 +832,43 @@ describe("processResponsesStream", () => { { role: "user", content: "recover", timestamp: 1 }, ], }; - const requests: ResponseCreateParamsStreaming[] = []; - const output = createAssistantOutput(); const onPayload = vi.fn((request: unknown) => request); const onCompactionRejected = vi.fn(); - - await runResponsesStreamLifecycle({ - stream: new AssistantMessageEventStream(), - model: nativeOpenAIModel, - output, - options: { ...replayIdentity, onCompactionRejected, onPayload }, - createClient: () => ({ - responses: { - create: (request) => { - requests.push(structuredClone(request)); - const attempt = requests.length; - return { - withResponse: async () => { - if (attempt === 1) { - throw Object.assign(new Error("invalid_encrypted_content"), { - code: "invalid_encrypted_content", - status: 400, - }); - } - return { - data: streamResponsesEvents([ - { - type: "response.completed", - sequence_number: 1, - response: { id: "resp_recovered", status: "completed", output: [] }, - } as unknown as ResponseStreamEvent, - ]), - response: new Response(null, { status: 200 }), - }; - }, - }; - }, - }, - }), - buildParams: (_model, replayMode) => ({ - model: nativeOpenAIModel.id, - input: convertResponsesMessages(nativeOpenAIModel, context, testAllowedToolCallProviders, { - ...replayIdentity, - replayMode, + const { output, requests, done } = startLifecycle( + (attempt) => { + if (attempt === 1) { + throw Object.assign(new Error("invalid_encrypted_content"), { + code: "invalid_encrypted_content", + status: 400, + }); + } + return streamResponsesEvents([ + { + type: "response.completed", + sequence_number: 1, + response: { id: "resp_recovered", status: "completed", output: [] }, + } as unknown as ResponseStreamEvent, + ]); + }, + { + options: { ...replayIdentity, onCompactionRejected, onPayload }, + buildParams: (_model, replayMode) => ({ + model: nativeOpenAIModel.id, + input: convertResponsesMessages( + nativeOpenAIModel, + context, + testAllowedToolCallProviders, + { + ...replayIdentity, + replayMode, + }, + ), + stream: true, }), - stream: true, - }), - }); + }, + ); + await done; expect(requests).toHaveLength(2); expect(requests[0]?.input).toEqual( expect.arrayContaining([expect.objectContaining({ type: "compaction", id: "cmp_rejected" })]), @@ -1320,74 +905,44 @@ describe("processResponsesStream", () => { expect(nextInput.some((item) => item.type === "compaction")).toBe(false); }); - it("records the effective model from the terminal response", async () => { - const output = createAssistantOutput(); - - await processResponsesStream( - responseEvents([ - { - type: "response.completed", - response: { - id: "resp_rerouted", - status: "completed", - model: "gpt-5.5-rerouted", - }, - }, - ]), - output, - new AssistantMessageEventStream(), - nativeOpenAIModel, - ); - - expect(output.responseModel).toBe("gpt-5.5-rerouted"); - }); - it("keeps interleaved reasoning items bound to their output indices", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { type: "reasoning", id: "rs_first", summary: [] }, + const { output, events } = await collectResponses([ + { + type: "response.output_item.added", + output_index: 0, + item: { type: "reasoning", id: "rs_first", summary: [] }, + }, + { + type: "response.output_item.added", + output_index: 1, + item: { type: "reasoning", id: "rs_second", summary: [] }, + }, + { type: "response.reasoning_text.delta", output_index: 0, delta: "first" }, + { type: "response.reasoning_text.delta", output_index: 1, delta: "second" }, + { + type: "response.output_item.done", + output_index: 1, + item: { + type: "reasoning", + id: "rs_second", + summary: [], + content: [{ type: "reasoning_text", text: "second" }], + encrypted_content: "cipher-second", }, - { - type: "response.output_item.added", - output_index: 1, - item: { type: "reasoning", id: "rs_second", summary: [] }, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_first", + summary: [], + content: [{ type: "reasoning_text", text: "first" }], + encrypted_content: "cipher-first", }, - { type: "response.reasoning_text.delta", output_index: 0, delta: "first" }, - { type: "response.reasoning_text.delta", output_index: 1, delta: "second" }, - { - type: "response.output_item.done", - output_index: 1, - item: { - type: "reasoning", - id: "rs_second", - summary: [], - content: [{ type: "reasoning_text", text: "second" }], - encrypted_content: "cipher-second", - }, - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_first", - summary: [], - content: [{ type: "reasoning_text", text: "first" }], - encrypted_content: "cipher-first", - }, - }, - { type: "response.completed", response: { id: "resp_reasoning", status: "completed" } }, - ]), - output, - stream, - nativeOpenAIModel, - ); + }, + completed("resp_reasoning"), + ]); expect(output.content).toMatchObject([ { type: "thinking", thinking: "first" }, @@ -1414,143 +969,62 @@ describe("processResponsesStream", () => { ]); }); - it("preserves reasoning while a tool call streams on another output index", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { type: "reasoning", id: "rs_tool", summary: [] }, - }, - { type: "response.reasoning_text.delta", output_index: 0, delta: "before " }, - { - type: "response.output_item.added", - output_index: 1, - item: { - type: "function_call", - id: "fc_read", - call_id: "call_read", - name: "read", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - output_index: 1, - item_id: "fc_read", - delta: '{"path":"README.md"}', - }, - { type: "response.reasoning_text.delta", output_index: 0, delta: "after" }, - { - type: "response.output_item.done", - output_index: 1, - item: { - type: "function_call", - id: "fc_read", - call_id: "call_read", - name: "read", - arguments: '{"path":"README.md"}', - }, - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_tool", - summary: [], - encrypted_content: "cipher-tool", - }, - }, - { type: "response.completed", response: { id: "resp_tool", status: "completed" } }, - ]), - output, - stream, - nativeOpenAIModel, - ); - - expect(output.content).toMatchObject([ - { type: "thinking", thinking: "before after" }, - { type: "toolCall", id: "call_read|fc_read", arguments: { path: "README.md" } }, - ]); - expect( - events - .filter((event) => event.type.endsWith("_delta")) - .map((event) => [event.type, "contentIndex" in event ? event.contentIndex : undefined]), - ).toEqual([ - ["thinking_delta", 0], - ["toolcall_delta", 1], - ["thinking_delta", 0], - ]); - }); - it("keeps deferred text before an interleaved output item", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "message", - id: "msg_first", - content: [{ type: "output_text", text: "" }], - }, + const { output, events } = await collectResponses([ + { + type: "response.output_item.added", + output_index: 0, + item: { + type: "message", + id: "msg_first", + content: [{ type: "output_text", text: "" }], }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "message", - id: "msg_first", - content: [{ type: "output_text", text: "first" }], - }, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_first", + content: [{ type: "output_text", text: "first" }], }, - { - type: "response.output_item.added", - output_index: 1, - item: { - type: "message", - id: "msg_second", - content: [{ type: "output_text", text: "" }], - }, + }, + { + type: "response.output_item.added", + output_index: 1, + item: { + type: "message", + id: "msg_second", + content: [{ type: "output_text", text: "" }], }, - { type: "response.output_text.delta", output_index: 1, delta: "first extended" }, - { - type: "response.output_item.added", - output_index: 2, - item: { type: "reasoning", id: "rs_after", summary: [] }, + }, + { type: "response.output_text.delta", output_index: 1, delta: "first extended" }, + { + type: "response.output_item.added", + output_index: 2, + item: { type: "reasoning", id: "rs_after", summary: [] }, + }, + { + type: "response.output_item.done", + output_index: 2, + item: { + type: "reasoning", + id: "rs_after", + summary: [], + content: [{ type: "reasoning_text", text: "thought" }], }, - { - type: "response.output_item.done", - output_index: 2, - item: { - type: "reasoning", - id: "rs_after", - summary: [], - content: [{ type: "reasoning_text", text: "thought" }], - }, + }, + { + type: "response.output_item.done", + output_index: 1, + item: { + type: "message", + id: "msg_second", + content: [{ type: "output_text", text: "first extended" }], }, - { - type: "response.output_item.done", - output_index: 1, - item: { - type: "message", - id: "msg_second", - content: [{ type: "output_text", text: "first extended" }], - }, - }, - { type: "response.completed", response: { id: "resp_order", status: "completed" } }, - ]), - output, - stream, - nativeOpenAIModel, - ); + }, + completed("resp_order"), + ]); expect(output.content).toMatchObject([ { type: "text", text: "first" }, @@ -1568,29 +1042,21 @@ describe("processResponsesStream", () => { ]); }); - it("finalizes incomplete terminal events with usage", async () => { - const output = createAssistantOutput(); - - await processResponsesStream( - responseEvents([ - { - type: "response.incomplete", - response: { - id: "resp_incomplete", - status: "incomplete", - usage: { - input_tokens: 30, - output_tokens: 12, - total_tokens: 42, - input_tokens_details: { cached_tokens: 5, cache_write_tokens: 3 }, - }, + it("finalizes incomplete usage and derives an omitted token total", async () => { + const { output } = await collectResponses([ + { + type: "response.incomplete", + response: { + id: "resp_incomplete", + status: "incomplete", + usage: { + input_tokens: 30, + output_tokens: 12, + input_tokens_details: { cached_tokens: 5, cache_write_tokens: 3 }, }, }, - ]), - output, - new AssistantMessageEventStream(), - nativeOpenAIModel, - ); + }, + ]); expect(output.stopReason).toBe("length"); expect(output.usage).toMatchObject({ @@ -1602,44 +1068,9 @@ describe("processResponsesStream", () => { }); }); - it("derives totalTokens from the split buckets when the payload omits it", async () => { - const output = createAssistantOutput(); - - await processResponsesStream( - responseEvents([ - { - type: "response.completed", - response: { - id: "resp_no_total", - status: "completed", - usage: { - input_tokens: 30, - output_tokens: 12, - input_tokens_details: { cached_tokens: 5, cache_write_tokens: 3 }, - }, - }, - }, - ]), - output, - new AssistantMessageEventStream(), - nativeOpenAIModel, - ); - - // Responses-compatible proxies routinely omit total_tokens; reporting 0 understates the turn. - expect(output.usage).toMatchObject({ - input: 22, - output: 12, - cacheRead: 5, - cacheWrite: 3, - totalTokens: 42, - }); - }); - it("reports content-filtered incomplete responses as errors", async () => { - const output = createAssistantOutput(); - - await processResponsesStream( - responseEvents([ + const { output, done } = startLifecycle(() => + streamResponsesEvents([ { type: "response.incomplete", response: { @@ -1647,45 +1078,12 @@ describe("processResponsesStream", () => { status: "incomplete", incomplete_details: { reason: "content_filter" }, }, - }, + } as ResponseStreamEvent, ]), - output, - new AssistantMessageEventStream(), - nativeOpenAIModel, ); - + await done; expect(output.stopReason).toBe("error"); expect(output.errorMessage).toBe("Provider incomplete_reason: content_filter"); - - const lifecycleOutput = createAssistantOutput(); - await runResponsesStreamLifecycle({ - stream: new AssistantMessageEventStream(), - model: nativeOpenAIModel, - output: lifecycleOutput, - createClient: () => ({ - responses: { - create: () => ({ - withResponse: async () => ({ - data: streamResponsesEvents([ - { - type: "response.incomplete", - response: { - id: "resp_filtered_lifecycle", - status: "incomplete", - incomplete_details: { reason: "content_filter" }, - }, - } as ResponseStreamEvent, - ]), - response: new Response(null, { status: 200 }), - }), - }), - }, - }), - buildParams: () => ({ model: nativeOpenAIModel.id, input: [], stream: true }), - }); - - expect(lifecycleOutput.stopReason).toBe("error"); - expect(lifecycleOutput.errorMessage).toBe("Provider incomplete_reason: content_filter"); }); it("preserves failed terminal response details and accounting", async () => { @@ -1710,7 +1108,7 @@ describe("processResponsesStream", () => { await expect( processResponsesStream( - responseEvents([ + streamResponsesEvents([ { type: "response.failed", response: { @@ -1792,21 +1190,9 @@ describe("processResponsesStream", () => { })(); await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - item: { - type: "function_call", - id: "fc_read", - call_id: "call_read", - name: "read", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - delta: '{"path":"docs/gateway/local-models.md"}', - }, + streamResponsesEvents([ + itemEvent("added", toolItem("read", "", "read")), + argumentsDelta('{"path":"docs/gateway/local-models.md"}'), { type: "response.function_call_arguments.done", ...(doneArguments === undefined ? {} : { arguments: doneArguments }), @@ -1824,13 +1210,7 @@ describe("processResponsesStream", () => { name: "read", }, }, - { - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - }, - }, + completed("resp_1"), ]), output, stream, @@ -1860,7 +1240,7 @@ describe("processResponsesStream", () => { const output = createAssistantOutput(); const { stream, events } = createCapturedAssistantMessageEventStream(); await processResponsesStream( - responseEvents([ + streamResponsesEvents([ { type: "response.output_item.added", output_index: 0, @@ -1871,7 +1251,7 @@ describe("processResponsesStream", () => { output_index: 0, item: { type: "function_call", name: "computer", arguments: "{}" }, }, - { type: "response.completed", response: { id: "resp_idless", status: "completed" } }, + completed("resp_idless"), ]), output, stream, @@ -1893,57 +1273,6 @@ describe("processResponsesStream", () => { expect(second.blockId).not.toBe(first.blockId); }); - it("uses the SDK call id directly when the optional item id stays absent", async () => { - const events: ResponseStreamEvent[] = [ - { - type: "response.output_item.added", - output_index: 0, - sequence_number: 1, - item: { - type: "function_call", - call_id: "call_without_item_id", - name: "computer", - arguments: "", - status: "in_progress", - }, - }, - { - type: "response.output_item.done", - output_index: 0, - sequence_number: 2, - item: { - type: "function_call", - call_id: "call_without_item_id", - name: "computer", - arguments: "{}", - status: "completed", - }, - }, - { - type: "response.completed", - sequence_number: 3, - response: { id: "resp_without_item_id", status: "completed" }, - } as ResponseStreamEvent, - ]; - const output = createAssistantOutput(); - - await processResponsesStream( - streamResponsesEvents(events), - output, - new AssistantMessageEventStream(), - nativeOpenAIModel, - ); - - expect(output.content).toEqual([ - { - type: "toolCall", - id: "call_without_item_id", - name: "computer", - arguments: {}, - }, - ]); - }); - it("adopts the completed SDK item id while preserving lifecycle and result linkage", async () => { const responseStream: ResponseStreamEvent[] = [ { @@ -2044,29 +1373,9 @@ describe("processResponsesStream", () => { await expect( processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first_index_owner", - call_id: "call_first_index_owner", - name: "computer", - arguments: "", - }, - }, - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_second_index_owner", - call_id: "call_second_index_owner", - name: "computer", - arguments: "", - }, - }, + streamResponsesEvents([ + itemEvent("added", toolItem("first_index_owner", ""), 0), + itemEvent("added", toolItem("second_index_owner", ""), 0), ]), output, stream, @@ -2077,103 +1386,23 @@ describe("processResponsesStream", () => { expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(0); }); - it("keeps interleaved Responses function calls bound to their output indices", async () => { - const responseStream: ResponseStreamEvent[] = [ - ...createInterleavedResponsesToolEvents(), - { - type: "response.completed", - sequence_number: 7, - response: { id: "resp_interleaved", status: "completed" }, - } as ResponseStreamEvent, - ]; - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - streamResponsesEvents(responseStream), - output, - stream, - nativeOpenAIModel, - ); - - expect(output.content).toEqual([ - { - type: "toolCall", - id: "call_click|fc_click", - name: "computer", - arguments: { action: "left_click", coordinate: [10, 20] }, - }, - { - type: "toolCall", - id: "call_type|fc_type", - name: "computer", - arguments: { action: "type", text: "hello" }, - }, - ]); - expect( - events - .filter((event) => event.type.startsWith("toolcall_")) - .map((event) => [event.type, "contentIndex" in event ? event.contentIndex : undefined]), - ).toEqual([ - ["toolcall_start", 0], - ["toolcall_start", 1], - ["toolcall_delta", 1], - ["toolcall_delta", 0], - ["toolcall_end", 0], - ["toolcall_end", 1], - ]); - }); - it("routes indexed Responses tool arguments when item ids rotate", async () => { const output = createResponsesAssistantOutput(gpt56SolModel); const { stream, events } = createCapturedAssistantMessageEventStream(); await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_read", - call_id: "call_read", - name: "read", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "encrypted_delta_1", - delta: '{"path":', - }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "encrypted_delta_2", - delta: '"README.md"}', - }, + streamResponsesEvents([ + itemEvent("added", toolItem("read", "", "read"), 0), + argumentsDelta('{"path":', 0, "encrypted_delta_1"), + argumentsDelta('"README.md"}', 0, "encrypted_delta_2"), { type: "response.function_call_arguments.done", output_index: 0, item_id: "encrypted_done", arguments: '{"path":"README.md"}', }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "function_call", - id: "fc_read", - call_id: "call_read", - name: "read", - arguments: "", - }, - }, - { - type: "response.completed", - response: { id: "resp_read", status: "completed" }, - }, + itemEvent("done", toolItem("read", "", "read"), 0), + completed("resp_read"), ]), output, stream, @@ -2202,7 +1431,7 @@ describe("processResponsesStream", () => { await expect( processResponsesStream( - responseEvents([ + streamResponsesEvents([ { type: "response.output_item.added", output_index: 0, @@ -2214,12 +1443,7 @@ describe("processResponsesStream", () => { arguments: "", }, }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "encrypted_delta", - delta: '{"path":"README.md"}', - }, + argumentsDelta('{"path":"README.md"}', 0, "encrypted_delta"), { type: "response.output_item.done", output_index: 0, @@ -2231,10 +1455,7 @@ describe("processResponsesStream", () => { arguments: '{"path":"README.md"}', }, }, - { - type: "response.completed", - response: { id: "resp_read", status: "completed" }, - }, + completed("resp_read"), ]), output, stream, @@ -2265,7 +1486,7 @@ describe("processResponsesStream", () => { }; await processResponsesStream( - responseEvents([ + streamResponsesEvents([ { type: "response.output_item.added", item: { ...firstItem, arguments: "", status: "in_progress" }, @@ -2274,16 +1495,8 @@ describe("processResponsesStream", () => { type: "response.output_item.added", item: { ...secondItem, arguments: "", status: "in_progress" }, }, - { - type: "response.function_call_arguments.delta", - item_id: secondItem.id, - delta: secondItem.arguments, - }, - { - type: "response.function_call_arguments.delta", - item_id: firstItem.id, - delta: firstItem.arguments, - }, + argumentsDelta(secondItem.arguments, undefined, secondItem.id), + argumentsDelta(firstItem.arguments, undefined, firstItem.id), { type: "response.function_call_arguments.done", item_id: firstItem.id, @@ -2357,16 +1570,13 @@ describe("processResponsesStream", () => { await expect( processResponsesStream( - responseEvents([ + streamResponsesEvents([ { type: "response.output_item.added", item: { ...firstItem, arguments: "" } }, { type: "response.output_item.added", item: { ...secondItem, arguments: "" } }, { type: "response.function_call_arguments.delta", delta: '{"slot":1}' }, { type: "response.output_item.done", item: firstItem }, { type: "response.output_item.done", item: secondItem }, - { - type: "response.completed", - response: { id: "resp_ambiguous_unindexed", status: "completed" }, - }, + completed("resp_ambiguous_unindexed"), ]), output, stream, @@ -2381,7 +1591,7 @@ describe("processResponsesStream", () => { const output = createAssistantOutput(); const { stream, events } = createCapturedAssistantMessageEventStream(); await processResponsesStream( - responseEvents(createResponsesDoneArgumentEvents()), + streamResponsesEvents(createResponsesDoneArgumentEvents()), output, stream, nativeOpenAIModel, @@ -2409,29 +1619,9 @@ describe("processResponsesStream", () => { await expect( processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_name_conflict", - call_id: "call_name_conflict", - name: "read", - arguments: "", - }, - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "function_call", - id: "fc_name_conflict", - call_id: "call_name_conflict", - name: "write", - arguments: "{}", - }, - }, + streamResponsesEvents([ + itemEvent("added", toolItem("name_conflict", "", "read"), 0), + itemEvent("done", toolItem("name_conflict", "{}", "write"), 0), ]), output, new AssistantMessageEventStream(), @@ -2440,78 +1630,17 @@ describe("processResponsesStream", () => { ).rejects.toThrow("Responses stream changed tool-call function name from read to write"); }); - it("routes an omitted-index suffix by item id across parallel Responses calls", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { - type: "response.output_item.added", - output_index: 1, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "fc_first", - delta: '{"slot":', - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_first", - delta: "0}", - }, - { - type: "response.function_call_arguments.delta", - output_index: 1, - delta: '{"slot":1}', - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.output_item.done", - output_index: 1, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: '{"slot":1}', - }, - }, - { type: "response.completed", response: { id: "resp_suffix", status: "completed" } }, - ]), - output, - stream, - nativeOpenAIModel, - ); + it("routes omitted-index parallel arguments and completions without duplicates", async () => { + const { output, events } = await collectResponses([ + itemEvent("added", toolItem("first", ""), 0), + itemEvent("added", toolItem("second", ""), 1), + argumentsDelta('{"slot":', 0, "fc_first"), + argumentsDelta("0}", undefined, "fc_first"), + argumentsDelta('{"slot":', 1), + itemEvent("done", toolItem("second", '{"slot":1}')), + itemEvent("done", toolItem("first", '{"slot":0}')), + completed("resp_suffix"), + ]); expect(output.content).toMatchObject([ { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, @@ -2522,131 +1651,18 @@ describe("processResponsesStream", () => { .filter((event) => event.type === "toolcall_delta") .map((event) => ("contentIndex" in event ? event.contentIndex : undefined)), ).toEqual([0, 0, 1]); - }); - - it("matches omitted-index parallel completions without duplicating indexed calls", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { - type: "response.output_item.added", - output_index: 1, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - output_index: 0, - item_id: "fc_first", - delta: '{"incomplete":', - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: '{"slot":1}', - }, - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.completed", - response: { id: "resp_omitted_completions", status: "completed" }, - }, - ]), - output, - stream, - nativeOpenAIModel, - ); - - expect(output.content).toMatchObject([ - { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, - { type: "toolCall", id: "call_second|fc_second", arguments: { slot: 1 } }, - ]); expect(events.filter((event) => event.type === "toolcall_start")).toHaveLength(2); expect(events.filter((event) => event.type === "toolcall_end")).toHaveLength(2); }); it("rejects omitted-index events whose identity mismatches the sole indexed call", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 0, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_other", - delta: '{"wrong":true}', - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_other", - call_id: "call_other", - name: "computer", - arguments: '{"wrong":true}', - }, - }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.completed", - response: { id: "resp_identity_mismatch", status: "completed" }, - }, - ]), - output, - stream, - nativeOpenAIModel, - ); + const { output, events } = await collectResponses([ + itemEvent("added", toolItem("first", ""), 0), + argumentsDelta('{"wrong":true}', undefined, "fc_other"), + itemEvent("done", toolItem("other", '{"wrong":true}')), + itemEvent("done", toolItem("first", '{"slot":0}')), + completed("resp_identity_mismatch"), + ]); expect(output.content).toMatchObject([ { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, @@ -2657,61 +1673,15 @@ describe("processResponsesStream", () => { }); it("keeps sequential omitted-index Responses calls unambiguous", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.added", - output_index: 7, - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: "", - }, - }, - { type: "response.function_call_arguments.delta", delta: '{"slot":0}' }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_first", - call_id: "call_first", - name: "computer", - arguments: '{"slot":0}', - }, - }, - { - type: "response.output_item.added", - output_index: 8, - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: "", - }, - }, - { type: "response.function_call_arguments.delta", delta: '{"slot":1}' }, - { - type: "response.output_item.done", - item: { - type: "function_call", - id: "fc_second", - call_id: "call_second", - name: "computer", - arguments: '{"slot":1}', - }, - }, - { type: "response.completed", response: { id: "resp_sequential", status: "completed" } }, - ]), - output, - stream, - nativeOpenAIModel, - ); + const { output, events } = await collectResponses([ + itemEvent("added", toolItem("first", ""), 7), + { type: "response.function_call_arguments.delta", delta: '{"slot":0}' }, + itemEvent("done", toolItem("first", '{"slot":0}')), + itemEvent("added", toolItem("second", ""), 8), + { type: "response.function_call_arguments.delta", delta: '{"slot":1}' }, + itemEvent("done", toolItem("second", '{"slot":1}')), + completed("resp_sequential"), + ]); expect(output.content).toMatchObject([ { type: "toolCall", id: "call_first|fc_first", arguments: { slot: 0 } }, @@ -2724,77 +1694,22 @@ describe("processResponsesStream", () => { ).toEqual([0, 1]); }); - it("materializes a done-only SDK tool call with a balanced terminal lifecycle", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.done", - output_index: 0, - sequence_number: 1, - item: { - type: "function_call", - id: "fc_done_only", - call_id: "call_done_only", - name: "weather", - arguments: '{"city":"Paris"}', - status: "completed", - }, - }, - { - type: "response.completed", - sequence_number: 2, - response: { id: "resp_done_only", status: "completed" }, - }, - ]), - output, - stream, - nativeOpenAIModel, - ); - - expect(output.content).toEqual([ - { - type: "toolCall", - id: "call_done_only|fc_done_only", - name: "weather", - arguments: { city: "Paris" }, - }, - ]); - expect(output.stopReason).toBe("toolUse"); - expect( - events.map((event) => [event.type, "contentIndex" in event ? event.contentIndex : undefined]), - ).toEqual([ - ["toolcall_start", 0], - ["toolcall_end", 0], - ]); - }); - it("pairs an item-only tool call with one generated call id", async () => { - const output = createAssistantOutput(); - const { stream, events } = createCapturedAssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.output_item.done", - output_index: 0, - sequence_number: 1, - item: { - type: "function_call", - id: "fc_item_only", - name: "computer", - arguments: "{}", - status: "completed", - }, + const { output, events } = await collectResponses([ + { + type: "response.output_item.done", + output_index: 0, + sequence_number: 1, + item: { + type: "function_call", + id: "fc_item_only", + name: "computer", + arguments: "{}", + status: "completed", }, - { type: "response.completed", response: { id: "resp_item_only", status: "completed" } }, - ]), - output, - stream, - nativeOpenAIModel, - ); + }, + completed("resp_item_only"), + ]); const block = output.content[0]; const end = events.find((event) => event.type === "toolcall_end"); @@ -2804,321 +1719,22 @@ describe("processResponsesStream", () => { expect(block.id).toMatch(/^call_[0-9a-f]{24}\|fc_item_only$/); expect(end.toolCall.id).toBe(block.id); }); - - it("prices cache-write tokens separately from ordinary Responses input", async () => { - const model = { - ...gpt56SolModel, - cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, - } satisfies Model<"openai-responses">; - const output = createResponsesAssistantOutput(model, model.api); - const stream = new AssistantMessageEventStream(); - - await processResponsesStream( - responseEvents([ - { - type: "response.completed", - response: { - id: "resp_cache_write", - status: "completed", - usage: { - input_tokens: 100, - input_tokens_details: { cached_tokens: 20, cache_write_tokens: 30 }, - output_tokens: 10, - output_tokens_details: { reasoning_tokens: 0 }, - total_tokens: 110, - }, - }, - }, - ]), - output, - stream, - model, - ); - - expect(output.usage).toMatchObject({ - input: 50, - output: 10, - cacheRead: 20, - cacheWrite: 30, - totalTokens: 110, - }); - expect(output.usage.cost.input).toBeCloseTo(0.00025); - expect(output.usage.cost.output).toBeCloseTo(0.0003); - expect(output.usage.cost.cacheRead).toBeCloseTo(0.00001); - expect(output.usage.cost.cacheWrite).toBeCloseTo(0.0001875); - expect(output.usage.cost.total).toBeCloseTo(0.0007475); - }); }); describe("Azure OpenAI Responses content type support", () => { const azureModel = { - id: "gpt-5.5", - name: "GPT-5.5 (Azure)", + ...nativeOpenAIModel, api: "azure-openai-responses", provider: "azure", baseUrl: "https://test.openai.azure.com/openai/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 8192, } satisfies Model<"azure-openai-responses">; - it("supports Azure 'text' content type in addition to 'output_text'", () => { - const input = convertResponsesMessages( - azureModel, - { - systemPrompt: "system", - messages: [ - { - role: "assistant", - api: azureModel.api, - provider: azureModel.provider, - model: azureModel.id, - usage: createZeroUsage(), - stopReason: "stop", - timestamp: 1, - content: [ - { - type: "text", - text: "Azure response with text content type", - textSignature: JSON.stringify({ - v: 1, - id: "msg_azure_text", - }), - }, - ], - }, - ], - } satisfies Context, - new Set(["azure", "azure-openai-responses"]), - { includeSystemPrompt: false }, - ); - - const assistantMessage = input.find( - (item) => item && typeof item === "object" && "role" in item && item.role === "assistant", - ); - - expect(assistantMessage).toMatchObject({ - type: "message", - role: "assistant", - content: [ - { - type: "output_text", - text: "Azure response with text content type", - annotations: [], - }, - ], - }); - }); - - it("processResponsesStream handles Azure 'text' content type with output_text deltas", async () => { - const azureEvents: OpenAIResponsesStreamEvent[] = [ - { - type: "response.output_item.added", - output_index: 0, - sequence_number: 1, - item: { - type: "message", - role: "assistant", - id: "msg_azure_1", - content: [], - status: "in_progress", - }, - }, - { - type: "response.content_part.added", - content_index: 0, - item_id: "msg_azure_1", - output_index: 0, - sequence_number: 2, - part: { - type: "text", - text: "", - }, - }, - { - type: "response.output_text.delta", - content_index: 0, - delta: "Hello", - item_id: "msg_azure_1", - logprobs: [], - output_index: 0, - sequence_number: 3, - }, - { - type: "response.output_text.delta", - content_index: 0, - delta: " from", - item_id: "msg_azure_1", - logprobs: [], - output_index: 0, - sequence_number: 4, - }, - { - type: "response.output_text.delta", - content_index: 0, - delta: " Azure!", - item_id: "msg_azure_1", - logprobs: [], - output_index: 0, - sequence_number: 5, - }, - { - type: "response.output_item.done", - output_index: 0, - sequence_number: 6, - item: { - type: "message", - role: "assistant", - id: "msg_azure_1", - content: [ - { - type: "text", - text: "Hello from Azure!", - }, - ], - status: "completed", - }, - }, - { - type: "response.completed", - sequence_number: 7, - response: { - id: "resp_azure_123", - created_at: 1, - output_text: "Hello from Azure!", - error: null, - incomplete_details: null, - instructions: null, - metadata: null, - model: azureModel.id, - object: "response", - output: [], - parallel_tool_calls: false, - temperature: null, - tool_choice: "auto", - tools: [], - top_p: null, - status: "completed", - usage: { - input_tokens: 10, - input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, - output_tokens: 5, - output_tokens_details: { reasoning_tokens: 0 }, - total_tokens: 15, - }, - }, - }, - ]; - + it.each([ + { name: "explicit text part", deltaType: "response.output_text.delta", explicitPart: true }, + { name: "implicit text part", deltaType: "response.text.delta", explicitPart: false }, + ])("streams Azure $name", async ({ deltaType, explicitPart }) => { const { stream, events } = createCapturedAssistantMessageEventStream(); - const output = createResponsesAssistantOutput(azureModel, "azure-openai-responses"); - await processResponsesStream(streamResponsesEvents(azureEvents), output, stream, azureModel); - - expect( - events.map((event) => - event.type === "text_delta" - ? { type: event.type, delta: event.delta } - : event.type === "text_end" - ? { type: event.type, content: event.content } - : { type: event.type }, - ), - ).toEqual([ - { type: "text_start" }, - { type: "text_delta", delta: "Hello" }, - { type: "text_delta", delta: " from" }, - { type: "text_delta", delta: " Azure!" }, - { type: "text_end", content: "Hello from Azure!" }, - ]); - - expect(output.content).toHaveLength(1); - expect(output.content[0]).toMatchObject({ - type: "text", - text: "Hello from Azure!", - }); - - expect(output.usage).toMatchObject({ - input: 10, - output: 5, - totalTokens: 15, - }); - - expect(output.stopReason).toBe("stop"); - }); - - it("processResponsesStream handles Azure text deltas without a content_part.added event", async () => { - const azureEvents: OpenAIResponsesStreamEvent[] = [ - { - type: "response.output_item.added", - output_index: 0, - sequence_number: 1, - item: { - type: "message", - role: "assistant", - id: "msg_azure_without_part", - content: [], - status: "in_progress", - }, - }, - { - type: "response.text.delta", - delta: "No explicit", - }, - { - type: "response.text.delta", - delta: " part", - }, - { - type: "response.output_item.done", - output_index: 0, - sequence_number: 4, - item: { - type: "message", - role: "assistant", - id: "msg_azure_without_part", - content: [ - { - type: "text", - text: "No explicit part", - }, - ], - status: "completed", - }, - }, - { - type: "response.completed", - sequence_number: 5, - response: { - id: "resp_azure_without_part", - created_at: 1, - output_text: "No explicit part", - error: null, - incomplete_details: null, - instructions: null, - metadata: null, - model: azureModel.id, - object: "response", - output: [], - parallel_tool_calls: false, - temperature: null, - tool_choice: "auto", - tools: [], - top_p: null, - status: "completed", - usage: { - input_tokens: 3, - input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, - output_tokens: 3, - output_tokens_details: { reasoning_tokens: 0 }, - total_tokens: 6, - }, - }, - }, - ]; - - const { stream, events } = createCapturedAssistantMessageEventStream(); - const output = createResponsesAssistantOutput(azureModel, "azure-openai-responses"); + const output = createResponsesAssistantOutput(azureModel, azureModel.api); const liveTextSignatures: Array = []; const push = stream.push.bind(stream); stream.push = (event) => { @@ -3128,9 +1744,51 @@ describe("Azure OpenAI Responses content type support", () => { } push(event); }; - - await processResponsesStream(streamResponsesEvents(azureEvents), output, stream, azureModel); - + const item = { type: "message", role: "assistant", id: "msg_azure", content: [] }; + await processResponsesStream( + streamResponsesEvents([ + itemEvent("added", { ...item, status: "in_progress" }, 0), + ...(explicitPart + ? [ + { + type: "response.content_part.added", + output_index: 0, + part: { type: "text", text: "" }, + }, + ] + : []), + { type: deltaType, delta: "Hello", ...(explicitPart ? { output_index: 0 } : {}) }, + { type: deltaType, delta: " from Azure!", ...(explicitPart ? { output_index: 0 } : {}) }, + itemEvent( + "done", + { + ...item, + status: "completed", + content: [{ type: "text", text: "Hello from Azure!" }], + }, + 0, + ), + { + type: "response.completed", + response: { + id: "resp_azure", + status: "completed", + model: azureModel.id, + output: [], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + ]), + output, + stream, + azureModel, + ); expect( events.map((event) => event.type === "text_delta" @@ -3139,14 +1797,10 @@ describe("Azure OpenAI Responses content type support", () => { ? `[END:${event.content}]` : event.type, ), - ).toEqual(["text_start", "No explicit", " part", "[END:No explicit part]"]); - - expect(output.content[0]).toMatchObject({ - type: "text", - text: "No explicit part", - }); - // Unphased Responses items keep streaming live; replay identity is stamped - // only once completion supplies the final block. + ).toEqual(["text_start", "Hello", " from Azure!", "[END:Hello from Azure!]"]); + expect(output.content).toMatchObject([{ type: "text", text: "Hello from Azure!" }]); + expect(output.usage).toMatchObject({ input: 10, output: 5, totalTokens: 15 }); + expect(output.stopReason).toBe("stop"); expect(liveTextSignatures).toEqual([undefined, undefined, undefined]); }); }); diff --git a/scripts/lib/ci-proof-test-inventory.mts b/scripts/lib/ci-proof-test-inventory.mts index e8a041b6605d..9b6006651e05 100644 --- a/scripts/lib/ci-proof-test-inventory.mts +++ b/scripts/lib/ci-proof-test-inventory.mts @@ -1332,7 +1332,6 @@ export const PR_PROTECTED_RUNTIME_TEST_FILES: readonly string[] = [ "src/agents/sessions/session-manager-static-notes.test.ts", "src/agents/sessions/session-manager.fork-rebase.test.ts", "src/agents/sessions/session-manager.persistence-compat.test.ts", - "src/agents/sessions/session-manager.user-idempotency.test.ts", "src/agents/sessions/settings-storage.test.ts", "src/agents/sessions/tools/bash-termination.test.ts", "src/agents/sessions/tools/bash.test.ts", diff --git a/src/agents/context-resolution.ts b/src/agents/context-resolution.ts index a3798168097a..2021a44a4c43 100644 --- a/src/agents/context-resolution.ts +++ b/src/agents/context-resolution.ts @@ -50,12 +50,12 @@ export type ModelContextTokenProjection = { const normalizePositiveContextTokens = (value: number | undefined) => typeof value === "number" && value > 0 ? value : undefined; -export const ANTHROPIC_CONTEXT_1M_TOKENS = 1_000_000; -export const ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS = 1_000_000; -export const ANTHROPIC_FABLE_CONTEXT_TOKENS = 1_000_000; -export const ANTHROPIC_MYTHOS_5_CONTEXT_TOKENS = 1_000_000; -export const ANTHROPIC_OPUS_5_CONTEXT_TOKENS = 1_000_000; -export const ANTHROPIC_SONNET_5_CONTEXT_TOKENS = 1_000_000; +const ANTHROPIC_CONTEXT_1M_TOKENS = 1_000_000; +const ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS = 1_000_000; +const ANTHROPIC_FABLE_CONTEXT_TOKENS = 1_000_000; +const ANTHROPIC_MYTHOS_5_CONTEXT_TOKENS = 1_000_000; +const ANTHROPIC_OPUS_5_CONTEXT_TOKENS = 1_000_000; +const ANTHROPIC_SONNET_5_CONTEXT_TOKENS = 1_000_000; function resolveProviderModelRef(params: { provider?: string; diff --git a/src/agents/context.eager-warmup.test.ts b/src/agents/context.eager-warmup.test.ts index 64a7190f525e..5301637016b1 100644 --- a/src/agents/context.eager-warmup.test.ts +++ b/src/agents/context.eager-warmup.test.ts @@ -1,33 +1,16 @@ -// Verifies importing context helpers does not eagerly load runtime config for -// lightweight CLI commands. import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; const loadConfigMock = vi.hoisted(() => vi.fn()); - vi.mock("../config/config.js", () => ({ getRuntimeConfig: loadConfigMock })); -describe("agents/context eager warmup", () => { - const originalArgv = process.argv.slice(); - - beforeEach(() => { - loadConfigMock.mockReset(); - }); - - afterEach(() => { - process.argv = originalArgv.slice(); - }); - - it.each([ - ["models", ["node", "openclaw", "models", "set", "openai/gpt-5.4"]], - ["agent", ["node", "openclaw", "agent", "--message", "ok"]], - ["memory", ["node", "openclaw", "memory", "search", "--json"]], - ])("does not eager-load config for %s commands on import", async (_label, argv) => { - // Import-time config reads are expensive and can fail for commands that only - // need static context helpers. - process.argv = argv; - await importFreshModule(import.meta.url, `./context.js?scope=${_label}`); - - expect(loadConfigMock).not.toHaveBeenCalled(); - }); +const originalArgv = process.argv.slice(); +afterEach(() => { + process.argv = originalArgv.slice(); +}); + +it("does not load config when importing context helpers for a lightweight CLI command", async () => { + process.argv = ["node", "openclaw", "models", "set", "openai/gpt-5.4"]; + await importFreshModule(import.meta.url, "./context.js?scope=models"); + expect(loadConfigMock).not.toHaveBeenCalled(); }); diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 97a5924dc45d..2064b9d14f97 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -1,481 +1,196 @@ -// Covers context-token lookup caches, catalog warmup, and provider-qualified -// model resolution. import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ContextWindowCatalog } from "./context-cache-projection.js"; import { replaceDiscoveredContextTokenCache } from "./context-cache.js"; -import { ANTHROPIC_CONTEXT_1M_TOKENS } from "./context-resolution.js"; import { CONTEXT_WINDOW_RUNTIME_STATE } from "./context-runtime-state.js"; -type DiscoveredModel = { - id: string; - provider?: string; - contextWindow?: number; - contextTokens?: number; -}; -type ContextModule = typeof import("./context.js"); - -const contextTestState = vi.hoisted(() => { - const state = { - loadConfigImpl: () => ({}) as unknown, - discoveredModels: [] as DiscoveredModel[], - staticCatalogModels: [] as DiscoveredModel[], - runtimeConfigSnapshot: null as OpenClawConfig | null, - runtimeConfigSourceSnapshot: null as OpenClawConfig | null, - loadModelCatalogOwnerSnapshot: vi.fn(async (_params: unknown) => ({ - modelCatalog: { - entries: state.discoveredModels, - routeVariants: [], - staticEntries: state.staticCatalogModels, - }, - })), - getPublishedModelCatalogOwnerSnapshot: vi.fn( - ( - _params: unknown, - ): +const state = vi.hoisted(() => { + const initialConfig: OpenClawConfig = {}; + const catalog: ContextWindowCatalog = { entries: [], staticEntries: [] }; + return { + config: initialConfig, + catalog, + loadConfig: vi.fn<() => OpenClawConfig>(), + loadOwner: vi.fn<(_params: unknown) => Promise<{ modelCatalog: ContextWindowCatalog }>>(), + publishedOwner: vi.fn< + (_params: unknown) => | { config: OpenClawConfig; - modelCatalog: { - entries: DiscoveredModel[]; - routeVariants: never[]; - staticEntries: DiscoveredModel[]; - }; + modelCatalog: ContextWindowCatalog; } - | undefined => ({ - config: state.loadConfigImpl() as OpenClawConfig, - modelCatalog: { - entries: state.discoveredModels, - routeVariants: [], - staticEntries: state.staticCatalogModels, - }, - }), - ), + | undefined + >(), }; - return state; }); -vi.mock("../config/config.js", () => ({ - getRuntimeConfig: () => contextTestState.loadConfigImpl(), -})); - +vi.mock("../config/config.js", () => ({ getRuntimeConfig: state.loadConfig })); vi.mock("../config/runtime-source-projection.js", () => ({ - projectConfigOntoRuntimeSourceSnapshot: (config: OpenClawConfig) => - contextTestState.runtimeConfigSnapshot && contextTestState.runtimeConfigSourceSnapshot - ? contextTestState.runtimeConfigSourceSnapshot - : config, + projectConfigOntoRuntimeSourceSnapshot: (snapshot: OpenClawConfig) => snapshot, })); - vi.mock("./prepared-model-catalog.js", () => ({ loadProviderScopedThinkingCatalog: vi.fn(async () => []), - loadPreparedModelCatalogOwnerSnapshot: contextTestState.loadModelCatalogOwnerSnapshot, - getPublishedPreparedModelCatalogOwnerSnapshot: - contextTestState.getPublishedModelCatalogOwnerSnapshot, + loadPreparedModelCatalogOwnerSnapshot: state.loadOwner, + getPublishedPreparedModelCatalogOwnerSnapshot: state.publishedOwner, })); -function mockContextDeps(params: { - getRuntimeConfig: () => unknown; - discoveredModels?: DiscoveredModel[]; -}) { - // The context module keeps process-local cache state, so tests replace the - // dependency seams before asking the already-imported module for values. - contextTestState.loadConfigImpl = params.getRuntimeConfig; - contextTestState.discoveredModels = params.discoveredModels ?? []; -} - -function mockContextModuleDeps(loadConfigImpl: () => unknown) { - return mockContextDeps({ getRuntimeConfig: loadConfigImpl }); -} - -// Shared mock setup used by multiple tests. -function mockDiscoveryDeps( - models: DiscoveredModel[], - configModels?: Record }>, -) { - mockContextDeps({ - getRuntimeConfig: () => ({ models: configModels ? { providers: configModels } : {} }), - discoveredModels: models, - }); -} - -function createContextOverrideConfig( - provider: string, - model: string, - contextWindow: number, -): OpenClawConfig { - return { - models: { - providers: { - [provider]: { - baseUrl: "https://example.invalid", - models: [{ id: model, contextWindow } as never], - }, - }, - }, - }; -} - -function createConfiguredModel(id: string, contextTokens: number): ModelDefinitionConfig { +function model(id: string, contextWindow: number, contextTokens?: number): ModelDefinitionConfig { return { id, name: id, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: contextTokens, + contextWindow, contextTokens, maxTokens: 4096, }; } -async function flushAsyncWarmup() { - // Warmup may run via timers or microtasks depending on the import path; flush - // both so assertions observe stable cache state. - if (vi.isFakeTimers()) { - await vi.advanceTimersByTimeAsync(0); - return; - } - await Promise.resolve(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - await Promise.resolve(); +function config(provider: string, ...models: ModelDefinitionConfig[]): OpenClawConfig { + return { models: { providers: { [provider]: { baseUrl: "https://example.invalid", models } } } }; } -let contextModule: ContextModule; +let context: typeof import("./context.js"); -async function importContextModule(): Promise { - await flushAsyncWarmup(); - return contextModule; -} - -async function importFreshContextModule(): Promise { +beforeAll(async () => { vi.resetModules(); - const module = await import("./context.js"); - await flushAsyncWarmup(); - return module; -} + context = await import("./context.js"); +}); -async function importResolveContextTokensForModel() { - const { resolveContextTokensForModel } = await importContextModule(); - return resolveContextTokensForModel; -} +beforeEach(() => { + state.config = {}; + state.catalog = { entries: [], staticEntries: [] }; + state.loadConfig.mockReset().mockImplementation(() => state.config); + state.loadOwner.mockReset().mockImplementation(async () => ({ modelCatalog: state.catalog })); + state.publishedOwner.mockReset().mockImplementation(() => ({ + config: state.config, + modelCatalog: state.catalog, + })); + context.resetContextWindowCacheForTest(); +}); -describe("lookupContextTokens", () => { - beforeAll(async () => { - contextModule = await importFreshContextModule(); - }); +afterEach(() => { + context.resetContextWindowCacheForTest(); + vi.useRealTimers(); +}); - beforeEach(() => { - contextTestState.loadConfigImpl = () => ({}); - contextTestState.discoveredModels = []; - contextTestState.staticCatalogModels = []; - contextTestState.runtimeConfigSnapshot = null; - contextTestState.runtimeConfigSourceSnapshot = null; - contextTestState.loadModelCatalogOwnerSnapshot.mockClear(); - contextTestState.loadModelCatalogOwnerSnapshot.mockImplementation(async () => ({ - modelCatalog: { - entries: contextTestState.discoveredModels, - routeVariants: [], - staticEntries: contextTestState.staticCatalogModels, - }, - })); - contextTestState.getPublishedModelCatalogOwnerSnapshot.mockClear(); - contextTestState.getPublishedModelCatalogOwnerSnapshot.mockImplementation(() => ({ - config: contextTestState.loadConfigImpl() as OpenClawConfig, - modelCatalog: { - entries: contextTestState.discoveredModels, - routeVariants: [], - staticEntries: contextTestState.staticCatalogModels, - }, - })); - contextModule.resetContextWindowCacheForTest(); - }); - - afterEach(async () => { - contextModule.resetContextWindowCacheForTest(); - await flushAsyncWarmup(); - }); - - it("keeps a lower configured window as a cap on discovered context tokens", async () => { - mockDiscoveryDeps([{ provider: "openai", id: "gpt-5.5", contextTokens: 272_000 }], { - openai: { - models: [{ id: "gpt-5.5", contextWindow: 128_000 }], - }, - }); - - const { lookupContextTokens } = await importContextModule(); - lookupContextTokens("gpt-5.5"); - await flushAsyncWarmup(); - - expect(lookupContextTokens("gpt-5.5")).toBe(128_000); - }); - - it("rehydrates config-backed cache entries after module reload when runtime config survives", async () => { - // The shared runtime snapshot should survive module reloads so lookups do - // not synchronously reread config on every import. - const firstLoadConfigMock = vi.fn(() => ({ - models: { - providers: { - openrouter: { - models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }], - }, - }, - }, - })); - mockContextModuleDeps(firstLoadConfigMock); - - let { lookupContextTokens } = await importFreshContextModule(); - expect(lookupContextTokens("openrouter/claude-sonnet", { allowAsyncLoad: false })).toBe( +describe("context cache lifecycle", () => { + it("rehydrates configured entries after module reload without rereading config", async () => { + state.config = config("openrouter", model("openrouter/claude-sonnet", 321_000)); + expect(context.lookupContextTokens("openrouter/claude-sonnet", { allowAsyncLoad: false })).toBe( 321_000, ); - expect(firstLoadConfigMock).toHaveBeenCalledTimes(1); + expect(state.loadConfig).toHaveBeenCalledTimes(1); vi.resetModules(); - - const secondLoadConfigMock = vi.fn(() => { + state.loadConfig.mockReset().mockImplementation(() => { throw new Error("config should come from shared runtime state"); }); - mockContextModuleDeps(secondLoadConfigMock); - - ({ lookupContextTokens } = await importFreshContextModule()); - expect(lookupContextTokens("openrouter/claude-sonnet", { allowAsyncLoad: false })).toBe( - 321_000, - ); - expect(secondLoadConfigMock).not.toHaveBeenCalled(); - }); - - it("retries config loading after backoff when an initial load fails", async () => { - vi.useFakeTimers(); - const loadConfigMock = vi - .fn() - .mockImplementationOnce(() => { - throw new Error("transient"); - }) - .mockImplementation(() => ({ - models: { - providers: { - openrouter: { - models: [{ id: "openrouter/claude-sonnet", contextWindow: 654_321 }], - }, - }, - }, - })); - - mockContextModuleDeps(loadConfigMock); - - try { - const { lookupContextTokens } = await importContextModule(); - expect(lookupContextTokens("openrouter/claude-sonnet")).toBeUndefined(); - expect(loadConfigMock).toHaveBeenCalledTimes(1); - expect(lookupContextTokens("openrouter/claude-sonnet")).toBeUndefined(); - expect(loadConfigMock).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1_000); - expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(654_321); - expect(loadConfigMock).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - - it("replaces a configured token override before refreshing discovery", async () => { - mockContextDeps({ - getRuntimeConfig: () => ({ - models: { - providers: { - openrouter: { - models: [ - { - id: "claude-sonnet", - contextWindow: 321_000, - contextTokens: 111_000, - }, - ], - }, - }, - }, - }), - discoveredModels: [{ provider: "openrouter", id: "claude-sonnet", contextWindow: 654_321 }], - }); - - const { lookupContextTokens, refreshContextWindowCache } = await importContextModule(); - expect(lookupContextTokens("claude-sonnet", { allowAsyncLoad: false })).toBe(111_000); - - const nextConfig = createContextOverrideConfig("openrouter", "claude-sonnet", 222_000); - contextTestState.discoveredModels = [ - { provider: "openrouter", id: "claude-sonnet", contextWindow: 222_000 }, - ]; - const refreshPromise = refreshContextWindowCache(nextConfig); - expect(lookupContextTokens("claude-sonnet", { allowAsyncLoad: false })).toBe(222_000); - await refreshPromise; - - expect(lookupContextTokens("claude-sonnet", { allowAsyncLoad: false })).toBe(222_000); - }); - - it("returns the smaller window when the same bare model id is discovered under multiple providers", async () => { - // Bare model ids are ambiguous across providers; the conservative minimum - // prevents over-budget prompts when callers lack provider context. - mockDiscoveryDeps([ - { id: "gemini-3.1-pro-preview", contextWindow: 1_048_576 }, - { id: "gemini-3.1-pro-preview", contextWindow: 128_000 }, - ]); - - const { lookupContextTokens } = await importContextModule(); - lookupContextTokens("gemini-3.1-pro-preview"); - await flushAsyncWarmup(); - // Conservative minimum: bare-id cache feeds runtime flush/compaction paths. - expect(lookupContextTokens("gemini-3.1-pro-preview")).toBe(128_000); - }); - - it("loads the read-only catalog during warmup and preserves provider-owned context metadata", async () => { - const config = { - agents: { defaults: { workspace: "/tmp/context-catalog-workspace" } }, - } as OpenClawConfig; - mockDiscoveryDeps([ - { - id: "anthropic/claude-opus-4.7-20260219", - provider: "anthropic", - contextWindow: 200_000, - }, - ]); - contextTestState.loadConfigImpl = () => config; - - const { lookupContextTokens } = await importContextModule(); - lookupContextTokens("anthropic/claude-opus-4.7-20260219"); - await flushAsyncWarmup(); - - expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledOnce(); - expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledWith({ - config, - readOnly: true, - }); - expect(lookupContextTokens("anthropic/claude-opus-4.7-20260219")).toBe( - ANTHROPIC_CONTEXT_1M_TOKENS, - ); - }); - - it("keeps ordinary cache loading on the exact owner path", async () => { - const config = { - ...createContextOverrideConfig("anthropic", "claude-opus-4.7-20260219", 200_000), - agents: { - ownership: "explicit" as const, - defaults: { systemAgent: { agentId: "beta" } }, - entries: { alpha: {}, beta: {} }, - }, - } satisfies OpenClawConfig; - mockDiscoveryDeps([ - { - id: "anthropic/claude-opus-4.7-20260219", - provider: "anthropic", - contextWindow: 200_000, - }, - ]); - - const { ensureContextWindowCacheLoaded, lookupContextTokens } = await importContextModule(); - await ensureContextWindowCacheLoaded(config); - - expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledWith({ - config, - readOnly: true, - }); - expect(contextTestState.getPublishedModelCatalogOwnerSnapshot).not.toHaveBeenCalled(); + const reloaded = await import("./context.js"); expect( - lookupContextTokens("anthropic/claude-opus-4.7-20260219", { allowAsyncLoad: false }), - ).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); + reloaded.lookupContextTokens("openrouter/claude-sonnet", { allowAsyncLoad: false }), + ).toBe(321_000); + expect(state.loadConfig).not.toHaveBeenCalled(); }); - it("warms from the current Gateway-published owner without hashing a fallback owner key", async () => { - const requestedConfig = { - ...createContextOverrideConfig("synthetic", "stale-model", 111_000), - agents: { - ownership: "explicit" as const, - defaults: { systemAgent: { agentId: "beta" } }, - entries: { alpha: {}, beta: {} }, - }, - } satisfies OpenClawConfig; - const publishedConfig = createContextOverrideConfig("synthetic", "current-model", 222_000); - contextTestState.getPublishedModelCatalogOwnerSnapshot.mockReturnValueOnce({ - config: publishedConfig, - modelCatalog: { - entries: [{ id: "discovered-model", provider: "synthetic", contextWindow: 64_000 }], - routeVariants: [], - staticEntries: [], - }, + it("retries config loading only after backoff", async () => { + vi.useFakeTimers(); + state.config = config("openrouter", model("openrouter/claude-sonnet", 654_321)); + state.loadConfig.mockImplementationOnce(() => { + throw new Error("transient"); }); + expect(context.lookupContextTokens("openrouter/claude-sonnet")).toBeUndefined(); + expect(state.loadConfig).toHaveBeenCalledTimes(1); + expect(context.lookupContextTokens("openrouter/claude-sonnet")).toBeUndefined(); + expect(state.loadConfig).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_000); + expect(context.lookupContextTokens("openrouter/claude-sonnet")).toBe(654_321); + expect(state.loadConfig).toHaveBeenCalledTimes(2); + await context.ensureContextWindowCacheLoaded(); + }); - const { lookupContextTokens, prewarmContextWindowCacheAfterReady } = - await importContextModule(); - await prewarmContextWindowCacheAfterReady({ config: requestedConfig }); + it("replaces configured token overrides before refreshing discovery", async () => { + state.config = config("openrouter", model("claude-sonnet", 321_000, 111_000)); + expect(context.lookupContextTokens("claude-sonnet", { allowAsyncLoad: false })).toBe(111_000); + state.catalog.entries = [ + { provider: "openrouter", id: "claude-sonnet", contextWindow: 654_321 }, + ]; + const pending = context.refreshContextWindowCache( + config("openrouter", model("claude-sonnet", 222_000)), + ); + expect(context.lookupContextTokens("claude-sonnet", { allowAsyncLoad: false })).toBe(222_000); + await pending; + expect(context.lookupContextTokens("claude-sonnet", { allowAsyncLoad: false })).toBe(222_000); + }); - expect(contextTestState.getPublishedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({ - config: requestedConfig, + it("loads exact read-only metadata rather than the Gateway-published owner", async () => { + const cfg = config("anthropic", model("claude-opus-4.7-20260219", 200_000)); + state.catalog.entries = [ + { + id: "anthropic/claude-opus-4.7-20260219", + provider: "anthropic", + contextWindow: 200_000, + }, + ]; + await context.ensureContextWindowCacheLoaded(cfg); + expect(state.loadOwner).toHaveBeenCalledWith({ config: cfg, readOnly: true }); + expect(state.publishedOwner).not.toHaveBeenCalled(); + expect( + context.lookupContextTokens("anthropic/claude-opus-4.7-20260219", { allowAsyncLoad: false }), + ).toBe(1_000_000); + }); + + it("warms configured, discovered, and static windows from the published owner", async () => { + const requested = config("synthetic", model("stale-model", 111_000)); + state.config = config("synthetic", model("current-model", 222_000)); + state.catalog = { + entries: [{ id: "discovered-model", provider: "synthetic", contextWindow: 64_000 }], + staticEntries: [{ id: "static-model", provider: "google", contextWindow: 1_048_576 }], + }; + await context.prewarmContextWindowCacheAfterReady({ config: requested }); + expect(state.publishedOwner).toHaveBeenCalledWith({ + config: requested, allowGatewaySubagentBinding: true, }); - expect(contextTestState.loadModelCatalogOwnerSnapshot).not.toHaveBeenCalled(); - expect( - lookupContextTokens("current-model", { - allowAsyncLoad: false, - skipRuntimeConfigLoad: true, - }), - ).toBe(222_000); - expect( - lookupContextTokens("discovered-model", { - allowAsyncLoad: false, - skipRuntimeConfigLoad: true, - }), - ).toBe(64_000); - expect( - lookupContextTokens("stale-model", { - allowAsyncLoad: false, - skipRuntimeConfigLoad: true, - }), - ).toBeUndefined(); + expect(state.loadOwner).not.toHaveBeenCalled(); + const options = { allowAsyncLoad: false, skipRuntimeConfigLoad: true }; + expect(context.lookupContextTokens("current-model", options)).toBe(222_000); + expect(context.lookupContextTokens("discovered-model", options)).toBe(64_000); + expect(context.lookupContextTokens("static-model", options)).toBe(1_048_576); + expect(context.lookupContextTokens("stale-model", options)).toBeUndefined(); }); - it("retires a failed published-owner load so exact request-time loading can recover", async () => { - contextTestState.getPublishedModelCatalogOwnerSnapshot.mockReturnValueOnce(undefined); - const config = createContextOverrideConfig("synthetic", "recovered-model", 96_000); - contextTestState.loadConfigImpl = () => config; - - const { ensureContextWindowCacheLoaded, prewarmContextWindowCacheAfterReady } = - await importContextModule(); - await expect(prewarmContextWindowCacheAfterReady({ config })).resolves.toBeUndefined(); + it("retires failed prewarm so exact request-time loading can recover", async () => { + state.publishedOwner.mockReturnValueOnce(undefined); + state.config = config("synthetic", model("recovered-model", 96_000)); + await context.prewarmContextWindowCacheAfterReady({ config: state.config }); expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).toBeNull(); expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBeNull(); expect(CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig).toBeUndefined(); - - await ensureContextWindowCacheLoaded(); - expect(contextTestState.loadModelCatalogOwnerSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ config, readOnly: true }), - ); + await context.ensureContextWindowCacheLoaded(); + expect(state.loadOwner).toHaveBeenCalledWith({ config: state.config, readOnly: true }); + expect(context.lookupContextTokens("recovered-model", { allowAsyncLoad: false })).toBe(96_000); }); - it("retires stale discovered metadata when exact catalog loading fails", async () => { + it("retires stale discovery when exact catalog loading fails", async () => { replaceDiscoveredContextTokenCache(new Map([["stale-model", 999_000]])); - contextTestState.loadModelCatalogOwnerSnapshot.mockRejectedValueOnce( - new Error("catalog unavailable"), + state.loadOwner.mockRejectedValueOnce(new Error("catalog unavailable")); + await context.ensureContextWindowCacheLoaded( + config("synthetic", model("current-model", 96_000)), ); - - const { ensureContextWindowCacheLoaded, lookupContextTokens } = await importContextModule(); - await ensureContextWindowCacheLoaded( - createContextOverrideConfig("synthetic", "current-model", 96_000), - ); - expect( - lookupContextTokens("stale-model", { - allowAsyncLoad: false, - skipRuntimeConfigLoad: true, - }), + context.lookupContextTokens("stale-model", { skipRuntimeConfigLoad: true }), ).toBeUndefined(); }); it("retires an unpublished prewarm marker when shutdown cancels during import", async () => { let cancelled = false; - const pending = contextModule.prewarmContextWindowCacheAfterReady({ + const pending = context.prewarmContextWindowCacheAfterReady({ config: {}, isCancelled: () => cancelled, }); cancelled = true; - await pending; expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).toBeNull(); expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBeNull(); @@ -485,407 +200,157 @@ describe("lookupContextTokens", () => { const legacyLoadPromise = Promise.resolve(); CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = legacyLoadPromise; CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration = null; - CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = createContextOverrideConfig( + CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = config( "fresh-provider", - "fresh-model", + model("fresh-model", 123_456), + ); + await context.ensureContextWindowCacheLoaded(); + expect(context.lookupContextTokens("fresh-model", { skipRuntimeConfigLoad: true })).toBe( 123_456, ); - - await contextModule.ensureContextWindowCacheLoaded(); - - expect( - contextModule.lookupContextTokens("fresh-model", { - allowAsyncLoad: false, - skipRuntimeConfigLoad: true, - }), - ).toBe(123_456); expect(CONTEXT_WINDOW_RUNTIME_STATE.loadPromise).not.toBe(legacyLoadPromise); expect(CONTEXT_WINDOW_RUNTIME_STATE.loadGeneration).toBe( CONTEXT_WINDOW_RUNTIME_STATE.generation, ); }); - it("status waits for pending context warmup but releases on timeout", async () => { + it("releases status waits on timeout while warmup is pending", async () => { vi.useFakeTimers(); - try { - contextTestState.loadModelCatalogOwnerSnapshot.mockImplementationOnce( - () => new Promise(() => {}), - ); - - const { ensureContextWindowCacheLoaded, waitForContextWindowCacheLoad } = - await importContextModule(); - void ensureContextWindowCacheLoaded( - createContextOverrideConfig("anthropic", "claude", 200_000), - ); - - const waitResult = waitForContextWindowCacheLoad({ timeoutMs: 5 }); - await vi.advanceTimersByTimeAsync(5); - - await expect(waitResult).resolves.toBe("timeout"); - } finally { - vi.useRealTimers(); - } + state.loadOwner.mockImplementationOnce(() => new Promise(() => {})); + void context.ensureContextWindowCacheLoaded(config("anthropic", model("claude", 200_000))); + const waiting = context.waitForContextWindowCacheLoad({ timeoutMs: 5 }); + await vi.advanceTimersByTimeAsync(5); + await expect(waiting).resolves.toBe("timeout"); }); +}); - it("warms context metadata from bundled provider static catalogs", async () => { - contextTestState.staticCatalogModels = [ - { - id: "gemini-3.1-pro-preview", - provider: "google", - contextWindow: 1_048_576, - }, - ]; - - const { lookupContextTokens } = await importContextModule(); - lookupContextTokens("gemini-3.1-pro-preview"); - await flushAsyncWarmup(); - - expect(lookupContextTokens("gemini-3.1-pro-preview")).toBe(1_048_576); - }); - - it("resolveContextTokensForModel handles self-prefixed provider-owned discovery ids", async () => { - mockDiscoveryDeps([ - { - provider: "github-copilot", - id: "github-copilot/gemini-3.1-pro-preview", - contextWindow: 128_000, - }, +describe("provider-owned context lookup", () => { + it("uses self-prefixed provider discovery ahead of the bare cross-provider minimum", async () => { + state.catalog.entries = [ + { provider: "github-copilot", id: "gemini-3.1-pro-preview", contextWindow: 128_000 }, + { id: "gemini-3.1-pro-preview", contextWindow: 128_000 }, { provider: "google-gemini-cli", id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576, }, - ]); - - const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule(); - lookupContextTokens("google-gemini-cli/gemini-3.1-pro-preview"); - await flushAsyncWarmup(); - - const result = resolveContextTokensForModel({ - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - }); - expect(result).toBe(1_048_576); + ]; + await context.ensureContextWindowCacheLoaded(); + expect( + context.resolveContextTokensForModel({ + provider: "google-gemini-cli", + model: "gemini-3.1-pro-preview", + }), + ).toBe(1_048_576); + expect(context.lookupContextTokens("gemini-3.1-pro-preview")).toBe(128_000); }); it.each([ { - name: "matches a bare configured row for a provider-self-prefixed runtime model", - model: "kilocode/kilo-auto/balanced", - configuredModels: [createConfiguredModel("kilo-auto/balanced", 900_000)], + name: "falls back to a bare configured row", + selected: "kilocode/kilo-auto/balanced", + models: [model("kilo-auto/balanced", 900_000, 900_000)], expected: 900_000, }, { - name: "prefers the exact qualified row over an earlier bare row", - model: "kilocode/kilo-auto/balanced", - configuredModels: [ - createConfiguredModel("kilo-auto/balanced", 111_000), - createConfiguredModel("kilocode/kilo-auto/balanced", 900_000), + name: "prefers an exact qualified row over an earlier bare row", + selected: "kilocode/kilo-auto/balanced", + models: [ + model("kilo-auto/balanced", 111_000, 111_000), + model("kilocode/kilo-auto/balanced", 900_000, 900_000), ], expected: 900_000, }, { - name: "prefers the exact bare row over an earlier self-prefixed row", - model: "kilo-auto/balanced", - configuredModels: [ - createConfiguredModel("kilocode/kilo-auto/balanced", 2_000), - createConfiguredModel("kilo-auto/balanced", 128_000), + name: "prefers an exact bare row over an earlier self-prefixed row", + selected: "kilo-auto/balanced", + models: [ + model("kilocode/kilo-auto/balanced", 2_000, 2_000), + model("kilo-auto/balanced", 128_000, 128_000), ], expected: 128_000, }, - { - name: "keeps the exact bare row ahead of a later self-prefixed row", - model: "kilo-auto/balanced", - configuredModels: [ - createConfiguredModel("kilo-auto/balanced", 128_000), - createConfiguredModel("kilocode/kilo-auto/balanced", 2_000), - ], - expected: 128_000, - }, - { - name: "does not strip another provider's prefix", - model: "openrouter/anthropic/claude-sonnet-5", - configuredModels: [createConfiguredModel("anthropic/claude-sonnet-5", 900_000)], - expected: 128_000, - }, - ])("$name", async ({ model, configuredModels, expected }) => { - mockDiscoveryDeps([{ provider: "kilocode", id: model, contextWindow: 128_000 }]); - const cfg = { - models: { - providers: { - kilocode: { baseUrl: "https://example.invalid", models: configuredModels }, - }, - }, - } satisfies OpenClawConfig; - const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule(); - lookupContextTokens(model); - await flushAsyncWarmup(); - - expect(resolveContextTokensForModel({ cfg, provider: "kilocode", model })).toBe(expected); - }); - - it("bounds a per-model cap by the Anthropic fixed contract", async () => { - mockDiscoveryDeps([]); - const resolveContextTokensForModel = await importResolveContextTokensForModel(); - + ])("$name", ({ selected, models, expected }) => { expect( - resolveContextTokensForModel({ - cfg: { - models: { - providers: { - anthropic: { - models: [ - { - id: "claude-sonnet-4-6", - contextWindow: 2_000_000, - contextTokens: 1_200_000, - }, - ], - }, - }, - }, - } as never, - provider: "anthropic", - model: "claude-sonnet-4-6", + context.resolveContextTokensForModel({ + cfg: config("kilocode", ...models), + provider: "kilocode", + model: selected, }), - ).toBe(1_000_000); + ).toBe(expected); }); - it("bounds an authored effective cap by a smaller authored context window", async () => { - mockDiscoveryDeps([]); - const { resolveContextTokensForModel, resolveModelContextTokenProjection } = - await importContextModule(); - const params = { - cfg: { - models: { - providers: { - custom: { - baseUrl: "https://example.invalid", - models: [{ ...createConfiguredModel("wide", 1_000_000), contextWindow: 128_000 }], - }, - }, - }, - }, - provider: "custom", - model: "wide", - allowAsyncLoad: false, - } satisfies Parameters[0]; - - expect(resolveModelContextTokenProjection(params)).toEqual({ - contextTokens: 128_000, - authoredContextTokens: 1_000_000, - }); - expect(resolveContextTokensForModel(params)).toBe(128_000); - }); - - it("resolveContextTokensForModel honors configured overrides when provider keys use mixed case", async () => { - mockDiscoveryDeps([{ id: "openrouter/anthropic/claude-sonnet-4-5", contextWindow: 1_048_576 }]); - - const cfg = createContextOverrideConfig(" OpenRouter ", "anthropic/claude-sonnet-4-5", 200_000); - const resolveContextTokensForModel = await importResolveContextTokensForModel(); - - const result = resolveContextTokensForModel({ - cfg: cfg as never, - provider: "openrouter", - model: "anthropic/claude-sonnet-4-5", - }); - expect(result).toBe(200_000); - }); - - it("resolveContextTokensForModel treats explicit config as authoritative for read-only misses", async () => { - const loadConfig = vi.fn(() => { - throw new Error("runtime config should not be loaded"); - }); - mockContextModuleDeps(loadConfig); - const resolveContextTokensForModel = await importResolveContextTokensForModel(); - - const result = resolveContextTokensForModel({ - cfg: { agents: { defaults: {} } } as never, - provider: "openai", - model: "unknown-test-model", - fallbackContextTokens: 123_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(123_000); - expect(loadConfig).not.toHaveBeenCalled(); - }); - - it("resolveContextTokensForModel: config direct scan prevents OpenRouter qualified key collision for Google provider", async () => { - // When provider is explicitly "google" and cfg has a Google contextWindow - // override, the config direct scan returns it before any cache lookup — - // so the OpenRouter raw "google/gemini-2.5-pro" qualified entry is never hit. - // Real callers (status.summary.ts) always pass cfg when provider is explicit. - mockDiscoveryDeps([ - { + it("honors configured overrides with mixed-case provider keys", () => { + expect( + context.resolveContextTokensForModel({ + cfg: config(" OpenRouter ", model("anthropic/claude-sonnet-4-5", 200_000)), provider: "openrouter", - id: "google/gemini-2.5-pro", - contextWindow: 999_000, - }, - ]); - - const cfg = createContextOverrideConfig("google", "gemini-2.5-pro", 2_000_000); - const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule(); - lookupContextTokens("google/gemini-2.5-pro"); - await flushAsyncWarmup(); - - // Google with explicit cfg: config direct scan wins before any cache lookup. - const googleResult = resolveContextTokensForModel({ - cfg: cfg as never, - provider: "google", - model: "gemini-2.5-pro", - }); - expect(googleResult).toBe(2_000_000); - - // OpenRouter provider with slash model id: bare lookup finds the raw entry. - const openrouterResult = resolveContextTokensForModel({ - provider: "openrouter", - model: "google/gemini-2.5-pro", - }); - expect(openrouterResult).toBe(999_000); - - // The same raw key must not be treated as provider-owned Google metadata. - const googleUnconfiguredResult = resolveContextTokensForModel({ - provider: "google", - model: "gemini-2.5-pro", - }); - expect(googleUnconfiguredResult).toBeUndefined(); - }); - - it("resolveContextTokensForModel follows modelProvider aliases to per-model config", async () => { - mockDiscoveryDeps([]); - const cfg = createContextOverrideConfig("anthropic", "claude-custom", 180_000); - const resolveContextTokensForModel = await importResolveContextTokensForModel(); - - expect( - resolveContextTokensForModel({ - cfg: cfg as never, - provider: "fixture-cli", - modelProvider: "anthropic", - model: "anthropic/claude-custom", + model: "anthropic/claude-sonnet-4-5", }), - ).toBe(180_000); + ).toBe(200_000); }); - it("resolveContextTokensForModel prefers exact provider key over alias-normalized match", async () => { - // When both "bedrock" and "amazon-bedrock" exist as config keys (alias pattern), - // resolveConfiguredProviderContextWindow must return the exact-key match first, - // not the first normalized hit — mirroring embedded-agent-runner/model.ts behaviour. - mockDiscoveryDeps([]); + it("treats explicit config as authoritative for read-only misses", () => { + state.loadConfig.mockImplementation(() => { + throw new Error("runtime config should not load"); + }); + expect( + context.resolveContextTokensForModel({ + cfg: { agents: { defaults: {} } }, + provider: "openai", + model: "unknown-test-model", + fallbackContextTokens: 123_000, + allowAsyncLoad: false, + }), + ).toBe(123_000); + expect(state.loadConfig).not.toHaveBeenCalled(); + }); + it("does not confuse raw slash model ids with verified provider identity", async () => { + state.catalog.entries = [ + { provider: "openrouter", id: "google/gemini-2.5-pro", contextWindow: 999_000 }, + ]; + await context.ensureContextWindowCacheLoaded(); + const cfg = config("google", model("gemini-2.5-pro", 2_000_000)); + expect( + context.resolveContextTokensForModel({ cfg, provider: "google", model: "gemini-2.5-pro" }), + ).toBe(2_000_000); + expect( + context.resolveContextTokensForModel({ + provider: "openrouter", + model: "google/gemini-2.5-pro", + }), + ).toBe(999_000); + expect( + context.resolveContextTokensForModel({ provider: "google", model: "gemini-2.5-pro" }), + ).toBeUndefined(); + expect(context.resolveContextTokensForModel({ cfg, model: "google/gemini-2.5-pro" })).toBe( + 999_000, + ); + }); + + it("prefers exact provider keys over alias-normalized matches", () => { const cfg = { models: { providers: { - "amazon-bedrock": { models: [{ id: "claude-alias-test", contextWindow: 32_000 }] }, - bedrock: { models: [{ id: "claude-alias-test", contextWindow: 128_000 }] }, + ...config("amazon-bedrock", model("claude-alias-test", 32_000)).models?.providers, + ...config("bedrock", model("claude-alias-test", 128_000)).models?.providers, }, }, }; - - const { resolveContextTokensForModel } = await importContextModule(); - - // Exact key "bedrock" wins over the alias-normalized match "amazon-bedrock". - const bedrockResult = resolveContextTokensForModel({ - cfg: cfg as never, - provider: "bedrock", - model: "claude-alias-test", - }); - expect(bedrockResult).toBe(128_000); - - // Exact key "amazon-bedrock" wins (no alias lookup needed). - const canonicalResult = resolveContextTokensForModel({ - cfg: cfg as never, - provider: "amazon-bedrock", - model: "claude-alias-test", - }); - expect(canonicalResult).toBe(32_000); - }); - - it("resolveContextTokensForModel(model-only) does not apply config scan for inferred provider", async () => { - // Model-only calls can infer the wrong provider from slash-containing model - // IDs. Config scans are reserved for explicit providers to avoid that. - mockDiscoveryDeps([{ id: "google/gemini-2.5-pro", contextWindow: 999_000 }]); - - const cfg = createContextOverrideConfig("google", "gemini-2.5-pro", 2_000_000); - const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule(); - lookupContextTokens("google/gemini-2.5-pro"); - await flushAsyncWarmup(); - - // model-only call (no explicit provider) must NOT apply config direct scan. - // Falls through to bare cache lookup: "google/gemini-2.5-pro" → 999k ✓. - const modelOnlyResult = resolveContextTokensForModel({ - cfg: cfg as never, - model: "google/gemini-2.5-pro", - // no provider - }); - expect(modelOnlyResult).toBe(999_000); - - // Explicit provider still uses config scan ✓. - const explicitResult = resolveContextTokensForModel({ - cfg: cfg as never, - provider: "google", - model: "gemini-2.5-pro", - }); - expect(explicitResult).toBe(2_000_000); - }); - - it("resolveContextTokensForModel(model-only) does not force 1M for inferred anthropic opus 4.7 ids", async () => { - mockDiscoveryDeps([{ id: "anthropic/claude-opus-4.7-20260219", contextWindow: 200_000 }]); - - const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule(); - lookupContextTokens("anthropic/claude-opus-4.7-20260219"); - await flushAsyncWarmup(); - - const result = resolveContextTokensForModel({ - model: "anthropic/claude-opus-4.7-20260219", - fallbackContextTokens: 200_000, - }); - - expect(result).toBe(200_000); - }); - - it("resolveContextTokensForModel: qualified key beats bare min when provider is explicit (original #35976 fix)", async () => { - // Regression: when both "gemini-3.1-pro-preview" (bare, min=128k) AND - // "google-gemini-cli/gemini-3.1-pro-preview" (qualified, 1M) are in cache, - // an explicit-provider call must return the provider-specific qualified value, - // not the collided bare minimum. - mockDiscoveryDeps([ - { - provider: "github-copilot", - id: "gemini-3.1-pro-preview", - contextWindow: 128_000, - }, - { id: "gemini-3.1-pro-preview", contextWindow: 128_000 }, - { - provider: "google-gemini-cli", - id: "gemini-3.1-pro-preview", - contextWindow: 1_048_576, - }, - ]); - - const { lookupContextTokens, resolveContextTokensForModel } = await importContextModule(); - lookupContextTokens("google-gemini-cli/gemini-3.1-pro-preview"); - await flushAsyncWarmup(); - - // Provider-owned 1M metadata wins over the bare 128k cross-provider minimum. - const result = resolveContextTokensForModel({ - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - }); - expect(result).toBe(1_048_576); - }); - - it("resolveContextTokensForModel does not match explicit provider id variants before config lookup", async () => { - mockDiscoveryDeps([]); - - const cfg = createContextOverrideConfig("z.ai", "glm-5", 256_000); - const { resolveContextTokensForModel } = await importContextModule(); - - const result = resolveContextTokensForModel({ - cfg: cfg as never, - provider: "z-ai", - model: "glm-5", - }); - expect(result).toBeUndefined(); + expect( + context.resolveContextTokensForModel({ + cfg, + provider: "bedrock", + model: "claude-alias-test", + }), + ).toBe(128_000); + expect( + context.resolveContextTokensForModel({ + cfg, + provider: "amazon-bedrock", + model: "claude-alias-test", + }), + ).toBe(32_000); }); }); diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts index 37660064224f..502add6e544e 100644 --- a/src/agents/context.test.ts +++ b/src/agents/context.test.ts @@ -1,18 +1,13 @@ -// Covers context-window cache application and session-manager runtime registry. -import { describe, expect, it, vi } from "vitest"; -import { createSessionManagerRuntimeRegistry } from "./agent-hooks/session-manager-runtime-registry.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getContextWindowCaches, providerContextTokenCacheKey } from "./context-cache.js"; import { - ANTHROPIC_CONTEXT_1M_TOKENS, - ANTHROPIC_FABLE_CONTEXT_TOKENS, - ANTHROPIC_MYTHOS_5_CONTEXT_TOKENS, - ANTHROPIC_OPUS_5_CONTEXT_TOKENS, - ANTHROPIC_SONNET_5_CONTEXT_TOKENS, - ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS, applyConfiguredContextWindows, applyDiscoveredContextWindows, resetContextWindowCacheForTest, resolveContextTokensForModel, + resolveModelContextTokenProjection, } from "./context.js"; vi.mock("../config/config.js", () => ({ @@ -20,162 +15,57 @@ vi.mock("../config/config.js", () => ({ projectConfigOntoRuntimeSourceSnapshot: (config: unknown) => config, })); -function testModelContextWindow(id: string, contextWindow: number) { +function modelConfig( + provider: string, + id: string, + limits: Partial>, +): OpenClawConfig { return { - id, - name: id, - reasoning: false, - input: ["text" as const], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: 4096, + models: { + providers: { + [provider]: { + baseUrl: "https://example.invalid", + models: [ + { + id, + name: id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 4096, + ...limits, + }, + ], + }, + }, + }, }; } -describe("applyDiscoveredContextWindows", () => { - it("stores provider-qualified entries independently", () => { - // Provider-qualified keys retain their exact discovered value; only bare - // keys collapse to the conservative cross-provider minimum. - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [ - { id: "github-copilot/gemini-3.1-pro-preview", contextWindow: 128_000 }, - { id: "google-gemini-cli/gemini-3.1-pro-preview", contextWindow: 1_048_576 }, - ], - }); +function resolve(params: Parameters[0]) { + return resolveContextTokensForModel({ allowAsyncLoad: false, ...params }); +} - expect(cache.get("github-copilot/gemini-3.1-pro-preview")).toBe(128_000); - expect(cache.get("google-gemini-cli/gemini-3.1-pro-preview")).toBe(1_048_576); +function discover(models: Parameters[0]["models"]) { + applyDiscoveredContextWindows({ cache: getContextWindowCaches().discoveredTokenCache, models }); +} + +beforeEach(resetContextWindowCacheForTest); +afterEach(resetContextWindowCacheForTest); + +describe("context cache projection", () => { + it("prefers discovered contextTokens over the native window", () => { + discover([{ id: "gpt-5.4", contextWindow: 1_050_000, contextTokens: 272_000 }]); + expect(resolve({ model: "gpt-5.4" })).toBe(272_000); }); - it("prefers discovered contextTokens over contextWindow", () => { - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [{ id: "gpt-5.4", contextWindow: 1_050_000, contextTokens: 272_000 }], - }); - - expect(cache.get("gpt-5.4")).toBe(272_000); + it("keeps unowned CLI discovery at its reported window", () => { + discover([{ id: "claude-cli/claude-opus-4.7-20260219", contextWindow: 200_000 }]); + expect(resolve({ model: "claude-cli/claude-opus-4.7-20260219" })).toBe(200_000); }); - it("keeps bare claude-cli GA variants at the discovered window", () => { - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [ - { id: "claude-cli/claude-opus-4.8-20260514", contextWindow: 200_000 }, - { id: "claude-cli/claude-opus-4.7-20260219", contextWindow: 200_000 }, - { id: "claude-cli/claude-sonnet-4-6", contextWindow: 200_000 }, - ], - }); - - expect(cache.get("claude-cli/claude-opus-4.8-20260514")).toBe(200_000); - expect(cache.get("claude-cli/claude-opus-4.7-20260219")).toBe(200_000); - expect(cache.get("claude-cli/claude-sonnet-4-6")).toBe(200_000); - }); - - it("does not upgrade non-Anthropic GA 1M model ids from discovery", () => { - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [{ id: "github-copilot/claude-opus-4.7", contextWindow: 128_000 }], - }); - - expect(cache.get("github-copilot/claude-opus-4.7")).toBe(128_000); - }); - - it("does not upgrade provider-qualified anthropic GA 1M discovery ids without verified ownership", () => { - // A slash-prefixed id alone is not proof that Anthropic owns the metadata; - // discovery must report provider ownership before applying the 1M override. - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [{ id: "anthropic/claude-opus-4.7-20260219", contextWindow: 200_000 }], - }); - - expect(cache.get("anthropic/claude-opus-4.7-20260219")).toBe(200_000); - }); - - it("upgrades provider-owned anthropic GA 1M discovery ids", () => { - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [ - { - id: "anthropic/claude-opus-4.7-20260219", - provider: "anthropic", - contextWindow: 200_000, - }, - ], - }); - - expect(cache.get("anthropic/claude-opus-4.7-20260219")).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); - }); - - it("does not upgrade bare GA 1M discovery ids without verified ownership", () => { - const cache = new Map(); - applyDiscoveredContextWindows({ - cache, - models: [{ id: "claude-opus-4.7", contextWindow: 128_000 }], - }); - - expect(cache.get("claude-opus-4.7")).toBe(128_000); - }); -}); - -describe("applyConfiguredContextWindows", () => { - it("writes bare model id to cache; does not touch raw provider-qualified discovery entries", () => { - // Discovery stored a raw provider-qualified entry. Config writes the bare - // key and the collision-free provider-owned key without touching raw keys. - const cache = new Map([["openrouter/anthropic/claude-opus-4-6", 1_000_000]]); - const windowCache = new Map(); - applyConfiguredContextWindows({ - cache, - windowCache, - modelsConfig: { - providers: { - openrouter: { - models: [{ id: "anthropic/claude-opus-4-6", contextWindow: 200_000 }], - }, - }, - }, - }); - - expect(windowCache.get("anthropic/claude-opus-4-6")).toBe(200_000); - expect( - windowCache.get(providerContextTokenCacheKey("openrouter", "anthropic/claude-opus-4-6")), - ).toBe(200_000); - // Discovery entry is untouched — no synthetic write that could corrupt - // an unrelated provider's raw slash-containing model ID. - expect(cache.get("openrouter/anthropic/claude-opus-4-6")).toBe(1_000_000); - }); - - it("writes provider-owned bare keys for self-prefixed configured ids", () => { - const cache = new Map(); - applyConfiguredContextWindows({ - cache, - windowCache: new Map(), - modelsConfig: { - providers: { - "google-gemini-cli": { - models: [ - { - id: "google-gemini-cli/gemini-3.1-pro-preview", - contextTokens: 1_000_000, - }, - ], - }, - }, - }, - }); - - expect( - cache.get(providerContextTokenCacheKey("google-gemini-cli", "gemini-3.1-pro-preview")), - ).toBe(1_000_000); - }); - - it("adds config-only model context windows and ignores invalid entries", () => { + it("adds valid configured windows and ignores invalid entries", () => { const cache = new Map(); const windowCache = new Map(); applyConfiguredContextWindows({ @@ -193,520 +83,159 @@ describe("applyConfiguredContextWindows", () => { }, }, }); - expect(windowCache.get("custom/model")).toBe(150_000); expect(windowCache.has("bad/model")).toBe(false); - }); -}); - -describe("createSessionManagerRuntimeRegistry", () => { - it("stores, reads, and clears values by object identity", () => { - const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); - const key = {}; - expect(registry.get(key)).toBeNull(); - registry.set(key, { value: 1 }); - expect(registry.get(key)).toEqual({ value: 1 }); - registry.set(key, null); - expect(registry.get(key)).toBeNull(); + expect(windowCache.has("")).toBe(false); }); - it("ignores non-object keys", () => { - const registry = createSessionManagerRuntimeRegistry<{ value: number }>(); - registry.set(null, { value: 1 }); - registry.set(123, { value: 1 }); - expect(registry.get(null)).toBeNull(); - expect(registry.get(123)).toBeNull(); - }); -}); - -describe("resolveContextTokensForModel", () => { - it.each(["claude-opus-4-7"])("keeps bare claude-cli/%s at its discovered window", (model) => { - resetContextWindowCacheForTest(); - try { - getContextWindowCaches().discoveredTokenCache.set( - providerContextTokenCacheKey("claude-cli", model), - 200_000, - ); - expect( - resolveContextTokensForModel({ - provider: "claude-cli", - model, - allowAsyncLoad: false, - }), - ).toBe(200_000); - } finally { - resetContextWindowCacheForTest(); - } - }); - - it.each(["claude-opus-4-7[1m]"])("resolves explicit claude-cli/%s to 1M", (model) => { + it("writes provider-owned bare keys for self-prefixed configured token caps", () => { + const cache = new Map(); + applyConfiguredContextWindows({ + cache, + windowCache: new Map(), + modelsConfig: { + providers: { + "google-gemini-cli": { + models: [ + { + id: "google-gemini-cli/gemini-3.1-pro-preview", + contextTokens: 1_000_000, + }, + ], + }, + }, + }, + }); expect( - resolveContextTokensForModel({ - provider: "claude-cli", - model, - allowAsyncLoad: false, - }), - ).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); + cache.get(providerContextTokenCacheKey("google-gemini-cli", "gemini-3.1-pro-preview")), + ).toBe(1_000_000); + }); +}); + +describe("context token resolution", () => { + it("can exclude unscoped discovery from provider-owned lookup", () => { + discover([{ id: "large", contextTokens: 32_000 }]); + const params = { provider: "claude-cli", model: "large" }; + expect(resolve({ ...params, allowUnscopedModelLookup: false })).toBeUndefined(); + expect(resolve(params)).toBe(32_000); }); - it("can exclude unscoped cache entries from provider-owned lookup", () => { - resetContextWindowCacheForTest(); - try { - applyDiscoveredContextWindows({ - cache: getContextWindowCaches().discoveredTokenCache, - models: [{ id: "large", contextTokens: 32_000 }], - }); - - expect( - resolveContextTokensForModel({ - provider: "claude-cli", - model: "large", - allowAsyncLoad: false, - allowUnscopedModelLookup: false, - }), - ).toBeUndefined(); - expect( - resolveContextTokensForModel({ - provider: "claude-cli", - model: "large", - allowAsyncLoad: false, - }), - ).toBe(32_000); - } finally { - resetContextWindowCacheForTest(); - } - }); - - it("returns 1M context when claude-cli context1m is enabled for a GA 1M model", () => { - const result = resolveContextTokensForModel({ - cfg: { - agents: { - defaults: { - models: { - "claude-cli/claude-opus-4-7": { - params: { context1m: true }, - }, - }, - }, - }, - }, - provider: "claude-cli", - model: "claude-opus-4-7", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); - }); - - it("lets a claude-cli model disable the global context1m default", () => { - const result = resolveContextTokensForModel({ - cfg: { - agents: { - defaults: { - params: { context1m: true }, - models: { - "claude-cli/claude-opus-4-7": { - params: { context1m: false }, - }, - }, - }, - }, - }, - provider: "claude-cli", - model: "claude-opus-4-7", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(200_000); - }); - - it.each(["fixture-cli"])( - "uses the caller-supplied model provider for the %s runtime", - (provider) => { - const result = resolveContextTokensForModel({ + it.each([ + [true, 1_000_000], + [false, 200_000], + ])("uses the model context1m setting %s over the global default", (context1m, expected) => { + expect( + resolve({ cfg: { - models: { - providers: { - anthropic: { - baseUrl: "https://api.anthropic.com", - models: [ - { - ...testModelContextWindow("claude-opus-4-7", 200_000), - contextTokens: 100_000, - }, - ], - }, + agents: { + defaults: { + params: { context1m: !context1m }, + models: { "claude-cli/claude-opus-4-7": { params: { context1m } } }, }, }, }, - provider, - modelProvider: "anthropic", + provider: "claude-cli", model: "claude-opus-4-7", fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(100_000); - }, - ); - - it.each([ - ["anthropic", "claude-fable-5", ANTHROPIC_FABLE_CONTEXT_TOKENS], - ["anthropic-vertex", "claude-fable-5", ANTHROPIC_FABLE_CONTEXT_TOKENS], - ["claude-cli", "claude-fable-5", ANTHROPIC_FABLE_CONTEXT_TOKENS], - ["anthropic", "claude-mythos-5", ANTHROPIC_MYTHOS_5_CONTEXT_TOKENS], - ["anthropic-vertex", "claude-mythos-5", ANTHROPIC_MYTHOS_5_CONTEXT_TOKENS], - ["claude-cli", "claude-mythos-5", 200_000], - ["anthropic", "claude-sonnet-5", ANTHROPIC_SONNET_5_CONTEXT_TOKENS], - ["anthropic-vertex", "claude-sonnet-5", ANTHROPIC_SONNET_5_CONTEXT_TOKENS], - ["claude-cli", "claude-sonnet-5", ANTHROPIC_SONNET_5_CONTEXT_TOKENS], - ["anthropic", "claude-opus-5", ANTHROPIC_OPUS_5_CONTEXT_TOKENS], - ["anthropic-vertex", "claude-opus-5", ANTHROPIC_OPUS_5_CONTEXT_TOKENS], - ["claude-cli", "claude-opus-5", ANTHROPIC_OPUS_5_CONTEXT_TOKENS], - ["anthropic", "claude-sonnet-4-6", ANTHROPIC_CONTEXT_1M_TOKENS], - ["anthropic-vertex", "claude-sonnet-4-6", ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS], - ])( - "returns the fixed context for unconfigured %s model %s", - (provider, modelId, expectedContextTokens) => { - const result = resolveContextTokensForModel({ - provider, - model: modelId, - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(expectedContextTokens); - }, - ); - - it("does not give fable-5 context window to claude-fable-50 (prefix boundary check)", () => { - const result = resolveContextTokensForModel({ - provider: "anthropic", - model: "claude-fable-50", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(200_000); - }); - - it("does not give mythos-5 context window to claude-mythos-50", () => { - const result = resolveContextTokensForModel({ - provider: "anthropic", - model: "claude-mythos-50", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(200_000); - }); - - it("clamps an authored Anthropic window to the fixed provider limit", () => { - expect( - resolveContextTokensForModel({ - cfg: { - models: { - providers: { - anthropic: { - baseUrl: "https://api.anthropic.com", - models: [testModelContextWindow("claude-sonnet-4-6", 2_000_000)], - }, - }, - }, - }, - provider: "anthropic", - model: "claude-sonnet-4-6", - allowAsyncLoad: false, }), - ).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); - }); - - it("keeps fixed Anthropic context above stale static native-window metadata", () => { - expect( - resolveContextTokensForModel({ - provider: "anthropic", - model: "claude-sonnet-4-6", - modelContextWindow: 200_000, - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }), - ).toBe(ANTHROPIC_CONTEXT_1M_TOKENS); + ).toBe(expected); }); it.each([ - ["anthropic", "claude-sonnet-4-6", ANTHROPIC_CONTEXT_1M_TOKENS], - ["claude-cli", "claude-mythos-5", 200_000], - ])( - "ignores a materialized lower context window for fixed %s model %s", - (provider, modelId, expectedContextTokens) => { - const result = resolveContextTokensForModel({ - cfg: { - models: { - providers: { - [provider]: { - baseUrl: "https://api.anthropic.com", - models: [testModelContextWindow(modelId, 200_000)], - }, - }, - }, - }, - provider, - model: modelId, - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); + ["anthropic", "claude-fable-5"], + ["anthropic-vertex", "claude-mythos-5"], + ["claude-cli", "claude-sonnet-5"], + ["claude-cli", "claude-opus-5"], + ["anthropic-vertex", "claude-sonnet-4-6"], + ["claude-cli", "claude-opus-4-7[1m]"], + ])("resolves the fixed window for %s/%s", (provider, model) => { + expect(resolve({ provider, model, fallbackContextTokens: 200_000 })).toBe(1_000_000); + }); - expect(result).toBe(expectedContextTokens); - }, - ); - - it("honors an explicit lower contextTokens cap for a fixed Anthropic model", () => { - const result = resolveContextTokensForModel({ - cfg: { - models: { - providers: { - anthropic: { - baseUrl: "https://api.anthropic.com", - models: [ - { - ...testModelContextWindow("claude-sonnet-4-6", 200_000), - contextTokens: 200_000, - }, - ], - }, - }, - }, - }, - provider: "anthropic", - model: "claude-sonnet-4-6", - fallbackContextTokens: 200_000, + it("retains authored cap provenance when a native window lowers the effective cap", () => { + const params = { + cfg: modelConfig("custom", "wide", { contextWindow: 128_000, contextTokens: 1_000_000 }), + provider: "custom", + model: "wide", allowAsyncLoad: false, + }; + expect(resolveModelContextTokenProjection(params)).toEqual({ + contextTokens: 128_000, + authoredContextTokens: 1_000_000, }); - - expect(result).toBe(200_000); + expect(resolve(params)).toBe(128_000); }); - it("keeps older Anthropic Sonnet 4.x models at the configured window when context1m is set", () => { - const result = resolveContextTokensForModel({ - cfg: { - models: { - providers: { - anthropic: { - baseUrl: "https://api.anthropic.com", - models: [testModelContextWindow("claude-sonnet-4-5", 200_000)], - }, - }, - }, - agents: { - defaults: { - models: { - "anthropic/claude-sonnet-4-5": { - params: { context1m: true }, - }, - }, - }, - }, - }, - provider: "anthropic", - model: "claude-sonnet-4-5", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, + it.each([ + [200_000, 200_000], + [1_200_000, 1_000_000], + ])("bounds an authored cap of %i by the fixed provider contract", (contextTokens, expected) => { + expect( + resolve({ + cfg: modelConfig("anthropic", "claude-sonnet-4-6", { + contextWindow: 2_000_000, + contextTokens, + }), + provider: "anthropic", + model: "claude-sonnet-4-6", + }), + ).toBe(expected); + }); + + it("uses the caller-supplied model provider for runtime aliases", () => { + expect( + resolve({ + cfg: modelConfig("anthropic", "claude-custom", { + contextWindow: 180_000, + contextTokens: 100_000, + }), + provider: "fixture-cli", + modelProvider: "anthropic", + model: "anthropic/claude-custom", + }), + ).toBe(100_000); + }); + + it("keeps configured token caps authoritative over lower discovery", () => { + discover([{ provider: "openai", id: "gpt-5.5", contextWindow: 272_000 }]); + const cfg = modelConfig("openai", "gpt-5.5", { contextTokens: 350_000 }); + const caches = getContextWindowCaches(); + applyConfiguredContextWindows({ + cache: caches.configuredTokenCache, + windowCache: caches.contextWindowCache, + modelsConfig: cfg.models, }); - - expect(result).toBe(200_000); + expect(resolve({ provider: "openai", model: "gpt-5.5" })).toBe(350_000); }); - it("does not force 1M context for non-opus/sonnet Anthropic models", () => { - const result = resolveContextTokensForModel({ - cfg: { - models: { - providers: { - anthropic: { - baseUrl: "https://api.anthropic.com", - models: [testModelContextWindow("claude-haiku-3-5", 200_000)], - }, - }, - }, - agents: { - defaults: { - models: { - "anthropic/claude-haiku-3-5": { - params: { context1m: true }, - }, - }, - }, - }, - }, - provider: "anthropic", - model: "claude-haiku-3-5", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, + it("keeps provider discovery ahead of static caps under configured windows", () => { + discover([{ provider: "openai", id: "gpt-5.5", contextTokens: 200_000 }]); + expect( + resolve({ + cfg: modelConfig("openai", "gpt-5.5", { contextWindow: 1_000_000 }), + provider: "openai", + model: "gpt-5.5", + modelContextTokens: 272_000, + }), + ).toBe(200_000); + }); + + it.each([ + [1_000_000, 272_000], + [128_000, 128_000], + ])("bounds prepared tokens by a configured native window of %i", (contextWindow, expected) => { + const caches = getContextWindowCaches(); + applyConfiguredContextWindows({ + cache: caches.discoveredTokenCache, + windowCache: caches.contextWindowCache, + modelsConfig: modelConfig("openai", "gpt-5.5", { contextWindow }).models, }); - - expect(result).toBe(200_000); - }); - - it("keeps bare claude-cli opus 4.7 variants at the fallback without context1m", () => { - const result = resolveContextTokensForModel({ - provider: "claude-cli", - model: "claude-opus-4.7-20260219", - fallbackContextTokens: 200_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(200_000); - }); - - it("does not force 1M context for non-Anthropic providers with opus 4.7 ids", () => { - const result = resolveContextTokensForModel({ - provider: "github-copilot", - model: "claude-opus-4.7", - fallbackContextTokens: 128_000, - allowAsyncLoad: false, - }); - - expect(result).toBe(128_000); - }); - - it("keeps configured contextTokens authoritative over lower discovery", () => { - resetContextWindowCacheForTest(); - try { - applyDiscoveredContextWindows({ - cache: getContextWindowCaches().discoveredTokenCache, - models: [{ provider: "openai", id: "gpt-5.5", contextWindow: 272_000 }], - }); - applyConfiguredContextWindows({ - cache: getContextWindowCaches().configuredTokenCache, - windowCache: getContextWindowCaches().contextWindowCache, - modelsConfig: { - providers: { - openai: { - models: [{ id: "gpt-5.5", contextTokens: 350_000 }], - }, - }, - }, - }); - - expect( - resolveContextTokensForModel({ - provider: "openai", - model: "gpt-5.5", - allowAsyncLoad: false, - }), - ).toBe(350_000); - } finally { - resetContextWindowCacheForTest(); - } - }); - - it("prefers verified provider discovery over static catalog fallbacks", () => { - resetContextWindowCacheForTest(); - try { - applyDiscoveredContextWindows({ - cache: getContextWindowCaches().discoveredTokenCache, - models: [{ provider: "openai", id: "gpt-5.5", contextTokens: 200_000 }], - }); - - expect( - resolveContextTokensForModel({ - provider: "openai", - model: "gpt-5.5", - modelContextWindow: 1_000_000, - modelContextTokens: 272_000, - allowAsyncLoad: false, - }), - ).toBe(200_000); - } finally { - resetContextWindowCacheForTest(); - } - }); - - it("keeps verified provider discovery ahead of static caps under configured windows", () => { - resetContextWindowCacheForTest(); - try { - applyDiscoveredContextWindows({ - cache: getContextWindowCaches().discoveredTokenCache, - models: [{ provider: "openai", id: "gpt-5.5", contextTokens: 200_000 }], - }); - - expect( - resolveContextTokensForModel({ - cfg: { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - models: [testModelContextWindow("gpt-5.5", 1_000_000)], - }, - }, - }, - }, - provider: "openai", - model: "gpt-5.5", - modelContextTokens: 272_000, - allowAsyncLoad: false, - }), - ).toBe(200_000); - } finally { - resetContextWindowCacheForTest(); - } - }); - - it("keeps configured native windows separate from prepared runtime caps", () => { - resetContextWindowCacheForTest(); - try { - applyConfiguredContextWindows({ - cache: getContextWindowCaches().discoveredTokenCache, - windowCache: getContextWindowCaches().contextWindowCache, - modelsConfig: { - providers: { - openai: { - models: [{ id: "gpt-5.5", contextWindow: 1_000_000 }], - }, - }, - }, - }); - - expect( - resolveContextTokensForModel({ - provider: "openai", - model: "gpt-5.5", - modelContextTokens: 272_000, - allowAsyncLoad: false, - }), - ).toBe(272_000); - } finally { - resetContextWindowCacheForTest(); - } - }); - - it("caps prepared runtime tokens by a lower configured native window", () => { - resetContextWindowCacheForTest(); - try { - applyConfiguredContextWindows({ - cache: getContextWindowCaches().discoveredTokenCache, - windowCache: getContextWindowCaches().contextWindowCache, - modelsConfig: { - providers: { - openai: { - models: [{ id: "gpt-5.5", contextWindow: 128_000 }], - }, - }, - }, - }); - - expect( - resolveContextTokensForModel({ - provider: "openai", - model: "gpt-5.5", - modelContextTokens: 272_000, - allowAsyncLoad: false, - }), - ).toBe(128_000); - } finally { - resetContextWindowCacheForTest(); - } + expect( + resolve({ + provider: "openai", + model: "gpt-5.5", + modelContextTokens: 272_000, + }), + ).toBe(expected); }); }); diff --git a/src/agents/context.ts b/src/agents/context.ts index e47ddbc9b19f..5aa2d5fb2957 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -25,14 +25,6 @@ import { CONTEXT_WINDOW_RUNTIME_STATE, } from "./context-runtime-state.js"; -export { - ANTHROPIC_CONTEXT_1M_TOKENS, - ANTHROPIC_FABLE_CONTEXT_TOKENS, - ANTHROPIC_MYTHOS_5_CONTEXT_TOKENS, - ANTHROPIC_OPUS_5_CONTEXT_TOKENS, - ANTHROPIC_SONNET_5_CONTEXT_TOKENS, - ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS, -} from "./context-resolution.js"; export { resetContextWindowCacheForTest } from "./context-runtime-state.js"; export { applyConfiguredContextWindows, diff --git a/src/agents/sessions/session-manager.fork-rebase.test.ts b/src/agents/sessions/session-manager.fork-rebase.test.ts index 569407f32310..de4d60f6233f 100644 --- a/src/agents/sessions/session-manager.fork-rebase.test.ts +++ b/src/agents/sessions/session-manager.fork-rebase.test.ts @@ -1,7 +1,7 @@ -// Fork-regression coverage split from session-manager.test.ts (max-lines). import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; +import { makeUserMessage } from "../../../test/helpers/user-message.js"; import { appendTranscriptMessage, appendTranscriptMessageSync, @@ -14,7 +14,7 @@ import { createNestedToolActivity } from "../../sessions/nested-tool-activity.js import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { cleanupSessionStateForTest } from "../../test-utils/session-state-cleanup.js"; import { createZeroUsageFixture } from "../test-helpers/usage-fixtures.js"; -import { SessionManager, type SessionEntry, type SessionMessageEntry } from "./session-manager.js"; +import { buildSessionContext, SessionManager } from "./session-manager.js"; const tempDirs = useAutoCleanupTempDirTracker((cleanup) => afterEach(async () => { @@ -25,215 +25,124 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) => }), ); -function preparedTurnMessage( - kind: "assistant" | "nested-tool", - timestamp: number, -): Parameters[0] { - if (kind === "nested-tool") { - return createNestedToolActivity({ - runId: "prepared-run", - scopeId: "prepared-scope", - afterEntryId: null, - startOrder: 0, - toolCallId: "prepared-message", - toolName: "message", - input: { action: "send", message: "Delivered reply" }, - result: { content: [{ type: "text", text: "Sent" }] }, - isError: false, - startedAt: timestamp, - timestamp, - }); - } +function assistant(text: string, timestamp = 2) { return { - role: "assistant", - content: [{ type: "text", text: "stale reply" }], - api: "openai-responses", + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "openai-responses" as const, provider: "openai", model: "gpt-5.5", usage: createZeroUsageFixture(), - stopReason: "stop", + stopReason: "stop" as const, timestamp, }; } -async function createSessionScope(sessionId: string, filename = "sessions.json") { +function nestedTool(timestamp: number) { + return createNestedToolActivity({ + runId: "prepared-run", + scopeId: "prepared-scope", + afterEntryId: null, + startOrder: 0, + toolCallId: "prepared-message", + toolName: "message", + input: { action: "send", message: "Delivered reply" }, + result: { content: [{ type: "text", text: "Sent" }] }, + isError: false, + startedAt: timestamp, + timestamp, + }); +} + +async function setup(content = "base") { const dir = tempDirs.make("openclaw-session-manager-"); const target = { agentId: "main", - sessionId, - sessionKey: `agent:main:${sessionId}`, - storePath: path.join(dir, filename), + sessionId: "rebase", + sessionKey: "agent:main:rebase", + storePath: path.join(dir, "sessions.json"), + }; + await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); + const persist = (eventId: string, message: unknown, now = 2, parentId?: string | null) => + appendTranscriptMessage(target, { eventId, message, now, parentId }); + await persist("base", makeUserMessage(content, 1), 1); + return { + dir, + target, + persist, + manager: SessionManager.open(target, dir), + events: () => loadTranscriptEvents(target), }; - await upsertSessionEntryCore(target, { sessionId, updatedAt: 1 }); - return { dir, target }; } describe("SessionManager stale-parent rebase", () => { - it("rebases a stale active append onto the out-of-band transcript tail", async () => { - const { dir, target } = await createSessionScope("stale-active-parent"); - const base = await appendTranscriptMessage(target, { - eventId: "base", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - const outOfBand = await appendTranscriptMessage(target, { - eventId: "out-of-band", - message: { role: "assistant", content: [{ type: "text", text: "late" }], timestamp: 2 }, - now: 2, - }); - - const appendedId = manager.appendMessage({ role: "user", content: "next", timestamp: 3 }); - - const messages = ( - (await loadTranscriptEvents(target)) as Array - ).filter((entry) => entry.type === "message"); - expect(messages.map(({ id, parentId }) => ({ id, parentId }))).toEqual([ - { id: base.messageId, parentId: null }, - { id: outOfBand.messageId, parentId: base.messageId }, - { id: appendedId, parentId: outOfBand.messageId }, + it("rebases a stale active append and replays its canonical parent", async () => { + const { target, manager, persist, events } = await setup(); + const { role, content, timestamp } = assistant("late"); + await persist("out-of-band", { role, content, timestamp }); + const message = makeUserMessage("next", 3); + const id = manager.appendMessage(message); + expect(await events()).toMatchObject([ + { type: "session" }, + { id: "base", parentId: null }, + { id: "out-of-band", parentId: "base" }, + { id, parentId: "out-of-band" }, ]); - expect(manager.getEntry(appendedId)?.parentId).toBe(outOfBand.messageId); - expect(manager.getBranch().map((entry) => entry.id)).toEqual([ - base.messageId, - outOfBand.messageId, - appendedId, - ]); - const replayed = await appendTranscriptMessage(target, { - appendIntent: "active-branch", - eventId: appendedId, - message: { role: "user", content: "next", timestamp: 3 }, - parentId: base.messageId, - }); - expect(replayed).toMatchObject({ - appended: false, - effectiveParentId: outOfBand.messageId, - messageId: appendedId, - }); - }); - - it("retries a stale control append with the refreshed transcript fence", async () => { - const { dir, target } = await createSessionScope("stale-control-fence"); - const base = await appendTranscriptMessage(target, { - eventId: "base", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - await appendTranscriptMessage(target, { - eventId: "out-of-band", - message: { role: "assistant", content: [{ type: "text", text: "late" }], timestamp: 2 }, - now: 2, - }); - - const modelChangeId = await manager.appendModelChange("openai", "gpt-5.6"); - - expect(manager.getEntry(modelChangeId)?.parentId).toBe("out-of-band"); - expect(manager.getBranch().map((entry) => entry.id)).toEqual([ - base.messageId, - "out-of-band", - modelChangeId, - ]); - await expect(loadTranscriptEvents(target)).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: modelChangeId, - parentId: "out-of-band", - type: "model_change", - }), - ]), - ); + expect(manager.getEntry(id)?.parentId).toBe("out-of-band"); + expect(manager.getBranch().map((entry) => entry.id)).toEqual(["base", "out-of-band", id]); + expect( + await appendTranscriptMessage(target, { + appendIntent: "active-branch", + eventId: id, + message, + parentId: "base", + }), + ).toMatchObject({ appended: false, effectiveParentId: "out-of-band", messageId: id }); }); it("reloads a stale control append after an unchanged-parent prefix rewrite", async () => { - const { dir, target } = await createSessionScope("stale-control-prefix"); - const base = await appendTranscriptMessage(target, { - eventId: "base", - message: { role: "user", content: "old", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - const persisted = (await loadTranscriptEvents(target)) as SessionEntry[]; + const { target, manager, events } = await setup("old"); + const records = await events(); expect( - replaceTranscriptEventsSync( - target, - persisted.map((entry) => - entry.type === "message" && entry.id === base.messageId - ? Object.assign({}, entry, { - message: { role: "user" as const, content: "rewritten", timestamp: 2 }, - }) - : entry, - ), - ), + replaceTranscriptEventsSync(target, [ + records[0], + { + type: "message", + id: "base", + parentId: null, + timestamp: new Date(1).toISOString(), + message: makeUserMessage("rewritten", 2), + }, + ]), ).toBe(true); - - const modelChangeId = await manager.appendModelChange("openai", "gpt-5.6"); - - expect(manager.getBranch().map((entry) => entry.id)).toEqual([base.messageId, modelChangeId]); - const reloadedBase = manager.getEntry(base.messageId); - expect(reloadedBase?.type).toBe("message"); - expect( - reloadedBase?.type === "message" && reloadedBase.message.role === "user" - ? reloadedBase.message.content - : undefined, - ).toBe("rewritten"); + const id = await manager.appendModelChange("openai", "gpt-5.6"); + expect(manager.getBranch().map((entry) => entry.id)).toEqual(["base", id]); + expect(manager.getEntry("base")).toMatchObject({ + type: "message", + message: { role: "user", content: "rewritten" }, + }); }); it("continues a prepared assistant across a visible context-free command pair without replaying it", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const target = { - agentId: "main", - sessionId: "prepared-context-free-command", - sessionKey: "agent:main:prepared-context-free-command", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(target, { - eventId: "base-user", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - await appendTranscriptMessage(target, { - eventId: "status-user", - message: { - role: "user", - content: "/status", - timestamp: 2, - excludeFromContext: true, - __openclaw: { contextFreeCommand: true }, - }, - now: 2, - }); - await appendTranscriptMessage(target, { - eventId: "status-assistant", - message: { - role: "assistant", - content: [{ type: "text", text: "Worker is running" }], - timestamp: 3, - excludeFromContext: true, - __openclaw: { contextFreeCommand: true }, - }, - now: 3, - }); - - const continuation = preparedTurnMessage("assistant", 4); - const continuedId = manager.appendMessage(continuation); - const messages = ((await loadTranscriptEvents(target)) as SessionEntry[]).filter( - (entry) => entry.type === "message", - ); - expect(messages).toMatchObject([ - { id: "base-user", message: { role: "user", content: "base" } }, - { id: "status-user", parentId: "base-user", message: { role: "user", content: "/status" } }, + const { dir, target, manager, persist, events } = await setup(); + const metadata = { excludeFromContext: true, __openclaw: { contextFreeCommand: true } }; + await persist("status-user", { ...makeUserMessage("/status", 2), ...metadata }); + await persist("status-assistant", { ...assistant("Worker is running", 3), ...metadata }, 3); + const continuation = assistant("stale reply", 4); + const id = manager.appendMessage(continuation); + expect(await events()).toMatchObject([ + { type: "session" }, + { id: "base", message: { role: "user", content: "base" } }, + { id: "status-user", parentId: "base", message: { role: "user", content: "/status" } }, { id: "status-assistant", parentId: "status-user", message: { role: "assistant", content: [{ type: "text", text: "Worker is running" }] }, }, - { id: continuedId, parentId: "status-assistant", message: continuation }, + { id, parentId: "status-assistant", message: continuation }, ]); expect(manager.buildSessionContext().messages).toEqual([ - { role: "user", content: "base", timestamp: 1 }, + makeUserMessage("base", 1), continuation, ]); expect(SessionManager.open(target, dir).buildSessionContext()).toEqual( @@ -241,85 +150,38 @@ describe("SessionManager stale-parent rebase", () => { ); }); - it.each( - [ - { name: "ordinary", metadata: {} }, - { name: "excluded-only", metadata: { excludeFromContext: true } }, - { name: "marked-only", metadata: { __openclaw: { contextFreeCommand: true } } }, - { - name: "string-marker", - metadata: { excludeFromContext: true, __openclaw: { contextFreeCommand: "true" } }, - }, - { - name: "numeric-marker", - metadata: { excludeFromContext: true, __openclaw: { contextFreeCommand: 1 } }, - }, - { - name: "string-exclusion", - metadata: { excludeFromContext: "true", __openclaw: { contextFreeCommand: true } }, - }, - { - name: "numeric-exclusion", - metadata: { excludeFromContext: 1, __openclaw: { contextFreeCommand: true } }, - }, - ].flatMap((scenario) => - (["assistant", "nested-tool"] as const).map((kind) => Object.assign({}, scenario, { kind })), - ), - )( + it.each([ + { name: "excluded-only", kind: "assistant", metadata: { excludeFromContext: true } }, + { + name: "marked-only", + kind: "assistant", + metadata: { __openclaw: { contextFreeCommand: true } }, + }, + { + name: "nonboolean-marker", + kind: "nested-tool", + metadata: { excludeFromContext: true, __openclaw: { contextFreeCommand: "true" } }, + }, + ])( "rejects a stale prepared $kind after a newer user turn ($name)", async ({ kind, metadata }) => { - const dir = tempDirs.make("openclaw-session-manager-"); - const target = { - agentId: "main", - sessionId: "stale-assistant-new-user", - sessionKey: "agent:main:stale-assistant-new-user", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(target, { - eventId: "base-user", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - await appendTranscriptMessage(target, { - appendIntent: "active-branch", - eventId: "new-user", - message: { role: "user", content: "/status", timestamp: 2, ...metadata }, - now: 2, - }); - const branchBeforeAppend = manager.getBranch(); - const eventsBeforeAppend = await loadTranscriptEvents(target); - - expect(() => manager.appendMessage(preparedTurnMessage(kind, 3))).toThrow( - "SQLite transcript changed while preparing rewrite", - ); - expect(manager.getBranch()).toEqual(branchBeforeAppend); - expect(await loadTranscriptEvents(target)).toEqual(eventsBeforeAppend); + const { manager, persist, events } = await setup(); + await persist("new-user", { ...makeUserMessage("/status", 2), ...metadata }); + const beforeBranch = manager.getBranch(); + const beforeEvents = await events(); + expect(() => + manager.appendMessage(kind === "assistant" ? assistant("stale reply", 3) : nestedTool(3)), + ).toThrow("SQLite transcript changed while preparing rewrite"); + expect(manager.getBranch()).toEqual(beforeBranch); + expect(await events()).toEqual(beforeEvents); }, ); it("rejects a stale custom message after a same-turn assistant append", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const target = { - agentId: "main", - sessionId: "stale-custom-message", - sessionKey: "agent:main:stale-custom-message", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(target, { - eventId: "base-user", - message: { role: "user", content: "base", timestamp: 1 }, - }); - const manager = SessionManager.open(target, dir); - await appendTranscriptMessage(target, { - eventId: "delivered-reply", - message: preparedTurnMessage("assistant", 2), - }); - const branchBeforeAppend = manager.getBranch(); - const eventsBeforeAppend = await loadTranscriptEvents(target); - + const { manager, persist, events } = await setup(); + await persist("delivered-reply", assistant("stale reply")); + const beforeBranch = manager.getBranch(); + const beforeEvents = await events(); expect(() => manager.appendMessage({ role: "custom", @@ -329,266 +191,106 @@ describe("SessionManager stale-parent rebase", () => { timestamp: 3, }), ).toThrow("SQLite transcript changed while preparing rewrite"); - expect(manager.getBranch()).toEqual(branchBeforeAppend); - expect(await loadTranscriptEvents(target)).toEqual(eventsBeforeAppend); + expect(manager.getBranch()).toEqual(beforeBranch); + expect(await events()).toEqual(beforeEvents); }); it("fences a prepared assistant retry to the snapshot that passed validation", async () => { - const { dir, target } = await createSessionScope("stale-assistant-validation-race"); - const base = await appendTranscriptMessage(target, { - eventId: "base-user", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - await appendTranscriptMessage(target, { - eventId: "intermediate-assistant", - message: { role: "assistant", content: [{ type: "text", text: "late" }], timestamp: 2 }, - now: 2, - }); - const branchBeforeRetry = manager.getBranch().map((entry) => entry.id); + const { target, manager, persist, events } = await setup(); + await persist("intermediate-assistant", assistant("late")); + const beforeBranch = manager.getBranch().map((entry) => entry.id); const { db } = openOpenClawAgentDatabase({ agentId: target.agentId, path: resolveSessionTranscriptDatabasePath(target), }); const exec = db.exec.bind(db); let injected = false; - const execSpy = vi.spyOn(db, "exec").mockImplementation((statement) => { + const spy = vi.spyOn(db, "exec").mockImplementation((statement) => { if (statement === "BEGIN IMMEDIATE" && !injected) { injected = true; - const concurrent = appendTranscriptMessageSync(target, { - appendIntent: "active-branch", - eventId: "new-user", - message: { role: "user", content: "new", timestamp: 3 }, - now: 3, - }); - expect(concurrent.ok).toBe(true); + expect( + appendTranscriptMessageSync(target, { + appendIntent: "active-branch", + eventId: "new-user", + message: makeUserMessage("new", 3), + now: 3, + }).ok, + ).toBe(true); } return exec(statement); }); try { - expect(() => - manager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "stale reply" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.5", - usage: createZeroUsageFixture(), - stopReason: "stop", - timestamp: 4, - }), - ).toThrow("SQLite transcript changed while preparing rewrite"); + expect(() => manager.appendMessage(assistant("stale reply", 4))).toThrow( + "SQLite transcript changed while preparing rewrite", + ); } finally { - execSpy.mockRestore(); + spy.mockRestore(); } - expect(manager.getBranch().map((entry) => entry.id)).toEqual(branchBeforeRetry); - const messages = ( - (await loadTranscriptEvents(target)) as Array - ).filter((entry) => entry.type === "message"); - expect(messages.map((entry) => entry.id)).toEqual([ - base.messageId, - "intermediate-assistant", - "new-user", + expect(manager.getBranch().map((entry) => entry.id)).toEqual(beforeBranch); + expect(await events()).toMatchObject([ + { type: "session" }, + { id: "base" }, + { id: "intermediate-assistant" }, + { id: "new-user" }, ]); }); - it.each(["assistant", "nested-tool"] as const)( - "rejects a prepared %s after a newer user outside the restored active ancestry", - async (kind) => { - const dir = tempDirs.make("openclaw-session-manager-"); - const target = { - agentId: "main", - sessionId: "stale-assistant-side-user", - sessionKey: "agent:main:stale-assistant-side-user", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); - const source = SessionManager.open(target, dir); - const baseId = source.appendMessage({ role: "user", content: "base", timestamp: 1 }); - const preparedParentId = source.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "ready" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.5", - usage: createZeroUsageFixture(), - stopReason: "stop", - timestamp: 2, - }); - const stale = SessionManager.open(target, dir); - source.branch(baseId); - source.appendMessage({ role: "user", content: "side user", timestamp: 3 }); - source.branch(preparedParentId); - const branchBeforeAppend = stale.getBranch(); - const eventsBeforeAppend = await loadTranscriptEvents(target); - - expect(() => stale.appendMessage(preparedTurnMessage(kind, 4))).toThrow( - "SQLite transcript changed while preparing rewrite", - ); - expect(stale.getBranch()).toEqual(branchBeforeAppend); - expect(await loadTranscriptEvents(target)).toEqual(eventsBeforeAppend); - }, - ); - - it("preserves a deliberate manager branch from an ancestor", async () => { - const { dir, target } = await createSessionScope("deliberate-manager-branch"); - const base = await appendTranscriptMessage(target, { - eventId: "branch-base", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const oldTail = await appendTranscriptMessage(target, { - eventId: "old-tail", - message: { role: "assistant", content: [{ type: "text", text: "old" }], timestamp: 2 }, - now: 2, - }); - const manager = SessionManager.open(target, dir); - - manager.branch(base.messageId); - const branchId = manager.appendMessage({ role: "user", content: "retry", timestamp: 3 }); - - expect(manager.getEntry(branchId)?.parentId).toBe(base.messageId); - expect(manager.getChildren(base.messageId).map((entry) => entry.id)).toEqual([ - oldTail.messageId, - branchId, - ]); + it("rejects a prepared nested tool after a newer user outside the restored active ancestry", async () => { + const { dir, target, manager: source, events } = await setup(); + const parentId = source.appendMessage(assistant("ready")); + const stale = SessionManager.open(target, dir); + source.branch("base"); + source.appendMessage(makeUserMessage("side user", 3)); + source.branch(parentId); + const beforeBranch = stale.getBranch(); + const beforeEvents = await events(); + expect(() => stale.appendMessage(nestedTool(4))).toThrow( + "SQLite transcript changed while preparing rewrite", + ); + expect(stale.getBranch()).toEqual(beforeBranch); + expect(await events()).toEqual(beforeEvents); }); it("preserves a stale manager branch when the concurrent tail is unrelated", async () => { - const { dir, target } = await createSessionScope("stale-unrelated-parent"); - const firstRoot = await appendTranscriptMessage(target, { - eventId: "first-root", - message: { role: "user", content: "first", timestamp: 1 }, - now: 1, - }); - const firstTail = await appendTranscriptMessage(target, { - eventId: "first-tail", - message: { role: "assistant", content: [{ type: "text", text: "first" }], timestamp: 2 }, - now: 2, - }); + const { dir, target, persist, events } = await setup("first"); + await persist("first-tail", assistant("first")); const manager = SessionManager.open(target, dir); - await appendTranscriptMessage(target, { - eventId: "second-root", - message: { role: "user", content: "second", timestamp: 3 }, - now: 3, - parentId: null, - }); - - const branchId = manager.appendMessage({ role: "user", content: "branch", timestamp: 4 }); - - expect(manager.getEntry(branchId)?.parentId).toBe(firstTail.messageId); - expect(manager.getBranch().map((entry) => entry.id)).toEqual([ - firstRoot.messageId, - firstTail.messageId, - branchId, + await persist("second-root", makeUserMessage("second", 3), 3, null); + const id = manager.appendMessage(makeUserMessage("branch", 4)); + expect(manager.getEntry(id)?.parentId).toBe("first-tail"); + expect(manager.getBranch().map((entry) => entry.id)).toEqual(["base", "first-tail", id]); + expect(buildSessionContext(manager.getEntries(), "first-tail").messages).toMatchObject([ + { role: "user", content: "first" }, + { role: "assistant", content: [{ type: "text", text: "first" }] }, ]); - const persisted = ( - (await loadTranscriptEvents(target)) as Array - ).find((entry) => entry.type === "message" && entry.id === branchId); - expect(persisted).toMatchObject({ parentId: firstTail.messageId }); + expect(await events()).toContainEqual(expect.objectContaining({ id, parentId: "first-tail" })); }); it("retries a stale side append against its unchanged explicit parent", async () => { - const { dir, target } = await createSessionScope("stale-side-append"); - const base = await appendTranscriptMessage(target, { - eventId: "side-base", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - manager.appendLeafControl({ - targetId: base.messageId, - appendParentId: base.messageId, - appendMode: "side", - }); - await appendTranscriptMessage(target, { - eventId: "concurrent-tail", - message: { - role: "assistant", - content: [{ type: "text", text: "concurrent" }], - timestamp: 2, - }, - now: 2, - parentId: base.messageId, - }); - - const sideId = manager.appendMessage({ role: "user", content: "side", timestamp: 3 }); - - const events = (await loadTranscriptEvents(target)) as Array< - SessionMessageEntry & { type?: string } - >; - const persisted = events.find((entry) => entry.type === "message" && entry.id === sideId); - expect(persisted).toMatchObject({ parentId: base.messageId }); + const { dir, target, manager, persist, events } = await setup(); + manager.appendLeafControl({ targetId: "base", appendParentId: "base", appendMode: "side" }); + const reopened = SessionManager.open(target, dir); + expect(reopened.getLeafId()).toBe("base"); + expect(reopened.getAppendParentId()).toBe("base"); + expect(reopened.getAppendMode()).toBe("side"); + await persist("concurrent-tail", assistant("concurrent"), 2, "base"); + const id = manager.appendMessage(makeUserMessage("side", 3)); + expect(await events()).toContainEqual(expect.objectContaining({ id, parentId: "base" })); expect(manager.getEntries()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "concurrent-tail" }), - expect.objectContaining({ id: sideId }), + expect.objectContaining({ id }), ]), ); expect(() => manager.prepareTranscriptRewrite()).not.toThrow(); }); it("retries a stale deliberate branch against an unchanged explicit parent", async () => { - const { dir, target } = await createSessionScope("stale-deliberate-branch"); - const base = await appendTranscriptMessage(target, { - eventId: "deliberate-base", - message: { role: "user", content: "base", timestamp: 1 }, - now: 1, - }); - const manager = SessionManager.open(target, dir); - manager.branch(base.messageId); - await appendTranscriptMessage(target, { - eventId: "concurrent-tail", - message: { - role: "assistant", - content: [{ type: "text", text: "concurrent" }], - timestamp: 2, - }, - now: 2, - parentId: base.messageId, - }); - - const branchId = manager.appendMessage({ role: "user", content: "branch", timestamp: 3 }); - - const persisted = ( - (await loadTranscriptEvents(target)) as Array - ).find((entry) => entry.type === "message" && entry.id === branchId); - expect(persisted).toMatchObject({ parentId: base.messageId }); - }); - - it("honors an explicit active parent when the tail is not its descendant", async () => { - const { target } = await createSessionScope("unrelated-explicit-parent"); - const firstRoot = await appendTranscriptMessage(target, { - eventId: "first-root", - message: { role: "user", content: "first", timestamp: 1 }, - now: 1, - }); - const firstTail = await appendTranscriptMessage(target, { - eventId: "first-tail", - message: { role: "assistant", content: [{ type: "text", text: "first" }], timestamp: 2 }, - now: 2, - }); - await appendTranscriptMessage(target, { - eventId: "second-root", - message: { role: "user", content: "second", timestamp: 3 }, - now: 3, - parentId: null, - }); - - const branched = await appendTranscriptMessage(target, { - appendIntent: "active-branch", - eventId: "preserved-branch", - message: { role: "user", content: "branch", timestamp: 4 }, - now: 4, - parentId: firstTail.messageId, - }); - - expect(branched.effectiveParentId).toBe(firstTail.messageId); - const branchEntry = ( - (await loadTranscriptEvents(target)) as Array<{ type?: string; id?: string }> - ).find((entry) => entry.type === "message" && entry.id === branched.messageId); - expect(branchEntry).toMatchObject({ parentId: firstTail.messageId }); - expect(firstRoot.messageId).not.toBe(firstTail.messageId); + const { manager, persist, events } = await setup(); + manager.branch("base"); + await persist("concurrent-tail", assistant("concurrent"), 2, "base"); + const id = manager.appendMessage(makeUserMessage("branch", 3)); + expect(await events()).toContainEqual(expect.objectContaining({ id, parentId: "base" })); + expect(manager.getChildren("base").map((entry) => entry.id)).toEqual(["concurrent-tail", id]); }); }); diff --git a/src/agents/sessions/session-manager.persistence-compat.test.ts b/src/agents/sessions/session-manager.persistence-compat.test.ts index d524828a46f6..7931ce593297 100644 --- a/src/agents/sessions/session-manager.persistence-compat.test.ts +++ b/src/agents/sessions/session-manager.persistence-compat.test.ts @@ -1,15 +1,10 @@ -// Focused persistence compatibility tests kept separate from the session tree suite. import fs from "node:fs/promises"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { openFileBackedSessionManagerForTest } from "../../../test/helpers/session-manager-file-fixture.js"; import { createTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { makeUserMessage } from "../../../test/helpers/user-message.js"; -import { - formatSqliteSessionFileMarker, - parseSqliteSessionFileMarker, -} from "../../config/sessions/legacy-sqlite-marker.js"; import { appendTranscriptMessage, loadTranscriptEvents, @@ -24,6 +19,7 @@ import { waitForSessionTranscriptIndexReconcile } from "../../config/sessions/se import { withOwnedSessionTranscriptWrites } from "../../config/sessions/transcript-write-context.js"; import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { cleanupSessionStateForTest } from "../../test-utils/session-state-cleanup.js"; +import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../internal-runtime-context.js"; import { createZeroUsageFixture } from "../test-helpers/usage-fixtures.js"; import { parseOpaqueLeafEntry } from "./session-manager-codec.js"; import { CURRENT_SESSION_VERSION, SessionManager } from "./session-manager.js"; @@ -49,32 +45,30 @@ function buildAssistantMessage(text: string) { }; } +function createScope(sessionId: string) { + const dir = tempDirs.make("openclaw-session-manager-compat-"); + const scope = { + agentId: "main", + sessionId, + sessionKey: `agent:main:${sessionId}`, + storePath: path.join(dir, "openclaw-agent.sqlite"), + }; + const persist = (eventId: string, message: unknown) => + appendTranscriptMessage(scope, { cwd: dir, eventId, message }); + return { dir, scope, persist }; +} + +function header(id: string, cwd: string, version = CURRENT_SESSION_VERSION) { + return { type: "session", version, id, timestamp: new Date(0).toISOString(), cwd }; +} + +function row(type: string, id: string, parent: string | null, fields: Record) { + return { type, id, parentId: parent, timestamp: new Date(1).toISOString(), ...fields }; +} + describe("SessionManager persistence compatibility", () => { - it("persists an assistant-first session after creating its header", async () => { - const dir = tempDirs.make("openclaw-session-manager-assistant-first-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "assistant-first-session"; - const sessionKey = "agent:main:dashboard:assistant-first"; - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - await upsertSessionEntryCore(scope, { sessionId, updatedAt: 1 }); - - const manager = SessionManager.open(scope, dir); - const assistantId = manager.appendMessage(buildAssistantMessage("first response")); - - await expect(loadTranscriptEvents(scope)).resolves.toEqual([ - expect.objectContaining({ id: sessionId, type: "session" }), - expect.objectContaining({ id: assistantId, type: "message", parentId: null }), - ]); - }); - it("persists canonical delivery facts and keeps the live assistant bytes identical", async () => { - const dir = tempDirs.make("openclaw-session-manager-directives-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "directive-session"; - const sessionKey = "agent:main:dashboard:directives"; - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - await upsertSessionEntryCore(scope, { sessionId, updatedAt: 1 }); - + const { dir, scope } = createScope("directive-session"); const manager = SessionManager.open(scope, dir); const tagged = buildAssistantMessage( [ @@ -85,30 +79,21 @@ describe("SessionManager persistence compatibility", () => { "Final answer [[tts:text]]Spoken answer[[/tts:text]]", ].join("\n"), ); - const codeExampleText = [ - "Use `[[reply_to_current]]` literally.", - "Use `[[tts:text]]spoken[[/tts:text]]` literally.", - "```text", - "[[audio_as_voice]]", - "[[tts:provider=mock voiceId=voice-7]]", - "```", - ].join("\n"); - const codeExample = buildAssistantMessage(codeExampleText); - const indentedCode = buildAssistantMessage(" [[reply_to_current]]\n [[audio_as_voice]]"); - const malformed = buildAssistantMessage("[[reply_to_current]\nVisible reply"); - const laterLiteral = buildAssistantMessage("Visible reply\n[[reply_to_current] literally"); - const ordinaryRelativeMedia = buildAssistantMessage("Generated image\nMEDIA:./render.png"); - const ordinaryMarkdownText = - " Leading spaces\r\n\r\n\r\n indented code\r\n```ts\r\nconst value = 1;\r\n```\r\n"; - const ordinaryMarkdown = buildAssistantMessage(ordinaryMarkdownText); + const cases = [ + { + input: + "Use `[[reply_to_current]]` literally.\nUse `[[tts:text]]spoken[[/tts:text]]` literally.\n```text\n[[audio_as_voice]]\n[[tts:provider=mock voiceId=voice-7]]\n```", + }, + { input: " [[reply_to_current]]\n [[audio_as_voice]]" }, + { input: "[[reply_to_current]\nVisible reply", expected: "Visible reply" }, + { input: "Visible reply\n[[reply_to_current] literally" }, + { input: "Generated image\nMEDIA:./render.png" }, + { + input: + " Leading spaces\r\n\r\n\r\n indented code\r\n```ts\r\nconst value = 1;\r\n```\r\n", + }, + ]; manager.appendMessage(tagged); - manager.appendMessage(codeExample); - manager.appendMessage(indentedCode); - manager.appendMessage(malformed); - manager.appendMessage(laterLiteral); - manager.appendMessage(ordinaryRelativeMedia); - manager.appendMessage(ordinaryMarkdown); - expect(tagged.content).toEqual([{ type: "text", text: "Final answer" }]); expect(tagged).toMatchObject({ openclawDelivery: { @@ -117,181 +102,40 @@ describe("SessionManager persistence compatibility", () => { tts: { tagged: true, text: "Spoken answer", - directives: [ - { - provider: "mock", - values: { voiceid: "voice-7" }, - }, - ], + directives: [{ provider: "mock", values: { voiceid: "voice-7" } }], }, }, }); - expect(codeExample.content).toEqual([{ type: "text", text: codeExampleText }]); - expect(codeExample).not.toHaveProperty("openclawDelivery"); - expect(indentedCode).not.toHaveProperty("openclawDelivery"); - expect(malformed.content).toEqual([{ type: "text", text: "Visible reply" }]); - expect(malformed).not.toHaveProperty("openclawDelivery"); - expect(laterLiteral.content).toEqual([ - { type: "text", text: "Visible reply\n[[reply_to_current] literally" }, - ]); - expect(laterLiteral).not.toHaveProperty("openclawDelivery"); - expect(ordinaryRelativeMedia).not.toHaveProperty("openclawDelivery"); - expect(ordinaryMarkdown.content).toEqual([{ type: "text", text: ordinaryMarkdownText }]); - expect(ordinaryMarkdown).not.toHaveProperty("openclawDelivery"); - - const persistedMessages = (await loadTranscriptEvents(scope)) - .filter((event) => (event as { type?: unknown }).type === "message") - .map((event) => (event as { message: unknown }).message); - expect(persistedMessages).toEqual([ + const messages = [ tagged, - codeExample, - indentedCode, - malformed, - laterLiteral, - ordinaryRelativeMedia, - ordinaryMarkdown, - ]); - expect(SessionManager.open(scope, dir).buildSessionContext().messages).toEqual([ - tagged, - codeExample, - indentedCode, - malformed, - laterLiteral, - ordinaryRelativeMedia, - ordinaryMarkdown, - ]); - }); - - it("rewrites SQLite transcript rows when removing trailing entries", async () => { - const dir = tempDirs.make("openclaw-session-manager-compat-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-remove-trailing-session"; - const sessionKey = "agent:main:dashboard:sqlite-remove-trailing"; - const marker = formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }); - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - await upsertSessionEntryCore( - { agentId: "main", sessionKey, storePath }, - { sessionFile: marker, sessionId, updatedAt: 10 }, + ...cases.map(({ input, expected }) => { + const message = buildAssistantMessage(input); + manager.appendMessage(message); + expect(message.content).toEqual([{ type: "text", text: expected ?? input }]); + expect(message).not.toHaveProperty("openclawDelivery"); + return message; + }), + ]; + const persisted = (await loadTranscriptEvents(scope)).flatMap((event) => + isRecord(event) && event.type === "message" ? [event.message] : [], ); - const user = await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "user-message", - message: { role: "user", content: "question" }, - }); - const baseAnswer = await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base-answer", - message: buildAssistantMessage("base answer"), - parentId: user.messageId, - }); - const temporaryError = await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "temporary-error", - message: buildAssistantMessage("temporary error"), - parentId: baseAnswer.messageId, - }); - const target = parseSqliteSessionFileMarker(marker); - if (!target) { - throw new Error("expected SQLite transcript marker fixture"); - } - const manager = SessionManager.open({ ...target, sessionKey }, dir); - - expect(manager.removeTrailingEntries((entry) => entry.id === temporaryError.messageId)).toBe(1); - expect(manager.getLeafId()).toBe(baseAnswer.messageId); - const replacementId = manager.appendMessage(buildAssistantMessage("replacement answer")); - const records = await loadTranscriptEvents(scope); - - expect( - records.map((record) => - record && typeof record === "object" && "id" in record ? record.id : undefined, - ), - ).not.toContain(temporaryError.messageId); - expect(records).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: replacementId, - message: expect.objectContaining({ - content: [{ type: "text", text: "replacement answer" }], - role: "assistant", - }), - parentId: baseAnswer.messageId, - type: "message", - }), - ]), - ); - await expect(fs.stat(path.join(process.cwd(), marker))).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("keeps the transcript version current after a metadata append", async () => { - const dir = tempDirs.make("openclaw-session-manager-metadata-version-"); - const scope = { - agentId: "main", - sessionId: "metadata-version", - sessionKey: "agent:main:metadata-version", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - const user = await appendTranscriptMessage(scope, { - eventId: "user", - message: { role: "user", content: "question" }, - }); - const manager = SessionManager.open(scope, dir); - const modelChangeId = await manager.appendModelChange("openai", "gpt-5.6"); - - expect(manager.removeTrailingEntries((entry) => entry.id === modelChangeId)).toBe(1); - expect(manager.getLeafId()).toBe(user.messageId); + expect(persisted).toEqual(messages); + expect(SessionManager.open(scope, dir).buildSessionContext().messages).toEqual(messages); }); it("removes an active tail followed by a later inactive raw row", async () => { - const dir = tempDirs.make("openclaw-session-manager-later-inactive-row-"); - const scope = { - agentId: "main", - sessionId: "later-inactive-row-session", - sessionKey: "agent:main:later-inactive-row-session", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope } = createScope("later-inactive-row-session"); await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); expect( replaceTranscriptEventsSync(scope, [ - { - type: "session", - version: 3, - id: scope.sessionId, - timestamp: new Date(0).toISOString(), - cwd: dir, - }, - { - type: "message", - id: "root", - parentId: null, - timestamp: new Date(1).toISOString(), - message: { role: "user", content: "root" }, - }, - { - type: "message", - id: "active", - parentId: "root", - timestamp: new Date(2).toISOString(), - message: buildAssistantMessage("active"), - }, - { - type: "message", - id: "inactive", - parentId: "root", - timestamp: new Date(3).toISOString(), + header(scope.sessionId, dir, 3), + row("message", "root", null, { message: { role: "user", content: "root" } }), + row("message", "active", "root", { message: buildAssistantMessage("active") }), + row("message", "inactive", "root", { appendMode: "side", message: buildAssistantMessage("inactive"), - }, - { - type: "leaf", - id: "active-leaf", - parentId: "inactive", - timestamp: new Date(4).toISOString(), - targetId: "active", - appendParentId: "active", - }, + }), + row("leaf", "active-leaf", "inactive", { targetId: "active", appendParentId: "active" }), ]), ).toBe(true); await waitForSessionTranscriptIndexReconcile({ @@ -303,83 +147,33 @@ describe("SessionManager persistence compatibility", () => { expect(manager.removeTrailingEntries((entry) => entry.id === "active")).toBe(1); const events = await loadTranscriptEvents(scope); - expect(events).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "active" })])); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "inactive", parentId: "root", appendMode: "side" }), - ]), + expect(events).not.toContainEqual(expect.objectContaining({ id: "active" })); + expect(events).toContainEqual( + expect.objectContaining({ id: "inactive", parentId: "root", appendMode: "side" }), ); }); it("preserves and rebases trailing metadata, labels, and leaf controls", async () => { - const dir = tempDirs.make("openclaw-session-manager-controls-"); - const scope = { - agentId: "main", - sessionId: "sqlite-remove-controls-session", - sessionKey: "agent:main:dashboard:sqlite-remove-controls", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope } = createScope("sqlite-remove-controls-session"); await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); const events = [ - { - type: "session", - version: CURRENT_SESSION_VERSION, - id: scope.sessionId, - timestamp: new Date(0).toISOString(), - cwd: dir, - }, - { - type: "message", - id: "user", - parentId: null, - timestamp: new Date(1).toISOString(), - message: { role: "user", content: "question" }, - }, - { - type: "message", - id: "temporary", - parentId: "user", - timestamp: new Date(2).toISOString(), - message: buildAssistantMessage("temporary"), - }, - { - type: "label", - id: "temporary-label", - parentId: "temporary", - timestamp: new Date(3).toISOString(), - targetId: "temporary", - label: "retry", - }, - { - type: "label", - id: "nested-temporary-label", - parentId: "temporary-label", - timestamp: new Date(4).toISOString(), + header(scope.sessionId, dir), + row("message", "user", null, { message: { role: "user", content: "question" } }), + row("message", "temporary", "user", { message: buildAssistantMessage("temporary") }), + row("label", "temporary-label", "temporary", { targetId: "temporary", label: "retry" }), + row("label", "nested-temporary-label", "temporary-label", { targetId: "temporary-label", label: "nested retry", - }, - { - type: "custom", - id: "plugin-state", - parentId: "nested-temporary-label", - timestamp: new Date(5).toISOString(), + }), + row("custom", "plugin-state", "nested-temporary-label", { customType: "plugin-state", data: { enabled: true }, - }, - { - type: "session_info", - id: "session-info", - parentId: "plugin-state", - timestamp: new Date(6).toISOString(), - name: "kept session", - }, - { - type: "leaf", - id: "leaf-control", - parentId: "session-info", + }), + row("session_info", "session-info", "plugin-state", { name: "kept session" }), + row("leaf", "leaf-control", "session-info", { targetId: "temporary", appendParentId: "temporary", - }, + }), ]; expect(replaceTranscriptEventsSync(scope, events)).toBe(true); const generationBefore = readSessionTranscriptWatermark(scope).generation; @@ -409,62 +203,30 @@ describe("SessionManager persistence compatibility", () => { }); it("allows stale suffix cleanup to remain a no-op when its target is absent", async () => { - const dir = tempDirs.make("openclaw-session-manager-concurrent-noop-"); - const scope = { - agentId: "main", - sessionId: "sqlite-remove-concurrent-noop-session", - sessionKey: "agent:main:dashboard:sqlite-remove-concurrent-noop", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope, persist } = createScope("sqlite-remove-concurrent-noop-session"); await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base", - message: { role: "user", content: "question" }, - }); + await persist("base", { role: "user", content: "question" }); const manager = SessionManager.open(scope, dir); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "concurrent", - message: { role: "user", content: "concurrent" }, - }); + await persist("concurrent", { role: "user", content: "concurrent" }); expect(manager.removeTrailingEntries((entry) => entry.id === "absent")).toBe(0); expect(manager.buildSessionContext().messages).toMatchObject([ { role: "user", content: "question" }, ]); - expect( - (await loadTranscriptEvents(scope)).map((event) => - event && typeof event === "object" && "id" in event ? event.id : undefined, - ), - ).toEqual([scope.sessionId, "base", "concurrent"]); + expect(await loadTranscriptEvents(scope)).toMatchObject([ + { id: scope.sessionId }, + { id: "base" }, + { id: "concurrent" }, + ]); }); it("rejects stale suffix removal without deleting concurrent history", async () => { - const dir = tempDirs.make("openclaw-session-manager-concurrent-"); - const scope = { - agentId: "main", - sessionId: "sqlite-remove-concurrent-session", - sessionKey: "agent:main:dashboard:sqlite-remove-concurrent", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope, persist } = createScope("sqlite-remove-concurrent-session"); await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base", - message: { role: "user", content: "question" }, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "temporary", - message: buildAssistantMessage("temporary"), - }); + await persist("base", { role: "user", content: "question" }); + await persist("temporary", buildAssistantMessage("temporary")); const manager = SessionManager.open(scope, dir); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "concurrent", - message: { role: "user", content: "concurrent" }, - }); + await persist("concurrent", { role: "user", content: "concurrent" }); expect(() => manager.removeTrailingEntries((entry) => entry.id === "temporary")).toThrow( "SQLite transcript changed while preparing suffix removal", @@ -480,54 +242,10 @@ describe("SessionManager persistence compatibility", () => { ).toEqual([scope.sessionId, "base", "temporary", "concurrent"]); }); - it("rejects a prepared assistant after another writer advances the transcript fence", async () => { - const dir = tempDirs.make("openclaw-session-manager-stale-append-fence-"); - const scope = { - agentId: "main", - sessionId: "sqlite-stale-append-fence-session", - sessionKey: "agent:main:dashboard:sqlite-stale-append-fence", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base", - message: { role: "user", content: "question" }, - }); - const manager = SessionManager.open(scope, dir); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "concurrent", - message: { role: "user", content: "concurrent" }, - }); - - expect(() => manager.appendMessage(buildAssistantMessage("temporary"))).toThrow( - "SQLite transcript changed while preparing rewrite", - ); - expect(manager.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "question" }, - ]); - expect( - (await loadTranscriptEvents(scope)).map((event) => - event && typeof event === "object" && "id" in event ? event.id : undefined, - ), - ).toEqual([scope.sessionId, "base", "concurrent"]); - }); - it("retains the append transaction fence when another write starts after commit", async () => { - const dir = tempDirs.make("openclaw-session-manager-append-fence-"); - const scope = { - agentId: "main", - sessionId: "sqlite-append-fence-session", - sessionKey: "agent:main:dashboard:sqlite-append-fence", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope, persist } = createScope("sqlite-append-fence-session"); await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base", - message: { role: "user", content: "question" }, - }); + await persist("base", { role: "user", content: "question" }); const manager = SessionManager.open(scope, dir); const temporaryId = manager.appendMessage(buildAssistantMessage("temporary")); const afterAppend = await loadTranscriptEvents(scope); @@ -553,68 +271,10 @@ describe("SessionManager persistence compatibility", () => { ]); }); - it("rejects suffix removal after a concurrent retained-prefix rewrite", async () => { - const dir = tempDirs.make("openclaw-session-manager-prefix-concurrent-"); - const scope = { - agentId: "main", - sessionId: "sqlite-remove-prefix-concurrent-session", - sessionKey: "agent:main:dashboard:sqlite-remove-prefix-concurrent", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "base", - message: { role: "user", content: "question" }, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "temporary", - message: buildAssistantMessage("temporary"), - }); - const manager = SessionManager.open(scope, dir); - const current = await loadTranscriptEvents(scope); - const base = current[1]; - if (!base || typeof base !== "object") { - throw new Error("Expected persisted base transcript event"); - } - expect( - replaceTranscriptEventsSync(scope, [ - current[0], - { ...base, message: { role: "user", content: "rewritten question" } }, - current[2], - ]), - ).toBe(true); - - expect(() => manager.removeTrailingEntries((entry) => entry.id === "temporary")).toThrow( - "SQLite transcript changed while preparing suffix removal", - ); - expect(manager.buildSessionContext().messages).toMatchObject([ - { role: "user", content: "question" }, - { role: "assistant", content: [{ type: "text", text: "temporary" }] }, - ]); - expect(await loadTranscriptEvents(scope)).toMatchObject([ - { type: "session" }, - { id: "base", message: { role: "user", content: "rewritten question" } }, - { id: "temporary" }, - ]); - }); - - it("keeps file fixture factories off the production SessionManager class", () => { - expect(SessionManager).not.toHaveProperty("create"); - expect(SessionManager).not.toHaveProperty("openFile"); - }); - - it.each(["sqlite", "bounded-sqlite", "identity", "writer", "lifecycle"])( + it.each(["bounded-sqlite", "identity", "writer", "lifecycle"])( "keeps the live tree unchanged after a rejected %s tail rewrite", async (failure) => { - const dir = tempDirs.make("openclaw-session-manager-tail-"); - const scope = { - agentId: "main", - sessionId: "tail-rewrite", - sessionKey: "agent:main:tail-rewrite", - storePath: path.join(dir, "openclaw-agent.sqlite"), - }; + const { dir, scope } = createScope("tail-rewrite"); const initialEntry = { sessionId: scope.sessionId, updatedAt: 1, @@ -623,11 +283,7 @@ describe("SessionManager persistence compatibility", () => { }; await upsertSessionEntryCore(scope, initialEntry); const seed = SessionManager.open(scope, dir); - const earlierId = seed.appendMessage({ - role: "user", - content: "earlier history", - timestamp: 1, - }); + const earlierId = seed.appendMessage(makeUserMessage("earlier history", 1)); const questionId = seed.appendMessage({ role: "user", content: "question", timestamp: 2 }); const temporaryId = seed.appendMessage(buildAssistantMessage("temporary error")); const metadataId = seed.appendCustomEntry("preserved-state", { retained: true }); @@ -748,76 +404,156 @@ describe("SessionManager persistence compatibility", () => { } }, ); +}); - it("keeps the default fixture cwd independent from its transcript directory", async () => { - const dir = tempDirs.make("openclaw-session-manager-compat-"); - const manager = openFileBackedSessionManagerForTest(path.join(dir, "session.jsonl")); +it("keeps file fixture appends and rewrites readable after an unterminated record", async () => { + const dir = tempDirs.make("openclaw-session-manager-compat-"); + const file = path.join(dir, "unterminated.jsonl"); + await fs.writeFile(file, JSON.stringify(header("unterminated", dir))); + const manager = openFileBackedSessionManagerForTest(file, dir); + manager.appendMessage(makeUserMessage("appended", 1)); + expect(openFileBackedSessionManagerForTest(file, dir).buildSessionContext().messages).toEqual([ + expect.objectContaining({ content: "appended", role: "user" }), + ]); + expect(manager.removeTrailingEntries((entry) => entry.type === "message")).toBe(1); + expect(openFileBackedSessionManagerForTest(file, dir).buildSessionContext().messages).toEqual([]); +}); - expect(manager.getCwd()).toBe(process.cwd()); - expect(manager.getSessionDir()).toBe(dir); +async function userSession() { + const { dir, scope } = createScope("user-replay"); + await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); + const user = { ...makeUserMessage("question", 1), idempotencyKey: "run:user" }; + const persist = (eventId: string, message: unknown, parentId?: string) => + appendTranscriptMessage(scope, { cwd: dir, eventId, message, now: 1, parentId }); + return { dir, scope, user, persist }; +} + +function expectSingleUser(events: unknown[], key: string) { + expect( + events.filter( + (event) => + isRecord(event) && + isRecord(event.message) && + event.message.role === "user" && + event.message.idempotencyKey === key, + ), + ).toHaveLength(1); +} + +describe("SessionManager user idempotency", () => { + it("preserves distinct keyed user turns with the same visible text", () => { + const manager = SessionManager.inMemory(); + const message = { ...makeUserMessage("same question", 1), idempotencyKey: "first:user" }; + const first = manager.appendMessage(message); + const second = { ...message, idempotencyKey: "second-run:user", timestamp: 2 }; + expect(manager.appendMessage(second)).not.toBe(first); + expect(manager.getEntries().filter((entry) => entry.type === "message")).toHaveLength(2); }); - it("keeps requested file fixture session identities aligned", async () => { - const dir = tempDirs.make("openclaw-session-manager-compat-"); - const sessionFile = path.join(dir, "session.jsonl"); - const manager = openFileBackedSessionManagerForTest(sessionFile, { - sessionId: "session-1", - sessionDir: dir, + it("allows an explicitly caller-checked keyed user append", () => { + const manager = SessionManager.inMemory(); + const message = { + ...makeUserMessage("caller-owned user", 1), + idempotencyKey: "caller-checked:user", + }; + const first = manager.appendMessage(message); + expect(manager.appendMessage(message, { idempotencyLookup: "caller-checked" })).not.toBe(first); + }); + + it("rejects a keyed user collision behind an excluded assistant", async () => { + const { dir, scope, user, persist } = await userSession(); + const excluded = { ...user, excludeFromContext: true }; + await persist("pre-persisted-user", excluded); + await persist( + "persisted-assistant", + { ...buildAssistantMessage("answer"), excludeFromContext: true }, + "pre-persisted-user", + ); + const manager = SessionManager.openBounded(scope, { cwd: dir, + maxBytes: 100_000, + maxEvents: 100, }); - - expect(manager.getSessionId()).toBe("session-1"); - expect(manager.getCwd()).toBe(dir); - expect(await fs.readFile(sessionFile, "utf8")).toContain('"id":"session-1"'); - expect(() => - openFileBackedSessionManagerForTest(sessionFile, { sessionId: "session-2" }), - ).toThrow("belongs to session-1, not session-2"); - const inMemory = vi.fn((cwd?: string) => SessionManager.inMemory(cwd)); - const ManagerClass = { inMemory } as unknown as typeof SessionManager; - openFileBackedSessionManagerForTest( - path.join(dir, "legacy.jsonl"), - undefined, - dir, - ManagerClass, + expect(() => manager.appendMessage(excluded)).toThrow( + "Session transcript keyed user is outside the current turn", ); - expect(inMemory).toHaveBeenCalledWith(dir); + expect(manager.getAppendParentId()).toBe("persisted-assistant"); + expect(manager.resolveCurrentTurnEntryId(() => true)).toBe("persisted-assistant"); + expectSingleUser(await loadTranscriptEvents(scope), user.idempotencyKey); }); - it("keeps file fixture appends and rewrites readable after an unterminated record", async () => { - const dir = tempDirs.make("openclaw-session-manager-compat-"); - const sessionFile = path.join(dir, "unterminated.jsonl"); - await fs.writeFile( - sessionFile, - JSON.stringify({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: "unterminated", - timestamp: "2026-01-01T00:00:00.000Z", - cwd: dir, - }), - ); - const manager = openFileBackedSessionManagerForTest(sessionFile, dir); - manager.appendMessage(makeUserMessage("appended", 1)); - expect( - openFileBackedSessionManagerForTest(sessionFile, dir).buildSessionContext().messages, - ).toEqual([expect.objectContaining({ content: "appended", role: "user" })]); - expect(manager.removeTrailingEntries((entry) => entry.type === "message")).toBe(1); - expect( - openFileBackedSessionManagerForTest(sessionFile, dir).buildSessionContext().messages, - ).toEqual([]); + it("adopts a keyed user persisted after the manager loaded", async () => { + const { dir, scope, user, persist } = await userSession(); + await persist("existing-assistant", buildAssistantMessage("previous answer")); + const manager = SessionManager.open(scope, dir); + await persist("ingress-persisted-user", user, "existing-assistant"); + const modelId = await manager.appendModelChange("openai", "gpt-5.5"); + const thinkingId = await manager.appendThinkingLevelChange("off"); + const metadataId = manager.appendCustomEntry("model-snapshot", { + modelApi: "openai-responses", + modelId: "gpt-5.5", + provider: "openai", + }); + expect(manager.appendMessage(user)).toBe("ingress-persisted-user"); + expect(manager.getAppendParentId()).toBe(metadataId); + const assistantId = manager.appendMessage(buildAssistantMessage("answer")); + const events = await loadTranscriptEvents(scope); + expect(events).toMatchObject([ + { type: "session" }, + { id: "existing-assistant" }, + { id: "ingress-persisted-user" }, + { id: modelId, parentId: "ingress-persisted-user" }, + { id: thinkingId, parentId: modelId }, + { id: metadataId, parentId: thinkingId }, + { id: assistantId, parentId: metadataId }, + ]); + expectSingleUser(events, user.idempotencyKey); }); - it("rotates new-session fixtures without rewriting the previous file", async () => { - const dir = tempDirs.make("openclaw-session-manager-compat-"); - const sessionFile = path.join(dir, "original.jsonl"); - const manager = openFileBackedSessionManagerForTest(sessionFile, dir); - manager.appendMessage({ role: "user", content: "original", timestamp: 1 }); - const original = await fs.readFile(sessionFile, "utf8"); - manager.newSession({ id: "replacement" }); - expect(await fs.readFile(sessionFile, "utf8")).toBe(original); - expect(manager.getSessionFile()).toBe(path.join(dir, "replacement.jsonl")); - expect(await fs.readFile(path.join(dir, "replacement.jsonl"), "utf8")).toContain( - '"id":"replacement"', + it("adopts an excluded persisted user across session setup metadata", async () => { + const { dir, scope, user, persist } = await userSession(); + const excluded = { ...user, excludeFromContext: true }; + await persist("pre-persisted-user", excluded); + const manager = SessionManager.openBounded(scope, { + cwd: dir, + maxBytes: 100_000, + maxEvents: 100, + }); + await manager.appendModelChange("openai", "gpt-5.5"); + await manager.appendThinkingLevelChange("off"); + const metadataId = manager.appendCustomEntry("model-snapshot", { + modelApi: "openai-responses", + modelId: "gpt-5.5", + provider: "openai", + }); + expect(manager.appendMessageWithTranscriptAnchor({ ...excluded, timestamp: 2 })).toMatchObject({ + entryId: "pre-persisted-user", + message: excluded, + anchor: { entryId: "pre-persisted-user", idempotencyKey: user.idempotencyKey }, + }); + expect(manager.getAppendParentId()).toBe(metadataId); + const id = manager.appendMessage(buildAssistantMessage("answer")); + const events = await loadTranscriptEvents(scope); + expect(events).toContainEqual(expect.objectContaining({ id, parentId: metadataId })); + expectSingleUser(events, user.idempotencyKey); + }); + + it("adopts the current keyed user across runtime context and compaction", async () => { + const { dir, scope, user, persist } = await userSession(); + await persist("requester-final", buildAssistantMessage("Earlier requester turn is complete")); + await persist("pre-persisted-user", user); + const manager = SessionManager.open(scope, dir); + manager.appendCustomMessageEntry( + OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE, + "Child completed; summarize its result.", + false, ); + const compactionId = manager.appendCompaction("Compacted history", "pre-persisted-user", 100); + expect(manager.appendMessage(user)).toBe("pre-persisted-user"); + expect(manager.getAppendParentId()).toBe(compactionId); + const id = manager.appendMessage(buildAssistantMessage("answer")); + const events = await loadTranscriptEvents(scope); + expect(events).toContainEqual(expect.objectContaining({ id, parentId: compactionId })); + expectSingleUser(events, user.idempotencyKey); }); }); diff --git a/src/agents/sessions/session-manager.test.ts b/src/agents/sessions/session-manager.test.ts index 1abf157a5aa6..65f65d221c34 100644 --- a/src/agents/sessions/session-manager.test.ts +++ b/src/agents/sessions/session-manager.test.ts @@ -1,4 +1,3 @@ -// Session manager tests cover SQLite persistence and in-memory tree behavior. import fs from "node:fs/promises"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -6,7 +5,6 @@ import { serialize } from "node:v8"; import { redactIdentifier } from "@openclaw/normalization-core/node-crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { makeUserMessage } from "../../../test/helpers/user-message.js"; import * as configEnv from "../../config/config-env-vars.js"; import { formatSqliteSessionFileMarker, @@ -24,12 +22,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { cleanupSessionStateForTest } from "../../test-utils/session-state-cleanup.js"; import { withMockedPlatform } from "../../test-utils/vitest-spies.js"; import { createZeroUsageFixture } from "../test-helpers/usage-fixtures.js"; -import { - buildSessionContext, - CURRENT_SESSION_VERSION, - SessionManager, - type SessionMessageEntry, -} from "./session-manager.js"; +import { CURRENT_SESSION_VERSION, SessionManager } from "./session-manager.js"; const tempDirs = useAutoCleanupTempDirTracker((cleanup) => afterEach(async () => { @@ -40,6 +33,23 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) => }), ); +function createScope(sessionId: string) { + const dir = tempDirs.make("openclaw-session-manager-"); + return { + dir, + scope: { + agentId: "main", + sessionId, + sessionKey: `agent:main:${sessionId}`, + storePath: path.join(dir, "sessions.json"), + }, + }; +} + +function sessionHeader(id: string, cwd: string, version = CURRENT_SESSION_VERSION) { + return { type: "session" as const, version, id, timestamp: "2026-01-01T00:00:00.000Z", cwd }; +} + function openMarker(marker: string, sessionKey: string, cwd: string): SessionManager { const target = parseSqliteSessionFileMarker(marker); if (!target) { @@ -49,189 +59,108 @@ function openMarker(marker: string, sessionKey: string, cwd: string): SessionMan } describe("SessionManager.open", () => { - it.each(["native", "windows"])( - "commits ordered metadata with a %s environment without host transcript writes", - async (environment) => { - const dir = tempDirs.make("openclaw-session-metadata-worker-"); - const target = { - agentId: "main", - sessionId: "metadata-worker", - sessionKey: "agent:main:metadata-worker", - storePath: path.join(dir, "agents", "main", "agent", "openclaw-agent.sqlite"), - }; - const manager = SessionManager.open(target, dir); - // Preserve the implementation so each observed call uses its actual database receiver. - // oxlint-disable-next-line typescript/unbound-method - const nativePrepare = DatabaseSync.prototype.prepare; - const hostWrites: string[] = []; - const prepare = vi.spyOn(DatabaseSync.prototype, "prepare").mockImplementation(function ( - this: DatabaseSync, - sql, - ) { - const mutation = /^\s*(insert|update|delete|replace)\b/i.exec(sql)?.[1]; - if (mutation && /\b(?:transcript_events|session_windows|session_nodes)\b/i.test(sql)) { - hostWrites.push(mutation); - } - return nativePrepare.call(this, sql); - }); - const cloneEnv = configEnv.cloneEnvWithPlatformSemantics; - const clone = - environment === "windows" - ? vi.spyOn(configEnv, "cloneEnvWithPlatformSemantics").mockImplementation((env) => { - const { OPENCLAW_STATE_DIR, ...rest } = env; - const captured = withMockedPlatform("win32", () => - cloneEnv({ - ...rest, - OpenClaw_State_Dir: OPENCLAW_STATE_DIR, - }), - ); - expect(() => serialize(captured)).toThrow("could not be cloned"); - return captured; - }) - : undefined; - let ids: string[]; - try { - ids = await Promise.all([ - manager.appendModelChange("test-provider", "test-model"), - manager.appendThinkingLevelChange("high"), - ]); - } finally { - prepare.mockRestore(); - clone?.mockRestore(); + it("commits ordered metadata with Windows environment semantics off-thread", async () => { + const { dir, scope: target } = createScope("metadata-worker"); + target.storePath = path.join(dir, "agents", "main", "agent", "openclaw-agent.sqlite"); + const manager = SessionManager.open(target, dir); + // Preserve the implementation so each observed call uses its actual database receiver. + // oxlint-disable-next-line typescript/unbound-method + const nativePrepare = DatabaseSync.prototype.prepare; + const hostWrites: string[] = []; + const prepare = vi.spyOn(DatabaseSync.prototype, "prepare").mockImplementation(function ( + this: DatabaseSync, + sql, + ) { + const mutation = /^\s*(insert|update|delete|replace)\b/i.exec(sql)?.[1]; + if (mutation && /\b(?:transcript_events|session_windows|session_nodes)\b/i.test(sql)) { + hostWrites.push(mutation); } - expect(hostWrites).toEqual([]); - expect(manager.getEntries()).toMatchObject([ - { - type: "model_change", - id: ids[0], - parentId: null, - provider: "test-provider", - modelId: "test-model", - }, - { type: "thinking_level_change", id: ids[1], parentId: ids[0], thinkingLevel: "high" }, + return nativePrepare.call(this, sql); + }); + const cloneEnv = configEnv.cloneEnvWithPlatformSemantics; + const clone = vi.spyOn(configEnv, "cloneEnvWithPlatformSemantics").mockImplementation((env) => { + const { OPENCLAW_STATE_DIR, ...rest } = env; + const captured = withMockedPlatform("win32", () => + cloneEnv({ + ...rest, + OpenClaw_State_Dir: OPENCLAW_STATE_DIR, + }), + ); + expect(() => serialize(captured)).toThrow("could not be cloned"); + return captured; + }); + let ids: string[]; + try { + ids = await Promise.all([ + manager.appendModelChange("test-provider", "test-model"), + manager.appendThinkingLevelChange("high"), ]); - expect(SessionManager.open(target, dir).getEntries()).toEqual(manager.getEntries()); - expect(loadSessionEntry(target)?.sessionId).toBe(target.sessionId); - }, - ); + } finally { + prepare.mockRestore(); + clone.mockRestore(); + } + expect(hostWrites).toEqual([]); + expect(manager.getEntries()).toMatchObject([ + { + type: "model_change", + id: ids[0], + parentId: null, + provider: "test-provider", + modelId: "test-model", + }, + { type: "thinking_level_change", id: ids[1], parentId: ids[0], thinkingLevel: "high" }, + ]); + expect(SessionManager.open(target, dir).getEntries()).toEqual(manager.getEntries()); + expect(loadSessionEntry(target)?.sessionId).toBe(target.sessionId); + }); it("opens SQLite markers without creating marker-named files and persists assistant replies", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-session"; - const sessionKey = "agent:main:dashboard:sqlite"; - const marker = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId, - storePath, + const { dir, scope } = createScope("sqlite-session"); + const marker = formatSqliteSessionFileMarker(scope); + await upsertSessionEntryCore(scope, { + sessionFile: marker, + sessionId: scope.sessionId, + updatedAt: 10, }); - await upsertSessionEntryCore( - { agentId: "main", sessionKey, storePath }, - { - sessionFile: marker, - sessionId, - updatedAt: 10, - }, - ); - await appendTranscriptMessage( - { agentId: "main", sessionId, sessionKey, storePath }, - { - cwd: dir, - message: { role: "user", content: "question" }, - }, - ); - - const sessionManager = openMarker(marker, sessionKey, dir); - expect(sessionManager.buildSessionContext().messages).toEqual([ - expect.objectContaining({ content: "question", role: "user" }), + await appendTranscriptMessage(scope, { + cwd: dir, + message: { role: "user", content: "question" }, + }); + const manager = openMarker(marker, scope.sessionKey, dir); + expect(manager.buildSessionContext().messages).toMatchObject([ + { content: "question", role: "user" }, ]); - - const assistantId = sessionManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "answer" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.5", - usage: createZeroUsageFixture(), - stopReason: "stop", - timestamp: Date.now(), - }); - const thinkingChangeId = await sessionManager.appendThinkingLevelChange("high"); - const modelChangeId = await sessionManager.appendModelChange("openai", "gpt-5.5"); - const compactionId = sessionManager.appendCompaction("summary", "assistant-1", 42); - const resetId = sessionManager.appendResetBoundary("new", assistantId); - expect(sessionManager.getBoundaryCount()).toBe(2); - + const assistantId = manager.appendMessage(buildAssistantMessage("answer")); + const thinkingId = await manager.appendThinkingLevelChange("high"); + const modelId = await manager.appendModelChange("openai", "gpt-5.5"); + const compactionId = manager.appendCompaction("summary", "assistant-1", 42); + const resetId = manager.appendResetBoundary("new", assistantId); + expect(manager.getBoundaryCount()).toBe(2); await expect(fs.stat(path.join(process.cwd(), marker))).rejects.toMatchObject({ code: "ENOENT", }); - await expect( - loadTranscriptEvents({ agentId: "main", sessionId, sessionKey, storePath }), - ).resolves.toEqual([ - expect.objectContaining({ type: "session" }), - expect.objectContaining({ - message: expect.objectContaining({ content: "question", role: "user" }), + await expect(loadTranscriptEvents(scope)).resolves.toMatchObject([ + { type: "session" }, + { type: "message", message: { content: "question", role: "user" } }, + { type: "message", - }), - expect.objectContaining({ id: assistantId, parentId: expect.any(String), - message: expect.objectContaining({ - content: [{ type: "text", text: "answer" }], - role: "assistant", - }), - type: "message", - }), - expect.objectContaining({ - id: thinkingChangeId, - thinkingLevel: "high", - type: "thinking_level_change", - }), - expect.objectContaining({ - id: modelChangeId, - modelId: "gpt-5.5", - provider: "openai", - type: "model_change", - }), - expect.objectContaining({ - firstKeptEntryId: "assistant-1", - id: compactionId, - summary: "summary", - type: "compaction", - }), - expect.objectContaining({ - firstKeptEntryId: assistantId, - id: resetId, - reason: "new", - type: "reset", - }), + message: { content: [{ type: "text", text: "answer" }], role: "assistant" }, + }, + { type: "thinking_level_change", id: thinkingId, thinkingLevel: "high" }, + { type: "model_change", id: modelId, modelId: "gpt-5.5", provider: "openai" }, + { type: "compaction", id: compactionId, firstKeptEntryId: "assistant-1", summary: "summary" }, + { type: "reset", id: resetId, firstKeptEntryId: assistantId, reason: "new" }, ]); - const reopened = openMarker(marker, sessionKey, dir); - expect(reopened.getEntries()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: thinkingChangeId, type: "thinking_level_change" }), - expect.objectContaining({ id: modelChangeId, type: "model_change" }), - expect.objectContaining({ id: compactionId, type: "compaction" }), - expect.objectContaining({ id: resetId, type: "reset" }), - ]), - ); + expect(openMarker(marker, scope.sessionKey, dir).getEntries()).toEqual(manager.getEntries()); }); it("rejects persisted legacy transcripts until doctor or import migrates them", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "legacy-persisted-session"; - const sessionKey = "agent:main:legacy-persisted-session"; - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - await upsertSessionEntryCore(scope, { sessionId, updatedAt: 1 }); + const { dir, scope } = createScope("legacy-persisted-session"); + await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); replaceTranscriptEventsSync(scope, [ - { - type: "session", - version: 1, - id: sessionId, - timestamp: "2026-01-01T00:00:00.000Z", - cwd: dir, - }, + sessionHeader(scope.sessionId, dir, 1), { type: "message", message: { role: "user", content: "legacy message" }, @@ -248,10 +177,9 @@ describe("SessionManager.open", () => { expect(existingManager.getCwd()).toBe("/original-workspace"); const currentScope = { - agentId: "main", + ...scope, sessionId: "current-persisted-session", sessionKey: "agent:main:current-persisted-session", - storePath, }; await upsertSessionEntryCore(currentScope, { sessionId: currentScope.sessionId, updatedAt: 2 }); const currentManager = SessionManager.open(currentScope, dir); @@ -269,157 +197,8 @@ describe("SessionManager.open", () => { ]); }); - it("skips malformed null rows while opening a persisted transcript", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-malformed-row", - sessionKey: "agent:main:dashboard:sqlite-malformed-row", - storePath: path.join(dir, "sessions.json"), - }; - replaceTranscriptEventsSync(scope, [ - null, - { - type: "session", - version: CURRENT_SESSION_VERSION, - id: scope.sessionId, - timestamp: "2026-01-01T00:00:00.000Z", - cwd: dir, - }, - ] as never); - - const manager = SessionManager.open(scope, dir); - expect(manager.getSessionId()).toBe(scope.sessionId); - expect(manager.getHeader()?.cwd).toBe(dir); - }); - - it("persists explicit leaf controls across SQLite reopen", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-leaf-control", - sessionKey: "agent:main:dashboard:sqlite-leaf-control", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(scope, { - sessionId: scope.sessionId, - updatedAt: 1, - }); - const manager = SessionManager.open(scope, dir); - const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); - const secondId = manager.appendMessage({ role: "user", content: "second", timestamp: 2 }); - - manager.appendLeafControl({ - targetId: firstId, - appendParentId: secondId, - appendMode: "side", - }); - await expect(loadTranscriptEvents(scope)).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "leaf", - targetId: firstId, - appendParentId: secondId, - appendMode: "side", - }), - ]), - ); - - const reopened = SessionManager.open(scope, dir); - expect(reopened.getLeafId()).toBe(firstId); - expect(reopened.getAppendParentId()).toBe(secondId); - expect(reopened.getAppendMode()).toBe("side"); - }); - - it("persists the current header before a first non-message entry", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-model-change-first", - sessionKey: "agent:main:dashboard:sqlite-model-change-first", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(scope, { - sessionId: scope.sessionId, - updatedAt: 1, - }); - - const manager = SessionManager.open(scope, dir); - await manager.appendModelChange("test-provider", "test-model"); - - await expect(loadTranscriptEvents(scope)).resolves.toEqual([ - expect.objectContaining({ - type: "session", - version: CURRENT_SESSION_VERSION, - id: scope.sessionId, - cwd: dir, - }), - expect.objectContaining({ - type: "model_change", - provider: "test-provider", - modelId: "test-model", - }), - ]); - expect(() => SessionManager.open(scope, dir)).not.toThrow(); - }); - - it("persists a fresh SQLite session header and first message", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-fresh-session", - sessionKey: "agent:main:sqlite-fresh-session", - storePath: path.join(dir, "sessions.json"), - }; - - expect(loadSessionEntry(scope)).toBeUndefined(); - const manager = SessionManager.open(scope, dir); - expect(loadSessionEntry(scope)).toBeUndefined(); - const messageId = manager.appendMessage(makeUserMessage("first message", 1)); - - await expect(loadTranscriptEvents(scope)).resolves.toEqual([ - expect.objectContaining({ - id: scope.sessionId, - type: "session", - version: CURRENT_SESSION_VERSION, - }), - expect.objectContaining({ - id: messageId, - message: expect.objectContaining({ content: "first message", role: "user" }), - type: "message", - }), - ]); - expect(loadSessionEntry(scope)).toMatchObject({ sessionId: scope.sessionId }); - }); - - it("does not rewrite an existing session row when opening an empty transcript", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-empty-existing-row-target", - sessionKey: "agent:main:sqlite-empty-existing-row", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(scope, { - sessionId: "sqlite-existing-row", - updatedAt: 123, - label: "preserved", - }); - const before = loadSessionEntry(scope); - - SessionManager.open(scope, dir); - - expect(loadSessionEntry(scope)).toEqual(before); - }); - it("does not overwrite a rebound session row when the first append seeds its header", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-stale-appender", - sessionKey: "agent:main:sqlite-rebound-before-header", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope } = createScope("sqlite-stale-appender"); await upsertSessionEntryCore(scope, { sessionId: "sqlite-current-owner", updatedAt: 456, @@ -470,19 +249,11 @@ describe("SessionManager.open", () => { }); it("refreshes cwd when switching persisted targets and rejects identity reset", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const firstTarget = { - agentId: "main", - sessionId: "first-target", - sessionKey: "agent:main:first-target", - storePath, - }; + const { dir, scope: firstTarget } = createScope("first-target"); const secondTarget = { - agentId: "main", + ...firstTarget, sessionId: "second-target", sessionKey: "agent:main:second-target", - storePath, }; await upsertSessionEntryCore(firstTarget, { sessionId: firstTarget.sessionId, updatedAt: 1 }); await upsertSessionEntryCore(secondTarget, { sessionId: secondTarget.sessionId, updatedAt: 1 }); @@ -490,13 +261,16 @@ describe("SessionManager.open", () => { cwd: path.join(dir, "first-workspace"), message: { role: "user", content: "first" }, }); - await appendTranscriptMessage(secondTarget, { - cwd: path.join(dir, "second-workspace"), - message: { role: "user", content: "second" }, - }); + replaceTranscriptEventsSync(secondTarget, [ + null, + sessionHeader(secondTarget.sessionId, path.join(dir, "second-workspace")), + ]); const manager = SessionManager.open(firstTarget); + const leaf = manager.getLeafId(); + manager.appendLeafControl({ targetId: leaf, appendParentId: leaf, appendMode: "side" }); manager.setSessionTarget(secondTarget); + expect(manager.getAppendMode()).toBeUndefined(); expect(manager.getCwd()).toBe(path.join(dir, "second-workspace")); expect(() => manager.newSession()).toThrow( @@ -504,99 +278,10 @@ describe("SessionManager.open", () => { ); }); - it("reloads prompt-time SQLite appends before the attempt resumes", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const target = { - agentId: "main", - sessionId: "prompt-reload", - sessionKey: "agent:main:prompt-reload", - storePath: path.join(dir, "sessions.json"), - }; - await upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 1 }); - const manager = SessionManager.open(target, dir); - const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); - const external = await appendTranscriptMessage(target, { - message: { role: "user", content: "prompt-time", timestamp: 2 }, - now: 2, - parentId: firstId, - }); - - expect(manager.getLeafId()).toBe(firstId); - manager.reloadPersistedTranscript(); - - expect(manager.getLeafId()).toBe(external.messageId); - }); - - it("clears side-append mode when switching to a header-only target", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const firstTarget = { - agentId: "main", - sessionId: "side-target", - sessionKey: "agent:main:side-target", - storePath, - }; - const secondTarget = { - agentId: "main", - sessionId: "header-target", - sessionKey: "agent:main:header-target", - storePath, - }; - await upsertSessionEntryCore(firstTarget, { sessionId: firstTarget.sessionId, updatedAt: 1 }); - await upsertSessionEntryCore(secondTarget, { sessionId: secondTarget.sessionId, updatedAt: 1 }); - const manager = SessionManager.open(firstTarget, dir); - const firstId = manager.appendMessage({ role: "user", content: "first", timestamp: 1 }); - manager.appendLeafControl({ targetId: firstId, appendParentId: firstId, appendMode: "side" }); - replaceTranscriptEventsSync(secondTarget, [ - { - type: "session", - version: CURRENT_SESSION_VERSION, - id: secondTarget.sessionId, - timestamp: "2026-01-01T00:00:00.000Z", - cwd: dir, - }, - ]); - - manager.setSessionTarget(secondTarget); - - expect(manager.getAppendMode()).toBeUndefined(); - }); - - it("migrates version-two hook messages before current-role validation", () => { - const manager = SessionManager.fromEntries([ - { - type: "session", - version: 2, - id: "legacy-hook-session", - timestamp: "2026-01-01T00:00:00.000Z", - cwd: "/tmp", - }, - { - type: "message", - id: "legacy-hook-message", - parentId: null, - timestamp: "2026-01-01T00:00:01.000Z", - message: { - role: "hookMessage", - content: "legacy hook context", - }, - }, - ]); - - expect(manager.getEntry("legacy-hook-message")).toMatchObject({ - message: { role: "custom", customType: "hook", content: "legacy hook context" }, - }); - }); - it("does not mutate frozen caller entries during in-memory migration", () => { const entries = [ - Object.freeze({ - type: "session" as const, - version: 2, - id: "frozen-legacy-session", - timestamp: "2026-01-01T00:00:00.000Z", - cwd: "/tmp", - }), + null, + Object.freeze(sessionHeader("frozen-legacy-session", "/tmp", 2)), Object.freeze({ type: "message" as const, id: "frozen-legacy-hook", @@ -611,20 +296,14 @@ describe("SessionManager.open", () => { expect(manager.getEntry("frozen-legacy-hook")).toMatchObject({ message: { role: "custom", customType: "hook", content: "frozen hook context" }, }); - expect(entries[1].message).toEqual({ + expect(entries[2].message).toEqual({ role: "hookMessage", content: "frozen hook context", }); }); it("keeps stale appenders valid across a reset while snapshot replacement rotates generation", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const scope = { - agentId: "main", - sessionId: "sqlite-reset-stale-appender", - sessionKey: "agent:main:dashboard:sqlite-reset-stale-appender", - storePath: path.join(dir, "sessions.json"), - }; + const { dir, scope } = createScope("sqlite-reset-stale-appender"); const marker = formatSqliteSessionFileMarker(scope); await upsertSessionEntryCore(scope, { sessionFile: marker, @@ -645,21 +324,9 @@ describe("SessionManager.open", () => { const staleManager = openMarker(marker, scope.sessionKey, dir); const resetManager = openMarker(marker, scope.sessionKey, dir); resetManager.appendResetBoundary("reset"); - expect(() => - staleManager.appendMessage({ - role: "assistant", - content: [{ type: "text", text: "late append" }], - api: "openai-responses", - provider: "openai", - model: "gpt-5.5", - usage: createZeroUsageFixture(), - stopReason: "stop", - timestamp: Date.now(), - }), - ).not.toThrow(); + expect(() => staleManager.appendMessage(buildAssistantMessage("late append"))).not.toThrow(); - const resumed = readTranscriptRawDelta(scope, { cursor: cursor.cursor }); - expect(resumed.kind).toBe("page"); + expect(readTranscriptRawDelta(scope, { cursor: cursor.cursor }).kind).toBe("page"); const events = await loadTranscriptEvents(scope); expect(events.map((event) => (event as { type?: unknown }).type)).toContain("reset"); const context = JSON.stringify(openMarker(marker, scope.sessionKey, dir).buildSessionContext()); @@ -673,171 +340,23 @@ describe("SessionManager.open", () => { }); }); - it("reuses a pre-persisted user as the canonical SQLite parent", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-runtime-user-parent"; - const sessionKey = "agent:main:dashboard:sqlite-runtime-user-parent"; - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - const marker = formatSqliteSessionFileMarker(scope); - const userMessage = { - role: "user" as const, - content: "question", - idempotencyKey: "runtime-user-parent:user", - timestamp: 1, - }; - await upsertSessionEntryCore(scope, { sessionFile: marker, sessionId, updatedAt: 1 }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "pre-persisted-user", - message: userMessage, - now: 1, - }); - const bootstrap = readTranscriptRawDelta(scope, { maxBytes: 10_000, maxEvents: 100 }); - expect(bootstrap.kind).toBe("page"); - if (bootstrap.kind !== "page") { - throw new Error(`expected bootstrap page, got ${bootstrap.kind}`); - } - - const sessionManager = openMarker(marker, sessionKey, dir); - const runtimeUserId = sessionManager.appendMessage(userMessage); - const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); - const resumed = readTranscriptRawDelta(scope, { - cursor: bootstrap.cursor, - maxBytes: 10_000, - maxEvents: 100, - }); - - expect(resumed.kind).toBe("page"); - if (resumed.kind !== "page") { - throw new Error(`expected append page, got ${resumed.kind}`); - } - expect(runtimeUserId).toBe("pre-persisted-user"); - expect(resumed.events.map((row) => (row.event as { id?: string }).id)).toEqual([assistantId]); - expect(resumed.events[0]?.event).toMatchObject({ parentId: "pre-persisted-user" }); - expect( - (await loadTranscriptEvents(scope)).filter( - (event) => - (event as { message?: { role?: string; idempotencyKey?: string } }).message?.role === - "user" && - (event as { message?: { idempotencyKey?: string } }).message?.idempotencyKey === - userMessage.idempotencyKey, - ), - ).toHaveLength(1); - }); - - it("preserves root-to-leaf ordering across session branches", () => { - const entries = [ - { - type: "message", - id: "root", - parentId: null, - timestamp: "2026-07-16T00:00:00.000Z", - message: { role: "user", content: "root", timestamp: 1 }, - }, - { - type: "message", - id: "main-leaf", - parentId: "root", - timestamp: "2026-07-16T00:00:01.000Z", - message: { role: "user", content: "main", timestamp: 2 }, - }, - { - type: "message", - id: "side-middle", - parentId: "root", - timestamp: "2026-07-16T00:00:02.000Z", - message: { role: "user", content: "side middle", timestamp: 3 }, - }, - { - type: "message", - id: "side-leaf", - parentId: "side-middle", - timestamp: "2026-07-16T00:00:03.000Z", - message: { role: "user", content: "side leaf", timestamp: 4 }, - }, - ] satisfies SessionMessageEntry[]; - const manager = SessionManager.inMemory(); - for (const entry of entries) { - manager.appendMessage(entry.message); - if (entry.id === "main-leaf") { - manager.branch(manager.getBranch().at(0)!.id); - } - } - - expect(buildSessionContext(entries, "side-leaf").messages).toMatchObject([ - { content: "root" }, - { content: "side middle" }, - { content: "side leaf" }, - ]); - expect( - manager - .getBranch() - .filter((entry) => entry.type === "message") - .map((entry) => entry.message), - ).toMatchObject([{ content: "root" }, { content: "side middle" }, { content: "side leaf" }]); - }); - - it.each([ - { label: "missing", names: [], expected: undefined, rewind: false }, - { - label: "single-line normalized", - names: [" first\nsecond\r\nthird "], - expected: "first second third", - rewind: false, - }, - { label: "cleared", names: ["old name", " "], expected: undefined, rewind: false }, - { - label: "off-branch latest", - names: ["old name", "latest name"], - expected: "latest name", - rewind: true, - }, - ])("reads $label session names", ({ names, expected, rewind }) => { + it("reads the latest normalized name even off the selected branch", () => { const manager = SessionManager.inMemory(); + expect(manager.getSessionName()).toBeUndefined(); const root = manager.appendMessage({ role: "user", content: "root", timestamp: 1 }); - for (const name of names) { - manager.appendSessionInfo(name); - } - if (rewind) { - manager.branch(root); - } - expect(manager.getSessionName()).toBe(expected); - }); - - it("ignores opaque SQLite rows while resolving the session cwd", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-opaque-header"; - const sessionKey = "agent:main:dashboard:sqlite-opaque-header"; - const marker = formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }); - await upsertSessionEntryCore( - { agentId: "main", sessionKey, storePath }, - { sessionFile: marker, sessionId, updatedAt: 10 }, - ); - - const loaded = SessionManager.fromEntries([ - null, - { - type: "session", - version: CURRENT_SESSION_VERSION, - id: sessionId, - timestamp: "2026-07-14T00:00:00.000Z", - cwd: dir, - }, - ]); - - expect(loaded.getCwd()).toBe(dir); + manager.appendSessionInfo("old name"); + manager.appendSessionInfo(" first\nsecond\r\nthird "); + manager.branch(root); + expect(manager.getSessionName()).toBe("first second third"); }); it("rejects persistence after the session target rebounds", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-prompt-release-rebound"; + const { dir, scope } = createScope("sqlite-prompt-release-rebound"); const sensitivePeer = "+15551234567"; const sessionKey = `agent:main:whatsapp:direct:${sensitivePeer}\n\x1b[31mspoof`; - const marker = formatSqliteSessionFileMarker({ agentId: "main", sessionId, storePath }); - const scope = { agentId: "main", sessionId, sessionKey, storePath }; + scope.sessionKey = sessionKey; + const { sessionId, storePath } = scope; + const marker = formatSqliteSessionFileMarker(scope); await upsertSessionEntryCore(scope, { sessionFile: marker, sessionId, updatedAt: 10 }); const user = await appendTranscriptMessage(scope, { cwd: dir, @@ -908,94 +427,6 @@ describe("SessionManager.open", () => { expect(sessionManager.getLeafId()).toBe(leafBeforeRejectedAppends); expect(sessionManager.getAppendParentId()).toBe(appendParentBeforeRejectedAppends); }); - - it("reloads SQLite markers through setSessionFile without switching to file paths", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "legacy-sqlite-marker-reload"; - const sessionKey = "agent:main:dashboard:legacy-sqlite-marker-reload"; - const marker = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId, - storePath, - }); - const scope = { agentId: "main", sessionId, sessionKey, storePath }; - await upsertSessionEntryCore( - { agentId: "main", sessionKey, storePath }, - { - sessionFile: marker, - sessionId, - updatedAt: 10, - }, - ); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "user-message", - message: { role: "user", content: "question before reload" }, - }); - - const sessionManager = openMarker(marker, sessionKey, dir); - sessionManager.setSessionTarget(scope); - expect(sessionManager.buildSessionContext().messages).toEqual([ - expect.objectContaining({ content: "question before reload", role: "user" }), - ]); - sessionManager.appendMessage(buildAssistantMessage("answer after reload")); - - await expect(fs.stat(path.join(process.cwd(), marker))).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect(loadTranscriptEvents(scope)).resolves.toEqual([ - expect.objectContaining({ type: "session" }), - expect.objectContaining({ - message: expect.objectContaining({ content: "question before reload", role: "user" }), - type: "message", - }), - expect.objectContaining({ - message: expect.objectContaining({ - content: [{ type: "text", text: "answer after reload" }], - role: "assistant", - }), - type: "message", - }), - ]); - }); - - it("persists user turns when a SQLite marker has no external recorder", async () => { - const dir = tempDirs.make("openclaw-session-manager-"); - const storePath = path.join(dir, "sessions.json"); - const sessionId = "sqlite-direct-user-session"; - const sessionKey = "agent:main:voice:direct-user"; - const marker = formatSqliteSessionFileMarker({ - agentId: "main", - sessionId, - storePath, - }); - await upsertSessionEntryCore( - { agentId: "main", sessionKey, storePath }, - { - sessionFile: marker, - sessionId, - updatedAt: 10, - }, - ); - - const sessionManager = openMarker(marker, sessionKey, dir); - const userId = sessionManager.appendMessage({ - role: "user", - content: "voice prompt", - timestamp: Date.now(), - }); - - await expect( - loadTranscriptEvents({ agentId: "main", sessionId, sessionKey, storePath }), - ).resolves.toContainEqual( - expect.objectContaining({ - id: userId, - message: expect.objectContaining({ content: "voice prompt", role: "user" }), - type: "message", - }), - ); - }); }); function buildAssistantMessage(text: string) { diff --git a/src/agents/sessions/session-manager.user-idempotency.test.ts b/src/agents/sessions/session-manager.user-idempotency.test.ts deleted file mode 100644 index e4e404d1f86e..000000000000 --- a/src/agents/sessions/session-manager.user-idempotency.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -// Focused coverage for exact-key user admission and physical SQLite parent ownership. -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; -import { - appendTranscriptMessage, - loadTranscriptEvents, - upsertSessionEntryCore, -} from "../../config/sessions/session-accessor.js"; -import { closeOpenClawAgentDatabasesAsync } from "../../state/openclaw-agent-db.js"; -import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../internal-runtime-context.js"; -import { createZeroUsageFixture } from "../test-helpers/usage-fixtures.js"; -import { SessionManager } from "./session-manager.js"; - -const tempDirs = useAutoCleanupTempDirTracker((cleanup) => - afterEach(async () => { - for (const dir of tempDirs.dirs) { - await closeOpenClawAgentDatabasesAsync(dir); - } - cleanup(); - }), -); - -function buildAssistantMessage(text: string) { - return { - role: "assistant" as const, - content: [{ type: "text" as const, text }], - api: "messages" as const, - provider: "anthropic" as const, - model: "sonnet-4.6" as const, - usage: createZeroUsageFixture(), - stopReason: "stop" as const, - timestamp: Date.now(), - }; -} - -describe("SessionManager user idempotency", () => { - it("preserves distinct keyed user turns with the same visible text", () => { - const sessionManager = SessionManager.inMemory(); - const makeMessage = (idempotencyKey: string, timestamp: number) => ({ - role: "user" as const, - content: "same question", - idempotencyKey, - timestamp, - }); - const first = sessionManager.appendMessage(makeMessage("first-run:user", 1)); - const second = sessionManager.appendMessage(makeMessage("second-run:user", 2)); - - expect(second).not.toBe(first); - expect(sessionManager.getEntries().filter((entry) => entry.type === "message")).toHaveLength(2); - }); - - it("allows an explicitly caller-checked keyed user append", () => { - const sessionManager = SessionManager.inMemory(); - const message = { - role: "user" as const, - content: "caller-owned user", - idempotencyKey: "caller-checked:user", - timestamp: 1, - }; - const first = sessionManager.appendMessage(message); - - expect(sessionManager.appendMessage(message, { idempotencyLookup: "caller-checked" })).not.toBe( - first, - ); - }); - - it.each([false, true])( - "rejects a keyed user collision outside the current SQLite append parent (excluded: %s)", - async (excludeFromContext) => { - const dir = tempDirs.make("openclaw-session-manager-user-idempotency-"); - const scope = { - agentId: "main", - sessionId: "sqlite-runtime-user-ancestor", - sessionKey: "agent:main:dashboard:sqlite-runtime-user-ancestor", - storePath: path.join(dir, "sessions.json"), - }; - const userMessage = { - role: "user" as const, - content: "question", - idempotencyKey: "runtime-user-ancestor:user", - ...(excludeFromContext ? { excludeFromContext: true } : {}), - timestamp: 1, - }; - await upsertSessionEntryCore(scope, { - sessionFile: formatSqliteSessionFileMarker(scope), - sessionId: scope.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "pre-persisted-user", - message: userMessage, - now: 1, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "persisted-assistant", - message: { - ...buildAssistantMessage("answer"), - ...(excludeFromContext ? { excludeFromContext: true } : {}), - }, - parentId: "pre-persisted-user", - }); - const sessionManager = SessionManager.openBounded(scope, { - cwd: dir, - maxBytes: 100_000, - maxEvents: 100, - }); - - expect(() => sessionManager.appendMessage(userMessage)).toThrow( - "Session transcript keyed user is outside the current turn", - ); - expect(sessionManager.getAppendParentId()).toBe("persisted-assistant"); - expect(sessionManager.resolveCurrentTurnEntryId(() => true)).toBe( - excludeFromContext ? "persisted-assistant" : null, - ); - expect( - (await loadTranscriptEvents(scope)).filter( - (event) => - (event as { message?: { role?: string; idempotencyKey?: string } }).message?.role === - "user" && - (event as { message?: { idempotencyKey?: string } }).message?.idempotencyKey === - userMessage.idempotencyKey, - ), - ).toHaveLength(1); - }, - ); - - it("adopts a keyed user persisted after the manager loaded", async () => { - const dir = tempDirs.make("openclaw-session-manager-user-idempotency-"); - const scope = { - agentId: "main", - sessionId: "sqlite-runtime-user-concurrent-ingress", - sessionKey: "agent:main:dashboard:sqlite-runtime-user-concurrent-ingress", - storePath: path.join(dir, "sessions.json"), - }; - const userMessage = { - role: "user" as const, - content: "question", - idempotencyKey: "runtime-user-concurrent-ingress:user", - timestamp: 1, - }; - await upsertSessionEntryCore(scope, { - sessionFile: formatSqliteSessionFileMarker(scope), - sessionId: scope.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "existing-assistant", - message: buildAssistantMessage("previous answer"), - now: 1, - }); - const sessionManager = SessionManager.open(scope, dir); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "ingress-persisted-user", - message: userMessage, - now: 2, - parentId: "existing-assistant", - }); - const modelChangeId = await sessionManager.appendModelChange("openai", "gpt-5.5"); - const thinkingId = await sessionManager.appendThinkingLevelChange("off"); - const metadataId = sessionManager.appendCustomEntry("model-snapshot", { - modelApi: "openai-responses", - modelId: "gpt-5.5", - provider: "openai", - }); - - expect(sessionManager.appendMessage(userMessage)).toBe("ingress-persisted-user"); - expect(sessionManager.getAppendParentId()).toBe(metadataId); - - const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); - const events = await loadTranscriptEvents(scope); - expect(events.find((event) => (event as { id?: string }).id === modelChangeId)).toMatchObject({ - parentId: "ingress-persisted-user", - }); - expect(events.find((event) => (event as { id?: string }).id === thinkingId)).toMatchObject({ - parentId: modelChangeId, - }); - expect(events.find((event) => (event as { id?: string }).id === metadataId)).toMatchObject({ - parentId: thinkingId, - }); - expect(events.find((event) => (event as { id?: string }).id === assistantId)).toMatchObject({ - parentId: metadataId, - }); - expect( - events.filter( - (event) => - (event as { message?: { role?: string; idempotencyKey?: string } }).message?.role === - "user" && - (event as { message?: { idempotencyKey?: string } }).message?.idempotencyKey === - userMessage.idempotencyKey, - ), - ).toHaveLength(1); - }); - - it.each([false, true])( - "adopts a persisted user across context-free session setup metadata (excluded: %s)", - async (excludeFromContext) => { - const dir = tempDirs.make("openclaw-session-manager-user-idempotency-"); - const scope = { - agentId: "main", - sessionId: "sqlite-runtime-user-setup-metadata", - sessionKey: "agent:main:dashboard:sqlite-runtime-user-setup-metadata", - storePath: path.join(dir, "sessions.json"), - }; - const userMessage = { - role: "user" as const, - content: "question", - idempotencyKey: "runtime-user-setup-metadata:user", - ...(excludeFromContext ? { excludeFromContext: true } : {}), - timestamp: 1, - }; - await upsertSessionEntryCore(scope, { - sessionFile: formatSqliteSessionFileMarker(scope), - sessionId: scope.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "pre-persisted-user", - message: userMessage, - now: 1, - }); - - const sessionManager = SessionManager.openBounded(scope, { - cwd: dir, - maxBytes: 100_000, - maxEvents: 100, - }); - await sessionManager.appendModelChange("openai", "gpt-5.5"); - await sessionManager.appendThinkingLevelChange("off"); - const metadataId = sessionManager.appendCustomEntry("model-snapshot", { - modelApi: "openai-responses", - modelId: "gpt-5.5", - provider: "openai", - }); - - expect( - sessionManager.appendMessageWithTranscriptAnchor({ ...userMessage, timestamp: 2 }), - ).toMatchObject({ - entryId: "pre-persisted-user", - message: userMessage, - anchor: { entryId: "pre-persisted-user", idempotencyKey: userMessage.idempotencyKey }, - }); - expect(sessionManager.getAppendParentId()).toBe(metadataId); - - const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); - const events = await loadTranscriptEvents(scope); - expect(events.find((event) => (event as { id?: string }).id === assistantId)).toMatchObject({ - parentId: metadataId, - }); - expect( - events.filter( - (event) => - (event as { message?: { role?: string; idempotencyKey?: string } }).message?.role === - "user" && - (event as { message?: { idempotencyKey?: string } }).message?.idempotencyKey === - userMessage.idempotencyKey, - ), - ).toHaveLength(1); - }, - ); - - it.each([false, true])( - "adopts the current keyed user across compaction (runtime context: %s)", - async (runtimeContext) => { - const dir = tempDirs.make("openclaw-session-manager-user-idempotency-"); - const scope = { - agentId: "main", - sessionId: "sqlite-runtime-user-compaction", - sessionKey: "agent:main:dashboard:sqlite-runtime-user-compaction", - storePath: path.join(dir, "sessions.json"), - }; - const userMessage = { - role: "user" as const, - content: "question", - idempotencyKey: "runtime-user-compaction:user", - timestamp: 1, - }; - await upsertSessionEntryCore(scope, { - sessionFile: formatSqliteSessionFileMarker(scope), - sessionId: scope.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "requester-final", - message: buildAssistantMessage("Earlier requester turn is complete"), - now: 0, - }); - await appendTranscriptMessage(scope, { - cwd: dir, - eventId: "pre-persisted-user", - message: userMessage, - now: 1, - }); - const sessionManager = SessionManager.open(scope, dir); - if (runtimeContext) { - sessionManager.appendCustomMessageEntry( - OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE, - "Child completed; summarize its result.", - false, - ); - } - const compactionId = sessionManager.appendCompaction( - "Compacted history", - "pre-persisted-user", - 100, - ); - - expect(sessionManager.appendMessage(userMessage)).toBe("pre-persisted-user"); - expect(sessionManager.getAppendParentId()).toBe(compactionId); - - const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer")); - const events = await loadTranscriptEvents(scope); - expect(events.find((event) => (event as { id?: string }).id === assistantId)).toMatchObject({ - parentId: compactionId, - }); - expect( - events.filter( - (event) => - (event as { message?: { role?: string; idempotencyKey?: string } }).message?.role === - "user" && - (event as { message?: { idempotencyKey?: string } }).message?.idempotencyKey === - userMessage.idempotencyKey, - ), - ).toHaveLength(1); - }, - ); -}); diff --git a/src/gateway/openresponses-http.server.file-only.test.ts b/src/gateway/openresponses-http.server.file-only.test.ts index 9c454d1da067..807bdfaa38db 100644 --- a/src/gateway/openresponses-http.server.file-only.test.ts +++ b/src/gateway/openresponses-http.server.file-only.test.ts @@ -48,15 +48,30 @@ beforeEach(() => { vi.clearAllMocks(); }); -async function postResponses(body: unknown) { - return await fetch(`http://127.0.0.1:${port}/v1/responses`, { +async function postInput(input: unknown, instructions?: string) { + agentCommandMock.mockResolvedValueOnce({ + payloads: [{ text: "ok", mediaUrl: null }], + meta: { durationMs: 0 }, + }); + const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json", "x-openclaw-scopes": "operator.write", }, - body: JSON.stringify(body), + body: JSON.stringify({ model: "openclaw", input, instructions }), }); + expect(res.status, await res.text()).toBe(200); + expect(agentCommandMock).toHaveBeenCalledTimes(1); + return agentCommandMock.mock.calls[0]?.[0] as { + message?: string; + images?: unknown[]; + extraSystemPrompt?: string; + }; +} + +function message(content: unknown) { + return { type: "message", role: "user", content }; } function createInputImage() { @@ -79,80 +94,18 @@ function createInputFile(filename: string) { } describe("OpenResponses file-only input that renders to images", () => { - it("labels only incomplete file text through the HTTP input_file boundary", async () => { - const actual = - await vi.importActual("../media/input-files.js"); - extractFileContentFromSourceMock.mockImplementation(actual.extractFileContentFromSource); - agentCommandMock.mockResolvedValueOnce(undefined); - const maxChars = 60_000; - const files = [ - { name: "under.txt", length: maxChars - 1 }, - { name: "exact.txt", length: maxChars }, - { name: "over.txt", length: maxChars + 1 }, - ]; - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role: "user", - content: files.map(({ name, length }) => ({ - type: "input_file", - source: { - type: "base64", - media_type: "text/plain", - filename: name, - data: Buffer.from("a".repeat(length)).toString("base64"), - }, - })), - }, - ], - }); - expect(res.status, await res.text()).toBe(200); - const opts = agentCommandMock.mock.calls[0]?.[0] as { extraSystemPrompt?: string }; - const blocks = [ - ...(opts.extraSystemPrompt ?? "").matchAll(/([\s\S]*?)<\/file>/g), - ]; - expect(blocks.map((block) => block[1])).toEqual(files.map((file) => file.name)); - expect( - blocks.map((block) => block[2]?.includes("[Partial document: text truncated.]")), - ).toEqual([false, false, true]); - expect(blocks[2]?.[2]).toContain("a".repeat(maxChars)); - expect(blocks[2]?.[2]).not.toContain("a".repeat(maxChars + 1)); - }); - it("keeps extraction truncation visible outside uploaded file content", async () => { extractFileContentFromSourceMock.mockResolvedValueOnce({ filename: "partial.pdf", text: "visible prefix", images: [], metadata: { - pages: { - total: 21, - processed: [1, 2, 3], - selection: "automatic", - truncated: true, - }, + pages: { total: 21, processed: [1, 2, 3], selection: "automatic", truncated: true }, textTruncated: false, imagesTruncated: false, }, }); - agentCommandMock.mockResolvedValueOnce(undefined); - - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role: "user", - content: [createInputFile("partial.pdf")], - }, - ], - }); - - const body = await res.text(); - expect(res.status, body).toBe(200); - const opts = agentCommandMock.mock.calls[0]?.[0] as { extraSystemPrompt?: string }; + const opts = await postInput([message([createInputFile("partial.pdf")])]); expect(opts.extraSystemPrompt).toContain("[Partial document: 3 of 21 pages processed.]"); expect(opts.extraSystemPrompt).toMatch( /\[Partial document[^]*<< { { type: "image", data: Buffer.alloc(8, 1).toString("base64"), mimeType: "image/png" }, ], }); - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - const res = await postResponses({ - model: "openclaw", - instructions: "Describe the attached scan.", - input: [ - { - type: "message", - role: "user", - content: [ - { - type: "input_file", - source: { - type: "base64", - media_type: "application/pdf", - data: Buffer.from("%PDF-1.4 scanned").toString("base64"), - filename: "scan.pdf", - }, + const opts = await postInput( + [ + message([ + { + type: "input_file", + source: { + type: "base64", + media_type: "application/pdf", + data: Buffer.from("%PDF-1.4 scanned").toString("base64"), + filename: "scan.pdf", }, - ], - }, + }, + ]), ], - }); - - expect(res.status).toBe(200); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - const opts = agentCommandMock.mock.calls[0]?.[0] as { message?: string; images?: unknown[] }; + "Describe the attached scan.", + ); expect(opts.message ?? "").not.toBe(""); - expect(opts.images?.length).toBe(1); - await res.text(); + expect(opts.images).toHaveLength(1); }); - it("keeps an empty extracted file visible to the model", async () => { - extractFileContentFromSourceMock.mockResolvedValueOnce({ - filename: "empty.txt", - text: "", - images: [], - }); - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role: "user", - content: [ - { - type: "input_file", - source: { - type: "base64", - media_type: "text/plain", - data: Buffer.from("binary-only file").toString("base64"), - filename: "empty.txt", - }, - }, - ], - }, - ], - }); - - const body = await res.text(); - expect(res.status, body).toBe(200); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - const opts = agentCommandMock.mock.calls[0]?.[0] as { extraSystemPrompt?: string }; - expect(opts.extraSystemPrompt).toContain(''); - expect(opts.extraSystemPrompt).toContain("[No extractable text]"); - }); - - it.each([ - { - name: "a newer user message", - followup: { type: "message", role: "user", content: "Describe the previous answer." }, - expectedCurrentMessage: "Describe the previous answer.", - }, - { - name: "a terminal client-tool result", - followup: { + it("does not replay historical attachments after a terminal client-tool result", async () => { + const opts = await postInput([ + message([createInputImage(), createInputFile("historical.txt")]), + { type: "message", role: "assistant", content: "I inspected the attachments." }, + { type: "function_call_output", call_id: "call_lookup", output: "The previous answer was accepted.", }, - expectedCurrentMessage: "The previous answer was accepted.", - }, - ])("does not replay historical attachments after $name", async (testCase) => { - extractFileContentFromSourceMock.mockResolvedValue({ - filename: "historical.txt", - text: "historical file contents", - images: [], - }); - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role: "user", - content: [createInputImage(), createInputFile("historical.txt")], - }, - { type: "message", role: "assistant", content: "I inspected the attachments." }, - testCase.followup, - ], - }); - - const body = await res.text(); - expect(res.status, body).toBe(200); + ]); expect(extractFileContentFromSourceMock).not.toHaveBeenCalled(); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - const opts = agentCommandMock.mock.calls[0]?.[0] as { - message?: string; - images?: unknown[]; - extraSystemPrompt?: string; - }; - expect(opts.message).toContain(testCase.expectedCurrentMessage); + expect(opts.message).toContain("The previous answer was accepted."); expect(opts.message).not.toContain("User sent image(s) with no text."); expect(opts.images).toBeUndefined(); expect(opts.extraSystemPrompt ?? "").not.toContain("historical.txt"); @@ -295,121 +165,38 @@ describe("OpenResponses file-only input that renders to images", () => { images: [], }), ); - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "Inspect the first attachments." }, - createInputImage(), - createInputFile("historical.txt"), - ], - }, - { type: "message", role: "assistant", content: "The first attachments were inspected." }, - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "Inspect only the current attachments." }, - createInputImage(), - createInputFile("current.txt"), - ], - }, - ], - }); - - const body = await res.text(); - expect(res.status, body).toBe(200); + const opts = await postInput([ + message([ + { type: "input_text", text: "Inspect the first attachments." }, + createInputImage(), + createInputFile("historical.txt"), + ]), + { type: "message", role: "assistant", content: "The first attachments were inspected." }, + message([ + { type: "input_text", text: "Inspect only the current attachments." }, + createInputImage(), + createInputFile("current.txt"), + ]), + ]); expect(extractFileContentFromSourceMock).toHaveBeenCalledTimes(1); - const opts = agentCommandMock.mock.calls[0]?.[0] as { - images?: unknown[]; - extraSystemPrompt?: string; - }; expect(opts.images).toHaveLength(1); expect(opts.extraSystemPrompt).toContain("current.txt"); expect(opts.extraSystemPrompt).not.toContain("historical.txt"); }); - it.each(["system", "developer", "assistant"] as const)( - "ignores attachments belonging to a historical %s message", - async (role) => { - extractFileContentFromSourceMock.mockResolvedValue({ - filename: "not-user-owned.txt", - text: "should not become current input", - images: [], - }); - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role, - content: [ - { type: "input_text", text: "Earlier non-user context." }, - createInputImage(), - createInputFile("not-user-owned.txt"), - ], - }, - { type: "message", role: "user", content: "Answer this current question." }, - ], - }); - - const body = await res.text(); - expect(res.status, body).toBe(200); - expect(extractFileContentFromSourceMock).not.toHaveBeenCalled(); - const opts = agentCommandMock.mock.calls[0]?.[0] as { - images?: unknown[]; - extraSystemPrompt?: string; - }; - expect(opts.images).toBeUndefined(); - expect(opts.extraSystemPrompt ?? "").not.toContain("not-user-owned.txt"); - }, - ); - - it.each(["input_image", "input_file"] as const)( - "does not fetch a historical %s URL on a newer text-only turn", - async (type) => { - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never); - - const res = await postResponses({ - model: "openclaw", - input: [ - { - type: "message", - role: "user", - content: [{ type, source: { type: "url", url: "https://example.com/historical" } }], - }, - { type: "message", role: "user", content: "Answer without fetching history." }, - ], - }); - - const body = await res.text(); - expect(res.status, body).toBe(200); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - expect(extractFileContentFromSourceMock).not.toHaveBeenCalled(); - }, - ); - it("counts historical image and file URLs against the request-wide source limit", async () => { - const historicalParts = Array.from({ length: 9 }, (_, index) => ({ + const parts = Array.from({ length: 9 }, (_, index) => ({ type: index % 2 === 0 ? "input_image" : "input_file", source: { type: "url", url: `https://example.com/historical-${index}` }, })); - - const res = await postResponses({ - model: "openclaw", - input: [ - { type: "message", role: "user", content: historicalParts }, - { type: "message", role: "user", content: "Answer without fetching history." }, - ], + const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json", "x-openclaw-scopes": "operator.write" }, + body: JSON.stringify({ + model: "openclaw", + input: [message(parts), message("Answer without fetching history.")], + }), }); - expect(res.status).toBe(400); expect(agentCommandMock).not.toHaveBeenCalled(); expect(extractFileContentFromSourceMock).not.toHaveBeenCalled(); diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 69300e93e714..687ecbeb1499 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -1,5 +1,3 @@ -// OpenAI Responses HTTP tests cover response creation, streaming, tool calls, -// response-session lookup, limits, auth scopes, and provider error mapping. import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; @@ -10,7 +8,6 @@ import { createClientToolNameConflictError } from "../agents/agent-tool-definiti import { FailoverError } from "../agents/failover-error.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; -import { recordAgentRunTerminalOutcome } from "../channels/turn/agent-run-terminal-outcome.js"; import { resetConfigRuntimeState, type GatewayAuthConfig } from "../config/config.js"; import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; @@ -32,16 +29,8 @@ import { registerOpenResponsesHttpMediaInputTests, } from "./http-input-media.test-support.js"; import { - assistantSnapshotCases, - streamingFailureCases, - captureStreamingTerminals, - emitResolvedStreamingFailure, incompatibleReplacementCases, - compatibleReplacementCases, - bufferedReplacementCases, emitIncompatibleAssistantReplacement, - emitCompatibleAssistantReplacement, - emitBufferedAssistantReplacement, createOpenAiHttpTestClient, parseSseEvents, collectSseEventTypes, @@ -79,12 +68,7 @@ installGatewayTestHooks({ scope: "suite" }); let enabledServer: Awaited>; let enabledPort: number; -let openResponsesTesting: { - resolveResponsesLimits(config: { maxUrlParts?: number } | undefined): { maxUrlParts: number }; -}; - beforeAll(async () => { - ({ testing: openResponsesTesting } = await import("./openresponses-http.js")); const started = await startGatewayServerWithRetries({ port: await getGatewayTestPort(), opts: { @@ -133,7 +117,7 @@ async function postResponses( headers?: Record, signal?: AbortSignal, ) { - const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { + return await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json", @@ -143,7 +127,21 @@ async function postResponses( body: JSON.stringify(body), ...(signal ? { signal } : {}), }); - return res; +} + +function post(body: Record = {}, headers?: Record) { + return postResponses(enabledPort, { model: "openclaw", input: "hi", ...body }, headers); +} + +function responseEvent(events: ReturnType, name = "response.completed") { + return (parseSseData(findSseEvent(events, name)) as { response: ResponseResource }).response; +} + +function createStream() { + return createOpenAiHttpTestClient(enabledPort).responses.stream({ + model: "openclaw", + input: "hi", + }); } function requireSessionKey(value: string | undefined, label: string): string { @@ -178,11 +176,7 @@ async function ensureResponseConsumed(res: Response) { } const WEATHER_TOOL = [ - { - type: "function", - name: "get_weather", - description: "Get weather", - }, + { type: "function", name: "get_weather", description: "Get weather" }, ] as const; const STREAM_FAILURE_CASES = [ @@ -215,45 +209,34 @@ const STREAM_FAILURE_CASES = [ }, ] as const; +function inputMessage(content: unknown[]) { + return [{ type: "message", role: "user", content }]; +} + function buildUrlInputMessage(params: { kind: "input_file" | "input_image"; url: string; text?: string; }) { - return [ - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: params.text ?? "read this" }, - { - type: params.kind, - source: { type: "url", url: params.url }, - }, - ], - }, - ]; + return inputMessage([ + { type: "input_text", text: params.text ?? "read this" }, + { type: params.kind, source: { type: "url", url: params.url } }, + ]); } function buildFileInputMessage(text: string, filename: string, message?: string) { - return [ + return inputMessage([ + ...(message === undefined ? [] : [{ type: "input_text", text: message }]), { - type: "message", - role: "user", - content: [ - ...(message === undefined ? [] : [{ type: "input_text", text: message }]), - { - type: "input_file", - source: { - type: "base64", - media_type: "text/plain", - data: Buffer.from(text).toString("base64"), - filename, - }, - }, - ], + type: "input_file", + source: { + type: "base64", + media_type: "text/plain", + data: Buffer.from(text).toString("base64"), + filename, + }, }, - ]; + ]); } function buildResponsesUrlPolicyConfig(maxUrlParts: number) { @@ -268,10 +251,7 @@ function buildResponsesUrlPolicyConfig(maxUrlParts: number) { allowUrl: true, urlAllowlist: ["cdn.example.com", "*.assets.example.com"], }, - images: { - allowUrl: true, - urlAllowlist: ["images.example.com"], - }, + images: { allowUrl: true, urlAllowlist: ["images.example.com"] }, }, }, }, @@ -279,14 +259,6 @@ function buildResponsesUrlPolicyConfig(maxUrlParts: number) { }; } -it("uses default URL part limits for non-finite OpenResponses config caps", () => { - const limits = openResponsesTesting.resolveResponsesLimits({ - maxUrlParts: Number.POSITIVE_INFINITY, - }); - - expect(limits.maxUrlParts).toBe(8); -}); - async function expectInvalidRequest( res: Response, messagePattern: RegExp, @@ -329,64 +301,14 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(context?.resolveGatewayContext).toBe(resolveGatewayContext); }); - it("returns a typed selection error unless an ownerless fleet request selects an agent", async () => { - try { - testState.agentsConfig = { - ownership: "explicit", - list: [{ id: "main" }, { id: "beta" }], - }; - resetConfigRuntimeState(); - agentCommandMock.mockClear(); - - const missing = await postResponses(enabledPort, { model: "openclaw", input: "hi" }); - expect(missing.status).toBe(400); - const missingJson = (await missing.json()) as { error?: { message?: string; type?: string } }; - expect(missingJson.error?.type).toBe("invalid_request_error"); - expect(missingJson.error?.message).toContain("has no explicit owner"); - expect(agentCommandMock).not.toHaveBeenCalled(); - - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); - const selected = await postResponses( - enabledPort, - { model: "openclaw/default", input: "hi" }, - { "x-openclaw-agent-id": "main" }, - ); - expect(selected.status).toBe(200); - expect((firstAgentOpts() as { sessionKey?: string }).sessionKey ?? "").toMatch( - /^agent:main:/, - ); - await ensureResponseConsumed(selected); - } finally { - testState.agentsConfig = undefined; - resetConfigRuntimeState(); - } - }); - - it.each([ - [false, [{ text: "SDK plain-text response", mediaUrl: null }], "SDK plain-text response"], - [true, [{ text: "SDK plain-text response", mediaUrl: null }], "SDK plain-text response"], - [false, [{ text: "", mediaUrl: null }], "No response from OpenClaw."], - [true, [{ text: "", mediaUrl: null }], "No response from OpenClaw."], - [ - false, - [ - { text: "", mediaUrl: "/tmp/image.png" }, - { text: "First caption.", mediaUrl: null }, - { text: "", mediaUrl: null }, - { text: "", mediaUrl: "/tmp/voice.ogg", audioAsVoice: true }, - { text: "Second caption.", mediaUrl: null }, - ], - "First caption.\n\nSecond caption.", - ], - ])( - "returns visible official SDK response text (stream: %s, payloads: %j)", - async (stream, payloads, expected) => { + it.each([false, true])( + "returns SDK fallback text for empty output (stream=%s)", + async (stream) => { agentCommandMock.mockClear(); agentCommandMock.mockResolvedValueOnce({ - payloads, + payloads: [{ text: "", mediaUrl: null }], meta: { durationMs: 0 }, }); - const client = createOpenAiHttpTestClient(enabledPort); const request = { model: "openclaw", @@ -404,12 +326,12 @@ describe("OpenResponses HTTP API (e2e)", () => { textDeltas.push(event.delta); } } - expect(textDeltas.join("")).toBe(expected); + expect(textDeltas.join("")).toBe("No response from OpenClaw."); expect(eventTypes).toContain("response.completed"); } else { const response = await client.responses.create({ ...request, stream: false }); expect(response.status).toBe("completed"); - expect(response.output_text).toBe(expected); + expect(response.output_text).toBe("No response from OpenClaw."); } expect(agentCommandMock).toHaveBeenCalledTimes(1); @@ -417,809 +339,272 @@ describe("OpenResponses HTTP API (e2e)", () => { ); it.each( - assistantSnapshotCases({ - name: "buffered leading text in cumulative assistant snapshots", - events: [{ text: "ok", delta: "tag>ok" }], - expected: "ok", - }), - )( - "preserves $name in official SDK assistant streams", - async ({ events, expected, resultTexts }) => { - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => { - const runId = (opts as { runId?: string }).runId; - if (!runId) { - throw new Error("expected a streaming response run ID"); - } - for (const data of events) { - emitAgentEvent({ runId, stream: "assistant", data }); - } - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); - return { payloads: (resultTexts ?? [expected]).map((text) => ({ text })) }; - }) as never); - - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ - model: "openclaw", - input: "Preserve the complete assistant snapshot.", - }); - const deltas: string[] = []; - stream.on("response.output_text.delta", (event) => deltas.push(event.delta)); - - const response = await stream.finalResponse(); - expect({ deltas: deltas.join(""), outputText: response.output_text }).toEqual({ - deltas: expected, - outputText: expected, - }); - expect(response.status).toBe("completed"); - }, - ); - - it.each(incompatibleReplacementCases)( - "fails an official SDK Responses stream when streamed text is $name", - async (scenario) => { - const { previousText = "draft answer" } = scenario; - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => { - const runId = (opts as { runId?: string }).runId; - if (!runId) { - throw new Error("expected a streaming response run ID"); - } - return { - ...emitIncompatibleAssistantReplacement( - runId, - scenario, - scenario.replaceable ? "" : undefined, - ), - meta: { agentMeta: { usage: { input: 11, output: 7, total: 18 } } }, - }; - }) as never); - - const client = createOpenAiHttpTestClient(enabledPort); - const events = await client.responses.create({ - model: "openclaw", - input: "Reject an incompatible replacement snapshot.", - stream: true, - }); - - const deltas: string[] = []; - const failures: Array<{ - status: string | undefined; - code: string | undefined; - usage: { input_tokens: number; output_tokens: number; total_tokens: number } | undefined; - }> = []; - let completionCount = 0; - for await (const event of events) { - if (event.type === "response.output_text.delta") { - deltas.push(event.delta); - } else if (event.type === "response.failed") { - failures.push({ - status: event.response.status, - code: event.response.error?.code, - usage: event.response.usage - ? { - input_tokens: event.response.usage.input_tokens, - output_tokens: event.response.usage.output_tokens, - total_tokens: event.response.usage.total_tokens, - } - : undefined, - }); - } else if (event.type === "response.completed") { - completionCount += 1; - } - } - - expect(deltas).toEqual([previousText]); - expect(failures).toEqual([ - { - status: "failed", - code: "server_error", - usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 }, - }, - ]); - expect(completionCount).toBe(0); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - }, - ); - - it.each( - compatibleReplacementCases.toSpliced(5, 0, { - name: "an explicit non-provisional recovery", - previousDelta: "final ", - intermediateText: "incompatible correction", - replacementDelta: "answer", - expectedDeltas: "final answer", - }), - )("keeps official SDK Responses text consistent for $name", async (scenario) => { - const { resultText, replacementText = "final answer", expectedDeltas } = scenario; + incompatibleReplacementCases.filter(({ name }) => + [ + "rewritten then extended by a delta", + "cleared by held output without a text-bearing result", + ].includes(name), + ), + )("fails an official SDK Responses stream when streamed text is $name", async (scenario) => { + const { previousText = "draft answer" } = scenario; agentCommandMock.mockClear(); agentCommandMock.mockImplementationOnce((async (opts: unknown) => { const runId = (opts as { runId?: string }).runId; if (!runId) { throw new Error("expected a streaming response run ID"); } - return emitCompatibleAssistantReplacement(runId, scenario); + return { + ...emitIncompatibleAssistantReplacement( + runId, + scenario, + scenario.replaceable ? "" : undefined, + ), + meta: { agentMeta: { usage: { input: 11, output: 7, total: 18 } } }, + }; }) as never); - const client = createOpenAiHttpTestClient(enabledPort); - const events = await client.responses.create({ - model: "openclaw", - input: "Stream a replacement snapshot exactly once.", - stream: true, - }); - + const stream = createStream(); const deltas: string[] = []; - const doneTexts: string[] = []; - const completedTexts: string[] = []; - for await (const event of events) { - if (event.type === "response.output_text.delta") { - deltas.push(event.delta); - } else if (event.type === "response.output_text.done") { - doneTexts.push(event.text); - } else if (event.type === "response.completed") { - completedTexts.push( - ...event.response.output.flatMap((item) => - item.type === "message" - ? item.content.flatMap((part) => (part.type === "output_text" ? [part.text] : [])) - : [], - ), - ); - } - } - - expect(deltas.join("")).toBe(expectedDeltas); - expect(doneTexts).toEqual([resultText ?? replacementText]); - expect(completedTexts).toEqual([resultText ?? replacementText]); + const terminals: string[] = []; + stream.on("response.output_text.delta", ({ delta }) => deltas.push(delta)); + stream.on("response.failed", () => terminals.push("failed")); + stream.on("response.completed", () => terminals.push("completed")); + const response = await stream.finalResponse(); + expect(deltas).toEqual([previousText]); + expect(terminals).toEqual(["failed"]); + expect(response.status).toBe("failed"); + expect(response.error?.code).toBe("server_error"); + expect(response.usage).toMatchObject({ input_tokens: 11, output_tokens: 7, total_tokens: 18 }); expect(agentCommandMock).toHaveBeenCalledTimes(1); }); - it.each([ - ["JSON-object output", { format: { type: "json_object" } }], - [ - "JSON-schema output", - { - format: { - type: "json_schema", - name: "value", - schema: { type: "object" }, - }, - }, - ], - ["unenforced verbosity", { format: { type: "text" }, verbosity: "high" }], - ])("rejects unsupported SDK text options (%s)", async (_name, text) => { - agentCommandMock.mockClear(); - - const res = await postResponses(enabledPort, { - model: "openclaw", - input: "Return the plain-text response.", - text, - }); - - await expectInvalidRequest(res, /text/); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - }); - it("handles OpenResponses request parsing and validation", async () => { - const port = enabledPort; - + async function accept( + body: Record, + expected: Record, + headers?: Record, + meta?: unknown, + ) { + mockAgentOnce([{ text: "hello" }], meta); + const res = await postResponses( + enabledPort, + { model: "openclaw", input: "hi", ...body }, + headers, + ); + expect(res.status).toBe(200); + await ensureResponseConsumed(res); + expect(firstAgentOpts()).toMatchObject(expected); + } + async function reject(body: unknown, message: RegExp, headers?: Record) { + agentCommandMock.mockClear(); + await expectInvalidRequest(await postResponses(enabledPort, body, headers), message); + expect(agentCommandMock).not.toHaveBeenCalled(); + } + const request = { model: "openclaw", input: "hi" }; + const admin = { "x-openclaw-scopes": "operator.admin, operator.write" }; try { testState.agentsConfig = { list: [{ id: "main" }] }; resetConfigRuntimeState(); - - const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, { - method: "GET", - headers: { authorization: "Bearer secret" }, - }); - expect(resNonPost.status).toBe(405); - await ensureResponseConsumed(resNonPost); - - const resMissingAuth = await fetch(`http://127.0.0.1:${port}/v1/responses`, { - method: "POST", - headers: { "content-type": "application/json", "x-openclaw-agent-id": "main" }, - body: JSON.stringify({ model: "openclaw", input: "hi" }), - }); - expect(resMissingAuth.status).toBe(200); - await ensureResponseConsumed(resMissingAuth); - - const resMissingModel = await postResponses(port, { input: "hi" }); - expect(resMissingModel.status).toBe(400); - const missingModelJson = (await resMissingModel.json()) as Record; - expect((missingModelJson.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - await ensureResponseConsumed(resMissingModel); - - agentCommandMock.mockClear(); - const resInvalidModel = await postResponses(port, { model: "openai/", input: "hi" }); - expect(resInvalidModel.status).toBe(400); - const invalidModelJson = (await resInvalidModel.json()) as { - error?: { type?: string; message?: string }; - }; - expect(invalidModelJson.error?.type).toBe("invalid_request_error"); - expect(invalidModelJson.error?.message).toBe( - "Invalid `model`. Use `openclaw` or `openclaw/`.", - ); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - await ensureResponseConsumed(resInvalidModel); - - mockAgentOnce([{ text: "hello" }]); - testState.agentsConfig = { - ownership: "explicit", - list: [{ id: "main" }, { id: "beta" }], - }; + const nonPost = await fetch(`http://127.0.0.1:${enabledPort}/v1/responses`); + expect(nonPost.status).toBe(405); + await ensureResponseConsumed(nonPost); + await reject({ input: "hi" }, /model/); + await reject({ model: "openai/", input: "hi" }, /Invalid `model`/); + for (const key of ["subagent:spoofed", "harness:codex:supervision:spoofed-native-thread"]) { + await reject(request, /cannot use reserved internal session namespaces/, { + "x-openclaw-session-key": `agent:main:${key}`, + }); + } + testState.agentsConfig = { ownership: "explicit", list: [{ id: "main" }, { id: "beta" }] }; resetConfigRuntimeState(); - const resHeader = await postResponses( - port, - { model: "openclaw", input: "hi" }, + await accept( + {}, + { sessionKey: expect.stringMatching(/^agent:beta:/), messageChannel: "webchat" }, { "x-openclaw-agent-id": "beta" }, ); - expect(resHeader.status).toBe(200); - const optsHeader = firstAgentOpts(); - expect((optsHeader as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( - /^agent:beta:/, - ); - expect((optsHeader as { messageChannel?: string } | undefined)?.messageChannel).toBe( - "webchat", - ); - await ensureResponseConsumed(resHeader); - - mockAgentOnce([{ text: "hello" }]); - const resSessionOverride = await postResponses( - port, - { model: "openclaw", input: "hi" }, + await accept( + {}, + { sessionKey: "agent:beta:openresponses:custom" }, { "x-openclaw-agent-id": "beta", "x-openclaw-session-key": "agent:beta:openresponses:custom", }, ); - expect(resSessionOverride.status).toBe(200); - expect((firstAgentOpts() as { sessionKey?: string }).sessionKey).toBe( - "agent:beta:openresponses:custom", + await accept( + { model: "openclaw/beta" }, + { sessionKey: expect.stringMatching(/^agent:beta:/) }, ); - await ensureResponseConsumed(resSessionOverride); - testState.agentsConfig = { list: [{ id: "main" }] }; resetConfigRuntimeState(); - - agentCommandMock.mockClear(); - const resReservedSessionOverride = await postResponses( - port, - { model: "openclaw", input: "hi" }, - { "x-openclaw-session-key": "agent:main:subagent:spoofed" }, + await accept( + { model: "openclaw/default" }, + { sessionKey: expect.stringMatching(/^agent:main:/) }, ); - expect(resReservedSessionOverride.status).toBe(400); - const reservedSessionJson = (await resReservedSessionOverride.json()) as { - error?: { type?: string; message?: string }; - }; - expect(reservedSessionJson.error?.type).toBe("invalid_request_error"); - expect(reservedSessionJson.error?.message).toBe( - "`x-openclaw-session-key` cannot use reserved internal session namespaces.", + await reject(request, /Unknown agent 'missing-agent'/, { + "x-openclaw-agent-id": "missing-agent", + }); + await reject( + { ...request, model: "openclaw/missing-agent" }, + /Unknown agent 'missing-agent'/, ); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - - const resHarnessSessionOverride = await postResponses( - port, - { model: "openclaw", input: "hi" }, + await accept( + {}, + { messageChannel: "custom-client-channel" }, { - "x-openclaw-session-key": "agent:main:harness:codex:supervision:spoofed-native-thread", + "x-openclaw-message-channel": "custom-client-channel", }, ); - expect(resHarnessSessionOverride.status).toBe(400); - const harnessSessionJson = (await resHarnessSessionOverride.json()) as { - error?: { type?: string; message?: string }; - }; - expect(harnessSessionJson.error?.type).toBe("invalid_request_error"); - expect(harnessSessionJson.error?.message).toBe( - "`x-openclaw-session-key` cannot use reserved internal session namespaces.", + await accept( + {}, + { model: "openai/gpt-5.4" }, + { ...admin, "x-openclaw-model": "openai/gpt-5.4" }, ); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - - mockAgentOnce([{ text: "hello" }]); - testState.agentsConfig = { - ownership: "explicit", - list: [{ id: "main" }, { id: "beta" }], - }; - resetConfigRuntimeState(); - const resModel = await postResponses(port, { model: "openclaw/beta", input: "hi" }); - expect(resModel.status).toBe(200); - const optsModel = firstAgentOpts(); - expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( - /^agent:beta:/, - ); - await ensureResponseConsumed(resModel); - - testState.agentsConfig = { list: [{ id: "main" }] }; - resetConfigRuntimeState(); - - mockAgentOnce([{ text: "hello" }]); - const resDefaultAlias = await postResponses(port, { model: "openclaw/default", input: "hi" }); - expect(resDefaultAlias.status).toBe(200); - const optsDefaultAlias = firstAgentOpts(); - expect((optsDefaultAlias as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( - /^agent:main:/, - ); - await ensureResponseConsumed(resDefaultAlias); - - { - agentCommandMock.mockClear(); - const res = await postResponses( - port, - { model: "openclaw", input: "hi" }, - { "x-openclaw-agent-id": "missing-agent" }, - ); - expect(res.status).toBe(400); - const json = (await res.json()) as { error?: { type?: string; message?: string } }; - expect(json.error?.type).toBe("invalid_request_error"); - expect(json.error?.message).toBe("Unknown agent 'missing-agent'."); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - } - - { - agentCommandMock.mockClear(); - const res = await postResponses(port, { model: "openclaw/missing-agent", input: "hi" }); - expect(res.status).toBe(400); - const json = (await res.json()) as { error?: { type?: string; message?: string } }; - expect(json.error?.type).toBe("invalid_request_error"); - expect(json.error?.message).toBe("Unknown agent 'missing-agent'."); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - } - - mockAgentOnce([{ text: "hello" }]); - const resChannelHeader = await postResponses( - port, - { model: "openclaw", input: "hi" }, - { "x-openclaw-message-channel": "custom-client-channel" }, - ); - expect(resChannelHeader.status).toBe(200); - const optsChannelHeader = firstAgentOpts(); - expect((optsChannelHeader as { messageChannel?: string } | undefined)?.messageChannel).toBe( - "custom-client-channel", - ); - await ensureResponseConsumed(resChannelHeader); - - mockAgentOnce([{ text: "hello" }]); - const resModelOverride = await postResponses( - port, - { - model: "openclaw", - input: "hi", - }, - { - "x-openclaw-model": "openai/gpt-5.4", - "x-openclaw-scopes": "operator.admin, operator.write", - }, - ); - expect(resModelOverride.status).toBe(200); - const optsModelOverride = firstAgentOpts(); - expect((optsModelOverride as { model?: string } | undefined)?.model).toBe("openai/gpt-5.4"); - await ensureResponseConsumed(resModelOverride); - - agentCommandMock.mockClear(); - const resInvalidOverride = await postResponses( - port, - { model: "openclaw", input: "hi" }, - { - "x-openclaw-model": "openai/", - "x-openclaw-scopes": "operator.admin, operator.write", - }, - ); - expect(resInvalidOverride.status).toBe(400); - const invalidOverrideJson = (await resInvalidOverride.json()) as { - error?: { type?: string; message?: string }; - }; - expect(invalidOverrideJson.error?.type).toBe("invalid_request_error"); - expect(invalidOverrideJson.error?.message).toBe("Invalid `x-openclaw-model`."); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - await ensureResponseConsumed(resInvalidOverride); - - agentCommandMock.mockClear(); - const resWriteOnlyOverride = await postResponses( - port, - { model: "openclaw", input: "hi" }, - { "x-openclaw-model": "openai/gpt-5.4" }, - ); - expect(resWriteOnlyOverride.status).toBe(403); - const writeOnlyJson = (await resWriteOnlyOverride.json()) as { - error?: { type?: string; message?: string }; - }; - expect(writeOnlyJson.error?.type).toBe("forbidden"); - expect(writeOnlyJson.error?.message).toBe("missing scope: operator.admin"); - expect(agentCommandMock).toHaveBeenCalledTimes(0); - await ensureResponseConsumed(resWriteOnlyOverride); - + await reject(request, /Invalid `x-openclaw-model`/, { + ...admin, + "x-openclaw-model": "openai/", + }); agentCommandMock.mockClear(); + const forbidden = await postResponses(enabledPort, request, { + "x-openclaw-model": "openai/gpt-5.4", + }); + expect(forbidden.status).toBe(403); + expect(await forbidden.json()).toMatchObject({ + error: { type: "forbidden", message: "missing scope: operator.admin" }, + }); + expect(agentCommandMock).not.toHaveBeenCalled(); agentCommandMock.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); - const resToolConflict = await postResponses(port, { - model: "openclaw", - input: "hi", - tools: WEATHER_TOOL, + const conflict = await postResponses(enabledPort, { ...request, tools: WEATHER_TOOL }); + expect(conflict.status).toBe(400); + expect(await conflict.json()).toMatchObject({ + error: { code: "invalid_request_error", message: "invalid tool configuration" }, }); - expect(resToolConflict.status).toBe(400); - const toolConflictJson = (await resToolConflict.json()) as { - error?: { code?: string; message?: string }; - }; - expect(toolConflictJson.error?.code).toBe("invalid_request_error"); - expect(toolConflictJson.error?.message).toBe("invalid tool configuration"); - await ensureResponseConsumed(resToolConflict); - - mockAgentOnce([{ text: "hello" }]); - const resUser = await postResponses(port, { - user: "alice", - model: "openclaw", - input: "hi", - }); - expect(resUser.status).toBe(200); - expect(firstAgentOpts().sessionKey).toContain("openresponses-user:alice"); - await ensureResponseConsumed(resUser); - - mockAgentOnce([{ text: "hello" }]); - const resString = await postResponses(port, { - model: "openclaw", - input: "hello world", - }); - expect(resString.status).toBe(200); - expect(firstAgentOpts().message).toBe("hello world"); - await ensureResponseConsumed(resString); - - mockAgentOnce([{ text: "hello" }]); - const resArray = await postResponses(port, { - model: "openclaw", - input: [{ type: "message", role: "user", content: "hello there" }], - }); - expect(resArray.status).toBe(200); - expect(firstAgentOpts().message).toBe("hello there"); - await ensureResponseConsumed(resArray); - - mockAgentOnce([{ text: "hello" }]); - const resSystemDeveloper = await postResponses(port, { - model: "openclaw", - input: [ - { type: "message", role: "system", content: "You are a helpful assistant." }, - { type: "message", role: "developer", content: "Be concise." }, - { type: "message", role: "user", content: "Hello" }, - ], - }); - expect(resSystemDeveloper.status).toBe(200); - const optsSystemDeveloper = firstAgentOpts(); - const extraSystemPrompt = - (optsSystemDeveloper as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? - ""; - expect(extraSystemPrompt).toContain("You are a helpful assistant."); - expect(extraSystemPrompt).toContain("Be concise."); - await ensureResponseConsumed(resSystemDeveloper); - - mockAgentOnce([{ text: "hello" }]); - const resInstructions = await postResponses(port, { - model: "openclaw", - input: "hi", - instructions: "Always respond in French.", - }); - expect(resInstructions.status).toBe(200); - const optsInstructions = firstAgentOpts(); - const instructionPrompt = - (optsInstructions as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; - expect(instructionPrompt).toContain("Always respond in French."); - await ensureResponseConsumed(resInstructions); - - mockAgentOnce([{ text: "I am Claude" }]); - const resHistory = await postResponses(port, { - model: "openclaw", - input: [ - { type: "message", role: "system", content: "You are a helpful assistant." }, - { type: "message", role: "user", content: "Hello, who are you?" }, - { type: "message", role: "assistant", content: "I am Claude." }, - { type: "message", role: "user", content: "What did I just ask you?" }, - ], - }); - expect(resHistory.status).toBe(200); - const optsHistory = firstAgentOpts(); - const historyMessage = (optsHistory as { message?: string } | undefined)?.message ?? ""; - expect(historyMessage).toContain(HISTORY_CONTEXT_MARKER); - expect(historyMessage).toContain("User: Hello, who are you?"); - expect(historyMessage).toContain("Assistant: I am Claude."); - expect(historyMessage).toContain(CURRENT_MESSAGE_MARKER); - expect(historyMessage).toContain("User: What did I just ask you?"); - await ensureResponseConsumed(resHistory); - - mockAgentOnce([{ text: "ok" }]); - const resFunctionOutput = await postResponses(port, { - model: "openclaw", - input: [ - { type: "message", role: "user", content: "What's the weather?" }, - { type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." }, - ], - }); - expect(resFunctionOutput.status).toBe(200); - const optsFunctionOutput = firstAgentOpts(); - const functionOutputMessage = - (optsFunctionOutput as { message?: string } | undefined)?.message ?? ""; - expect(functionOutputMessage).toContain("Sunny, 70F."); - await ensureResponseConsumed(resFunctionOutput); - - mockAgentOnce([{ text: "ok" }]); - const resInputFile = await postResponses(port, { - model: "openclaw", - input: buildFileInputMessage("hello", "hello.txt", "read this"), - }); - expect(resInputFile.status).toBe(200); - const optsInputFile = firstAgentOpts(); - const inputFileMessage = (optsInputFile as { message?: string } | undefined)?.message ?? ""; - const inputFilePrompt = - (optsInputFile as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; - expect(inputFileMessage).toBe("read this"); - expect(inputFilePrompt).toContain(''); - expect(inputFilePrompt).toContain('<<'); - expect(inputFileWhitespacePrompt).toContain("\n hello \n"); - expect(inputFileWhitespacePrompt).toContain('<< after', - 'test"> after', + await accept( + { user: "alice" }, + { sessionKey: expect.stringContaining("openresponses-user:alice") }, ); - expect(inputFileInjectionPrompt).not.toContain(''); - expect((inputFileInjectionPrompt.match(/', + "\n hello \n", + '<< after', + 'test"> after'); + expect(injection).not.toContain(''); + expect(injection.match(/; - expect(usageJson.usage).toEqual({ + const usage = await postResponses(enabledPort, request); + expect(usage.status).toBe(200); + expect(((await usage.json()) as ResponseResource).usage).toEqual({ input_tokens: 6, input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, output_tokens: 5, output_tokens_details: { reasoning_tokens: 4 }, total_tokens: 11, }); - await ensureResponseConsumed(resUsage); - - mockAgentOnce([{ text: "hello" }]); - const resShape = await postResponses(port, { - stream: false, - model: "openclaw", - input: "hi", - }); - expect(resShape.status).toBe(200); - const shapeJson = (await resShape.json()) as Record; - expect(shapeJson.object).toBe("response"); - expect(shapeJson.status).toBe("completed"); - expect(shapeJson.usage).toEqual({ - input_tokens: 0, - input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, - output_tokens: 0, - output_tokens_details: { reasoning_tokens: 0 }, - total_tokens: 0, - }); - expect(Array.isArray(shapeJson.output)).toBe(true); - - const output = shapeJson.output as Array>; - expect(output.length).toBe(1); - const item = output[0] ?? {}; - expect(item.type).toBe("message"); - expect(item.role).toBe("assistant"); - expect(item.phase).toBe("final_answer"); - - const content = item.content as Array>; - expect(content.length).toBe(1); - expect(content[0]?.type).toBe("output_text"); - expect(content[0]?.text).toBe("hello"); - await ensureResponseConsumed(resShape); - - const resNoUser = await postResponses(port, { - model: "openclaw", - input: [{ type: "message", role: "system", content: "yo" }], - }); - expect(resNoUser.status).toBe(400); - const noUserJson = (await resNoUser.json()) as Record; - expect((noUserJson.error as Record | undefined)?.type).toBe( - "invalid_request_error", + await reject( + { model: "openclaw", input: [{ type: "message", role: "system", content: "yo" }] }, + /Missing user message/, ); - await ensureResponseConsumed(resNoUser); } finally { testState.agentsConfig = undefined; resetConfigRuntimeState(); } }); - it("dispatches printable URL input_file text when Content-Type is absent", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(Buffer.from("headerless URL file text"), { status: 200 }), - release, - finalUrl: "https://example.com/notes", - }); - mockAgentOnce([{ text: "ok" }]); - - const res = await postResponses(enabledPort, { - model: "openclaw", - input: buildUrlInputMessage({ - kind: "input_file", - url: "https://example.com/notes", - }), - }); - const body = await res.text(); - - expect(res.status, body).toBe(200); - expect(release).toHaveBeenCalledTimes(1); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - const prompt = (firstAgentOpts() as { extraSystemPrompt?: string }).extraSystemPrompt ?? ""; - expect(prompt).toContain("headerless URL file text"); - expect(prompt).toContain('<< { let now = 1_700_000_000_000; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => (now += 1_000)); @@ -1233,11 +618,7 @@ describe("OpenResponses HTTP API (e2e)", () => { text: "hello", })) as never); - const response = await postResponses(enabledPort, { - stream: true, - model: "openclaw", - input: "hi", - }); + const response = await post({ stream: true }); expect(response.status).toBe(200); const createdAt = parseSseEvents(await response.text()) .filter((event) => @@ -1258,301 +639,118 @@ describe("OpenResponses HTTP API (e2e)", () => { } }); - it("streams OpenResponses SSE events", async () => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => - buildAssistantDeltaResult({ - opts, - emit: emitAgentEvent, - deltas: ["he", "llo"], - text: "hello", - })) as never); - - const resDelta = await postResponses(port, { - stream: true, - model: "openclaw", - input: "hi", - }); - expect(resDelta.status).toBe(200); - expect(resDelta.headers.get("content-type") ?? "").toContain("text/event-stream"); - - const deltaText = await resDelta.text(); - const deltaEvents = parseSseEvents(deltaText); - - const eventTypes = collectSseEventTypes(deltaEvents); - expect(eventTypes).toContain("response.created"); - expect(eventTypes).toContain("response.output_item.added"); - expect(eventTypes).toContain("response.in_progress"); - expect(eventTypes).toContain("response.content_part.added"); - expect(eventTypes).toContain("response.output_text.delta"); - expect(eventTypes).toContain("response.output_text.done"); - expect(eventTypes).toContain("response.content_part.done"); - expect(eventTypes).toContain("response.completed"); - expect(deltaEvents.map((event) => event.data)).toContain("[DONE]"); - expect(parseSseData(findSseEvent(deltaEvents, "response.output_item.added"))).toMatchObject({ - item: { content: [] }, - }); - expect(parseSseData(findSseEvent(deltaEvents, "response.content_part.added"))).toMatchObject({ - content_index: 0, - part: { type: "output_text", text: "" }, - }); - - const deltas = deltaEvents - .filter((e) => e.event === "response.output_text.delta") - .map((e) => { - const parsed = JSON.parse(e.data) as { delta?: string }; - return parsed.delta ?? ""; - }) - .join(""); - expect(deltas).toBe("hello"); - - const completedDeltaResponse = deltaEvents.find((e) => e.event === "response.completed"); - const completedDeltaOutput = ( - JSON.parse(completedDeltaResponse?.data ?? "{}") as { - response?: { output?: Array> }; - } - ).response?.output; - expect(completedDeltaOutput?.[0]?.phase).toBe("final_answer"); - - for (const event of deltaEvents) { - if (event.data === "[DONE]") { - continue; - } - const parsed = JSON.parse(event.data) as { type?: string }; - expect(event.event).toBe(parsed.type); - } - }); - - it.each([ - { name: "missing aggregate", usage: undefined }, - { name: "zero aggregate", usage: { input: 0, output: 0, total: 0 } }, - ])("uses last-call usage in the terminal SSE response for $name", async ({ usage }) => { - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - meta: { - agentMeta: { - ...(usage ? { usage } : {}), - lastCallUsage: { - input: 4, - output: 3, - cacheRead: 2, - cacheWrite: 1, - reasoningTokens: 2, - total: 9, - }, - }, - }, - } as never); - - const client = createOpenAiHttpTestClient(enabledPort); - const response = await client.responses - .stream({ - model: "openclaw", - input: "hi", - }) - .finalResponse(); - - expect(response.status).toBe("completed"); - expect(response.usage).toEqual({ - input_tokens: 7, - input_tokens_details: { cached_tokens: 2, cache_write_tokens: 1 }, - output_tokens: 3, - output_tokens_details: { reasoning_tokens: 2 }, - total_tokens: 10, - }); - }); - - it.each([false, true])( - "flushes same-turn assistant microtasks before completing an official SDK stream (held tools=%s)", - async (heldTools) => { - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce(((opts: unknown) => { - const runId = (opts as { runId?: string }).runId; - if (!runId) { - throw new Error("expected a streaming response run ID"); - } - emitAgentEvent({ - runId, - stream: "assistant", - data: { delta: "start ", ...(heldTools ? { itemId: "answer-1" } : {}) }, - }); - const result = Promise.resolve({ - payloads: heldTools ? [] : [{ text: "start finish!" }], - ...(heldTools - ? { - meta: { - stopReason: "tool_calls", - pendingToolCalls: [{ id: "call_1", name: "get_weather", arguments: "{}" }], - }, - } - : {}), - }); - void result.then(() => { - emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); - void Promise.resolve().then(() => { - emitAgentEvent({ - runId, - stream: "assistant", - data: heldTools - ? { - itemId: "answer-2", - text: "start finish", - delta: "", - replace: true, - replaceable: true, - } - : { delta: "finish" }, - }); - void Promise.resolve().then(() => { - emitAgentEvent({ - runId, - stream: "assistant", - data: heldTools - ? { itemId: "answer-2", text: "start finish!", delta: "!", replaceable: true } - : { delta: "!" }, - }); - }); - }); - }); - return result; - }) as never); - - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ - model: "openclaw", - input: "Finish the streamed response.", - }); - const deltas: string[] = []; - stream.on("response.output_text.delta", (event) => { - deltas.push(event.delta); - }); - - const response = await stream.finalResponse(); - expect(deltas.join("")).toBe("start finish!"); - expect(response.status).toBe("completed"); - expect(response.output_text).toBe("start finish!"); - expect(response.output.map((item) => item.type)).toEqual( - heldTools ? ["message", "function_call"] : ["message"], - ); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - }, - ); - - it.each([false, true])( - "flushes result-following assistant microtasks before finalization (recovery=%s)", - async (recovery) => { - const completion = createDeferred<{ payloads: Array<{ text: string }> }>(); - const created = createDeferred(); - let runId: string | undefined; - let lateEventEmitted = false; - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce(((opts: unknown) => { - runId = (opts as { runId?: string }).runId; - if (!runId) { - throw new Error("expected a streaming response run ID"); - } - emitAgentEvent({ runId, stream: "assistant", data: { delta: "start " } }); - if (recovery) { - emitAgentEvent({ - runId, - stream: "assistant", - data: { text: "incompatible correction", replace: true }, - }); - } - return completion.promise; - }) as never); - - try { - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ - model: "openclaw", - input: "Flush the final assistant microtask.", - }); - stream.on("response.created", () => created.resolve()); - const deltas: string[] = []; - stream.on("response.output_text.delta", (event) => deltas.push(event.delta)); - await created.promise; - if (!runId) { - throw new Error("expected an admitted streaming response run"); - } - const activeRunId = runId; - // Register the producer's completion work after the HTTP consumer is - // awaiting the result. Its queued recovery must survive that continuation. - void completion.promise.then(() => { - emitAgentEvent({ runId: activeRunId, stream: "lifecycle", data: { phase: "end" } }); - queueMicrotask(() => { - lateEventEmitted = true; - emitAgentEvent({ - runId: activeRunId, - stream: "assistant", - data: recovery ? { text: "start finish", replace: true } : { delta: "finish" }, - }); - }); - }); - completion.resolve({ payloads: [{ text: "start finish" }] }); - const response = await stream.finalResponse(); - - expect(lateEventEmitted).toBe(true); - expect(response.status).toBe("completed"); - expect(deltas.join("")).toBe("start finish"); - expect(response.output_text).toBe("start finish"); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - } finally { - completion.resolve({ payloads: [{ text: "start finish" }] }); - } - }, - ); - - it("fails conflicting assistant replacements queued after lifecycle completion", async () => { - let unsubscribe = () => {}; + it("flushes same-turn assistant microtasks before completing a tool stream", async () => { agentCommandMock.mockClear(); agentCommandMock.mockImplementationOnce(((opts: unknown) => { const runId = (opts as { runId?: string }).runId; if (!runId) { throw new Error("expected a streaming response run ID"); } - emitAgentEvent({ runId, stream: "assistant", data: { delta: "draft answer" } }); - unsubscribe = onAgentEvent((event) => { - if (event.runId !== runId || event.stream !== "lifecycle" || event.data?.phase !== "end") { - return; - } - unsubscribe(); + emitAgentEvent({ + runId, + stream: "assistant", + data: { delta: "start ", itemId: "answer-1" }, + }); + const result = Promise.resolve({ + payloads: [], + meta: { + stopReason: "tool_calls", + pendingToolCalls: [{ id: "call_1", name: "get_weather", arguments: "{}" }], + }, + }); + void result.then(() => { + emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); void Promise.resolve().then(() => { emitAgentEvent({ runId, stream: "assistant", - data: { text: "rewritten answer", replace: true, phase: "commentary" }, + data: { + itemId: "answer-2", + text: "start finish", + delta: "", + replace: true, + replaceable: true, + }, + }); + void Promise.resolve().then(() => { + emitAgentEvent({ + runId, + stream: "assistant", + data: { itemId: "answer-2", text: "start finish!", delta: "!", replaceable: true }, + }); }); }); }); - return Promise.resolve({ payloads: [{ text: "rewritten answer" }] }); + return result; + }) as never); + + const stream = createStream(); + const deltas: string[] = []; + stream.on("response.output_text.delta", (event) => { + deltas.push(event.delta); + }); + + const response = await stream.finalResponse(); + expect(deltas.join("")).toBe("start finish!"); + expect(response.status).toBe("completed"); + expect(response.output_text).toBe("start finish!"); + expect(response.output.map((item) => item.type)).toEqual(["message", "function_call"]); + expect(agentCommandMock).toHaveBeenCalledTimes(1); + }); + + it("flushes result-following recovery microtasks before finalization", async () => { + const completion = createDeferred<{ payloads: Array<{ text: string }> }>(); + const created = createDeferred(); + let runId: string | undefined; + let lateEventEmitted = false; + agentCommandMock.mockClear(); + agentCommandMock.mockImplementationOnce(((opts: unknown) => { + runId = (opts as { runId?: string }).runId; + if (!runId) { + throw new Error("expected a streaming response run ID"); + } + emitAgentEvent({ runId, stream: "assistant", data: { delta: "start " } }); + emitAgentEvent({ + runId, + stream: "assistant", + data: { text: "incompatible correction", replace: true }, + }); + return completion.promise; }) as never); try { - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ - model: "openclaw", - input: "Reject a late incompatible assistant replacement.", + const stream = createStream(); + stream.on("response.created", () => created.resolve()); + const deltas: string[] = []; + stream.on("response.output_text.delta", (event) => deltas.push(event.delta)); + await created.promise; + if (!runId) { + throw new Error("expected an admitted streaming response run"); + } + const activeRunId = runId; + // Register the producer's completion work after the HTTP consumer is + // awaiting the result. Its queued recovery must survive that continuation. + void completion.promise.then(() => { + emitAgentEvent({ runId: activeRunId, stream: "lifecycle", data: { phase: "end" } }); + queueMicrotask(() => { + lateEventEmitted = true; + emitAgentEvent({ + runId: activeRunId, + stream: "assistant", + data: { text: "start finish", replace: true }, + }); + }); }); - let completedEvents = 0; - let failedEvents = 0; - stream.on("response.completed", () => { - completedEvents += 1; - }); - stream.on("response.failed", () => { - failedEvents += 1; - }); - + completion.resolve({ payloads: [{ text: "start finish" }] }); const response = await stream.finalResponse(); - expect(completedEvents).toBe(0); - expect(failedEvents).toBe(1); - expect(response.status).toBe("failed"); - expect(response.error?.code).toBe("server_error"); - expect(response.error?.message).toContain("append-only response stream"); + + expect(lateEventEmitted).toBe(true); + expect(response.status).toBe("completed"); + expect(deltas.join("")).toBe("start finish"); + expect(response.output_text).toBe("start finish"); + expect(agentCommandMock).toHaveBeenCalledTimes(1); } finally { - unsubscribe(); + completion.resolve({ payloads: [{ text: "start finish" }] }); } }); @@ -1580,11 +778,7 @@ describe("OpenResponses HTTP API (e2e)", () => { }; }) as never); - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ - model: "openclaw", - input: "Report the provider failure.", - }); + const stream = createStream(); let completedEvents = 0; let failedEvents = 0; stream.on("response.completed", () => { @@ -1604,20 +798,14 @@ describe("OpenResponses HTTP API (e2e)", () => { code: "server_error", message: "All model fallback candidates failed", }); - expect(response.usage).toMatchObject({ - input_tokens: 11, - output_tokens: 7, - total_tokens: 18, - }); + expect(response.usage).toMatchObject({ input_tokens: 11, output_tokens: 7, total_tokens: 18 }); expect(agentCommandMock).toHaveBeenCalledTimes(1); }); it.each([ ["completed response without a provider terminal", "completed", false, false, false], - ["completed response with a provider terminal", "completed", false, true, false], ["provider-failed response with a resolved run", "failed", true, true, false], ["rejected response with a provider terminal", "failed", true, true, true], - ["rejected response without a provider terminal", "failed", true, false, true], ])( "keeps the %s admitted until its deferred SSE terminal is written", async (_label, name, failed, providerTerminal, reject) => { @@ -1712,117 +900,6 @@ describe("OpenResponses HTTP API (e2e)", () => { }, ); - it("maps provider format failures to OpenResponses 400 failed responses", async () => { - const port = enabledPort; - - agentCommandMock.mockClear(); - agentCommandMock.mockRejectedValueOnce( - new FailoverError( - "LLM request failed: provider rejected the request schema or tool payload.", - { - reason: "format", - status: 400, - code: "decimal_above_max_value", - rawError: - "400 Invalid 'top_p': decimal above maximum value. Expected a value <= 1, but got 5 instead.", - }, - ) as never, - ); - - const res = await postResponses(port, { - model: "openclaw", - input: "hi", - }); - expect(res.status).toBe(400); - const json = (await res.json()) as { - status?: string; - error?: { code?: string; message?: string }; - }; - expect(json.status).toBe("failed"); - expect(json.error?.code).toBe("invalid_request_error"); - expect(json.error?.message).toContain("Invalid 'top_p'"); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - }); - - it.each([ - ["error stop", { stopReason: "error" }, "failed", 500], - [ - "run-budget timeout without error metadata", - { aborted: false, timeoutPhase: "provider", providerStarted: true }, - "failed", - 500, - ], - ["completed run with an error payload", {}, "completed", 200], - ] as const)("uses the recorded outcome for %s", async (_name, meta, outcome, status) => { - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce( - recordAgentRunTerminalOutcome( - { payloads: [{ text: "Command may have changed state", isError: true }], meta }, - outcome, - ) as never, - ); - - const res = await postResponses(enabledPort, { model: "openclaw", input: "hi" }); - expect(res.status).toBe(status); - await res.text(); - }); - - it.each(streamingFailureCases)( - "fails resolved streaming agent failures from $label", - async ({ meta, expectedPhase, producerTerminal }) => { - let runId: string | undefined; - const { terminals, unsubscribe } = captureStreamingTerminals(() => runId); - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (options: unknown) => { - runId = (options as { runId?: string }).runId; - if (!runId) { - throw new Error("expected a streaming response run ID"); - } - return emitResolvedStreamingFailure( - runId, - { meta, producerTerminal }, - { - agentMeta: { - sessionId: "failed-stream-session", - provider: "openai", - model: "test-model", - usage: { input: 11, output: 7, total: 18 }, - }, - }, - ); - }) as never); - - try { - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ model: "openclaw", input: "hi" }); - const terminalEvents: string[] = []; - const content: string[] = []; - stream.on("response.output_text.delta", (event) => content.push(event.delta)); - stream.on("response.completed", () => terminalEvents.push("response.completed")); - stream.on("response.failed", () => terminalEvents.push("response.failed")); - - const response = await stream.finalResponse(); - expect(response.status).toBe("failed"); - expect(response.error).toEqual({ code: "api_error", message: "internal error" }); - expect(response.usage).toMatchObject({ - input_tokens: 11, - output_tokens: 7, - total_tokens: 18, - }); - expect(terminalEvents).toEqual(["response.failed"]); - expect(content.join("")).toBe("partial answer"); - expect(terminals).toEqual([ - { - phase: producerTerminal ? expectedPhase : "error", - status: producerTerminal && meta.timeoutPhase ? "timeout" : "error", - }, - ]); - } finally { - unsubscribe(); - } - }, - ); - it.each([ ...STREAM_FAILURE_CASES.map((failure) => ({ ...failure, @@ -1972,31 +1049,21 @@ describe("OpenResponses HTTP API (e2e)", () => { }, ); - for (const stream of [false, true]) { - for (const { scopes, senderIsOwner } of [ - { scopes: "operator.write", senderIsOwner: false }, - { scopes: "operator.admin, operator.write", senderIsOwner: true }, - ]) { - mockAgentOnce([{ text: "hello" }]); - - const res = await postResponses( + await expectDeclaredHttpOwnerIdentity({ + post: (stream, headers) => + postResponses( port, { stream, model: "openclaw", input: "hi" }, { "x-forwarded-for": "198.51.100.42", "x-forwarded-proto": "https", "x-forwarded-user": "operator@example.com", - "x-openclaw-scopes": scopes, - "x-openclaw-sender-is-owner": "true", + ...headers, }, - ); - - expect(res.status).toBe(200); - await ensureResponseConsumed(res); - expect(agentCommandMock).toHaveBeenCalledTimes(1); - expect(firstAgentOpts().senderIsOwner).toBe(senderIsOwner); - } - } + ), + consume: ensureResponseConsumed, + senderIsOwner: () => firstAgentOpts().senderIsOwner, + }); const trustedProxyHeaders = { "x-forwarded-for": "198.51.100.42", @@ -2163,11 +1230,7 @@ describe("OpenResponses HTTP API (e2e)", () => { return { payloads: [{ text: queued ? "answer queued" : "unreachable" }] }; }) as never); - const res = await postResponses(enabledPort, { - stream: true, - model: "openclaw", - input: "hi", - }); + const res = await post({ stream: true }); await new Promise((resolve) => { setImmediate(resolve); }); @@ -2180,285 +1243,86 @@ describe("OpenResponses HTTP API (e2e)", () => { await vi.waitFor(() => expect(getActiveGatewayRootWorkCount()).toBe(idleRootCount)); }); - it.each([true, false])( - "preserves non-stream function_call output (commentary: %s)", - async (commentary) => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: commentary - ? [{ text: "Let me check that.", mediaUrl: null }] - : [{ text: "", mediaUrl: "/tmp/image.png" }], - meta: { - durationMs: 0, - stopReason: "tool_calls", - pendingToolCalls: [ - { - id: "call_1", - name: "get_weather", - arguments: '{"city":"Taipei"}', - }, - ], - }, - }); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - }); + it("returns a tool-only response without empty commentary", async () => { + mockAgentOnce([{ text: "" }], { + stopReason: "tool_calls", + pendingToolCalls: [{ id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }], + }); + const res = await post({ tools: WEATHER_TOOL }); + expect(res.status).toBe(200); + const response = (await res.json()) as ResponseResource; + expect(response.status).toBe("completed"); + expect(response.output.map((item) => item.type)).toEqual(["function_call"]); + expect(response.output[0]).toMatchObject({ + name: "get_weather", + call_id: "call_1", + arguments: '{"city":"Taipei"}', + }); + }); + it.each([false, true])( + "reports output-budget truncation as incomplete (stream=%s)", + async (stream) => { + mockAgentOnce([{ text: "A partial answer" }], { stopReason: "length" }); + const res = await post({ stream }); expect(res.status).toBe(200); - const json = (await res.json()) as { - status?: string; - output?: Array>; - }; - expect(json.status).toBe("completed"); - expect(json.output?.map((item) => item.type)).toEqual( - commentary ? ["message", "function_call"] : ["function_call"], - ); - if (commentary) { - expect(json.output?.[0]).toMatchObject({ - phase: "commentary", - content: [{ type: "output_text", text: "Let me check that." }], + const text = await res.text(); + const events = stream ? parseSseEvents(text) : []; + const response = stream + ? responseEvent(events, "response.incomplete") + : (JSON.parse(text) as ResponseResource); + expect(response.status).toBe("incomplete"); + expect(response.incomplete_details?.reason).toBe("max_output_tokens"); + expect(response.output).toMatchObject([ + { type: "message", status: "incomplete", phase: "final_answer" }, + ]); + if (stream) { + expect( + collectSseEventTypes(events).filter((type) => + ["response.completed", "response.incomplete", "response.failed"].includes(type), + ), + ).toEqual(["response.incomplete"]); + expect(text.split("data: [DONE]")).toHaveLength(2); + expect(parseSseData(findSseEvent(events, "response.incomplete"))).toMatchObject({ + type: "response.incomplete", + }); + expect(parseSseData(findSseEvent(events, "response.output_item.done"))).toMatchObject({ + item: response.output[0], }); } - expect(json.output?.at(-1)).toMatchObject({ - name: "get_weather", - call_id: "call_1", - arguments: '{"city":"Taipei"}', - }); - await ensureResponseConsumed(res); }, ); - it("rejects an unsatisfied required tool_choice on the non-streaming path", async () => { - const port = enabledPort; - mockAgentOnce([{ text: "plain text despite required" }]); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - tool_choice: "required", - }); - - expect(res.status).toBe(502); - const json = (await res.json()) as { - status?: string; - error?: { code?: string; message?: string }; - }; - expect(json.status).toBe("failed"); - expect(json.error?.code).toBe("api_error"); - expect(json.error?.message ?? "").toContain("tool_choice=required was not satisfied"); - await ensureResponseConsumed(res); - }); - - it("returns the tool call when a required tool_choice is satisfied (non-streaming)", async () => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Calling the tool." }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [{ id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }], - }, - } as never); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - tool_choice: "required", - }); - - expect(res.status).toBe(200); - const json = (await res.json()) as { - status?: string; - output?: Array>; - }; - expect(json.status).toBe("completed"); - expect(json.output?.map((item) => item.type)).toEqual(["message", "function_call"]); - expect(json.output?.[1]?.name).toBe("get_weather"); - const opts = firstAgentOpts(); - expect((opts as { extraSystemPrompt?: string }).extraSystemPrompt ?? "").toContain( - "You must call one of the available tools", - ); - await ensureResponseConsumed(res); - }); - - it("reports output-budget truncation as incomplete on the non-streaming path", async () => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "A partial answer cut off by the output token budget mid-sent" }], - meta: { stopReason: "length" }, - } as never); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "write a long story", - }); - - expect(res.status).toBe(200); - const json = (await res.json()) as { - status?: string; - incomplete_details?: { reason?: string }; - output?: Array>; - }; - expect(json.status).toBe("incomplete"); - expect(json.incomplete_details?.reason).toBe("max_output_tokens"); - expect(json.output?.map((item) => item.type)).toEqual(["message"]); - expect(json.output?.[0]?.status).toBe("incomplete"); - expect(json.output?.[0]?.phase).toBe("final_answer"); - await ensureResponseConsumed(res); - }); - - it("carries output-budget truncation as incomplete on the streaming path", async () => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "A streamed partial answer cut off by the output token budget mid-" }], - meta: { stopReason: "length" }, - } as never); - - const res = await postResponses(port, { - stream: true, - model: "openclaw", - input: "write another long story", - }); - - expect(res.status).toBe(200); - const text = await res.text(); - const events = parseSseEvents(text); - expect( - collectSseEventTypes(events).filter((type) => - ["response.completed", "response.incomplete", "response.failed"].includes(type), - ), - ).toEqual(["response.incomplete"]); - expect(text.split("data: [DONE]")).toHaveLength(2); - const incomplete = parseSseData(findSseEvent(events, "response.incomplete")) as { - type: string; - response: ResponseResource; - }; - expect(incomplete.type).toBe("response.incomplete"); - expect(incomplete.response.status).toBe("incomplete"); - expect(incomplete.response.incomplete_details?.reason).toBe("max_output_tokens"); - expect(incomplete.response.output[0]).toMatchObject({ - type: "message", - status: "incomplete", - phase: "final_answer", - }); - expect(parseSseData(findSseEvent(events, "response.output_item.done"))).toMatchObject({ - item: incomplete.response.output[0], - }); - }); - - it("rejects an unsatisfied function tool_choice on the non-streaming path", async () => { - const port = enabledPort; - mockAgentOnce([{ text: "I can answer without the weather tool." }]); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - tool_choice: { type: "function", name: "get_weather" }, - }); - - expect(res.status).toBe(502); - const json = (await res.json()) as { - status?: string; - error?: { code?: string; message?: string }; - }; - expect(json.status).toBe("failed"); - expect(json.error?.code).toBe("api_error"); - expect(json.error?.message ?? "").toContain("tool_choice required a get_weather tool call"); - await ensureResponseConsumed(res); - }); - - it("rejects a non-streaming function tool_choice when the agent calls a different tool", async () => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Calling another tool." }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [{ id: "call_1", name: "get_time", arguments: "{}" }], - }, - } as never); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "check the weather", - tools: [ - ...WEATHER_TOOL, - { - type: "function", - name: "get_time", - description: "Get time", - }, - ], - tool_choice: { type: "function", name: "get_weather" }, - }); - - expect(res.status).toBe(502); - const json = (await res.json()) as { - status?: string; - error?: { code?: string; message?: string }; - }; - expect(json.status).toBe("failed"); - expect(json.error?.code).toBe("api_error"); - expect(json.error?.message ?? "").toContain("tool_choice required a get_weather tool call"); - await ensureResponseConsumed(res); - }); - - it("rejects an unsatisfied required tool_choice on the streaming path without leaking text", async () => { - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => - buildAssistantDeltaResult({ - opts, - emit: emitAgentEvent, - deltas: ["plain text despite required"], - text: "plain text despite required", - })) as never); - - const res = await postResponses(port, { - stream: true, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - tool_choice: "required", - }); - - expect(res.status).toBe(200); - const text = await res.text(); - const events = parseSseEvents(text); - const failed = findSseEvent(events, "response.failed"); - const failedResponse = ( - parseSseData(failed) as { - response?: { status?: string; error?: { code?: string; message?: string } }; + it.each([false, true])( + "rejects an unsatisfied required tool choice (stream=%s)", + async (stream) => { + agentCommandMock.mockClear(); + agentCommandMock.mockImplementationOnce((async (opts: unknown) => + buildAssistantDeltaResult({ + opts, + emit: emitAgentEvent, + deltas: ["plain text despite required"], + text: "plain text despite required", + })) as never); + const res = await post({ stream, tools: WEATHER_TOOL, tool_choice: "required" }); + expect(res.status).toBe(stream ? 200 : 502); + const text = await res.text(); + const events = stream ? parseSseEvents(text) : []; + const response = stream + ? responseEvent(events, "response.failed") + : (JSON.parse(text) as ResponseResource); + expect(response.status).toBe("failed"); + expect(response.error?.code).toBe("api_error"); + expect(response.error?.message).toContain("tool_choice=required was not satisfied"); + if (stream) { + expect(text).toContain("[DONE]"); + expect(text).not.toContain("plain text despite required"); + expect(collectSseEventTypes(events)).not.toContain("response.output_text.delta"); } - ).response; - expect(failedResponse?.status).toBe("failed"); - expect(failedResponse?.error?.code).toBe("api_error"); - expect(failedResponse?.error?.message ?? "").toContain( - "tool_choice=required was not satisfied", - ); - expect(text).toContain("[DONE]"); - // Buffered prose must never reach the client when the contract fails. - expect(text).not.toContain("plain text despite required"); - expect(collectSseEventTypes(events)).not.toContain("response.output_text.delta"); - }); + }, + ); it("rejects a streaming function tool_choice when the agent calls a different tool", async () => { - const port = enabledPort; agentCommandMock.mockClear(); agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "Calling another tool." }], @@ -2468,10 +1332,8 @@ describe("OpenResponses HTTP API (e2e)", () => { }, } as never); - const res = await postResponses(port, { + const res = await post({ stream: true, - model: "openclaw", - input: "check the weather", tools: [ ...WEATHER_TOOL, { @@ -2487,11 +1349,7 @@ describe("OpenResponses HTTP API (e2e)", () => { const text = await res.text(); const events = parseSseEvents(text); const failed = findSseEvent(events, "response.failed"); - const failedResponse = ( - parseSseData(failed) as { - response?: { status?: string; error?: { code?: string; message?: string } }; - } - ).response; + const failedResponse = (parseSseData(failed) as { response: ResponseResource }).response; expect(failedResponse?.status).toBe("failed"); expect(failedResponse?.error?.code).toBe("api_error"); expect(failedResponse?.error?.message ?? "").toContain( @@ -2501,509 +1359,148 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(text).toContain("[DONE]"); }); - it.each( - [ - { - name: "the final payload", - modes: ["required", "pinned"] as const, - payloads: [{ text: "Calling the tool." }], - expected: "Calling the tool.", - }, - { - name: "missing final text", - modes: ["required"] as const, - payloads: [], - expected: "Calling the tool.", - }, - { - name: "an explicit empty final payload", - modes: ["required"] as const, - payloads: [{ text: "" }], - expected: "", - }, - ].flatMap((scenario) => scenario.modes.map((mode) => ({ scenario, mode }))), - )( - "uses $scenario.name for $mode response tool-stream terminal commentary", - async ({ scenario, mode }) => { - const port = enabledPort; + it.each([false, true])( + "returns all client tool calls with commentary (stream=%s, #52288)", + async (stream) => { agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => { - const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; - emitAgentEvent({ runId, stream: "assistant", data: { delta: "Calling " } }); - emitAgentEvent({ runId, stream: "assistant", data: { delta: "the tool." } }); - return { - payloads: scenario.payloads, - meta: { - stopReason: "tool_calls", - pendingToolCalls: [ - { id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }, - ], - }, - }; - }) as never); - - const res = await postResponses(port, { - stream: true, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - tool_choice: mode === "required" ? "required" : { type: "function", name: "get_weather" }, + agentCommandMock.mockResolvedValueOnce({ + payloads: [{ text: "Calling all three tools now.", mediaUrl: null }], + meta: { + durationMs: 0, + stopReason: "tool_calls", + pendingToolCalls: [ + { id: "call_1", name: "create_graph", arguments: '{"nodes":["a","b"]}' }, + { id: "call_2", name: "activate_graph", arguments: "{}" }, + { id: "call_3", name: "get_status", arguments: "{}" }, + ], + }, }); - - expect(res.status).toBe(200); - const events = parseSseEvents(await res.text()); - const text = events - .filter((event) => event.event === "response.output_text.delta") - .map((event) => (parseSseData(event) as { delta?: string }).delta ?? "") - .join(""); - expect(text).toBe(scenario.expected); - const done = findSseEvent(events, "response.output_text.done"); - expect(parseSseData(done)).toMatchObject({ text: scenario.expected }); - const completed = findSseEvent(events, "response.completed"); - const response = ( - parseSseData(completed) as { - response?: { status?: string; output?: Array> }; - } - ).response; - expect(response?.status).toBe("completed"); - expect(response?.output?.map((item) => item.type)).toEqual(["message", "function_call"]); - expect(response?.output?.[1]?.name).toBe("get_weather"); - expect(response?.output?.[0]?.content).toEqual([ - { type: "output_text", text: scenario.expected }, - ]); - }, - ); - - it.each([ - ["an initial replacement", undefined, undefined], - ["a replacement after buffered text", "draft", ""], - ["a replacement with an explicit delta", "draft", "final answer"], - ])( - "buffers %s until the required client tool is validated", - async (_name, previousDelta, replacementDelta) => { - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => { - const runId = (opts as { runId?: string }).runId; - if (!runId) { - throw new Error("expected a streaming response run ID"); - } - if (previousDelta) { - emitAgentEvent({ - runId, - stream: "assistant", - data: { text: previousDelta, delta: previousDelta }, - }); - } - emitAgentEvent({ - runId, - stream: "assistant", - data: { - text: "final answer", - replace: true, - phase: "commentary", - ...(replacementDelta === undefined ? {} : { delta: replacementDelta }), - }, - }); - return { - payloads: [{ text: "final answer" }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [ - { id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }, - ], - }, - }; - }) as never); - - const res = await postResponses(enabledPort, { - stream: true, - model: "openclaw", - input: "check the weather", - tools: WEATHER_TOOL, - tool_choice: "required", + const res = await post({ + stream, + tools: ["create_graph", "activate_graph", "get_status"].map((name) => ({ + type: "function", + name, + })), }); - expect(res.status).toBe(200); - const events = parseSseEvents(await res.text()); - const deltas = events - .filter((event) => event.event === "response.output_text.delta") - .map((event) => (parseSseData(event) as { delta?: string }).delta); - expect(deltas).toEqual(["final answer"]); - - const completed = parseSseData(findSseEvent(events, "response.completed")) as { - response?: { - status?: string; - output?: Array<{ type?: string; content?: Array<{ text?: string }> }>; - }; - }; - expect(completed.response?.status).toBe("completed"); - expect(completed.response?.output?.map((item) => item.type)).toEqual([ + const events = stream ? parseSseEvents(await res.text()) : []; + const response = stream ? responseEvent(events) : ((await res.json()) as ResponseResource); + expect(response.status).toBe("completed"); + expect(response.output.map((item) => item.type)).toEqual([ "message", "function_call", + "function_call", + "function_call", ]); - expect(completed.response?.output?.[0]?.content?.[0]?.text).toBe("final answer"); + expect(response.output[0]).toMatchObject({ + phase: "commentary", + content: [{ type: "output_text", text: "Calling all three tools now." }], + }); + expect(response.output.slice(1)).toMatchObject([ + { name: "create_graph", call_id: "call_1", arguments: '{"nodes":["a","b"]}' }, + { name: "activate_graph", call_id: "call_2" }, + { name: "get_status", call_id: "call_3" }, + ]); + if (stream) { + const callsFor = (eventName: string) => + events + .filter(({ event }) => event === eventName) + .map( + (event) => + parseSseData(event) as { + output_index: number; + item: ResponseResource["output"][number]; + }, + ) + .filter(({ item }) => item.type === "function_call"); + const added = callsFor("response.output_item.added"); + const done = callsFor("response.output_item.done"); + expect(added.map(({ output_index }) => output_index)).toEqual([1, 2, 3]); + expect(added.map(({ item }) => item)).toMatchObject([ + { name: "create_graph", call_id: "call_1" }, + { name: "activate_graph", call_id: "call_2" }, + { name: "get_status", call_id: "call_3" }, + ]); + expect(done.map(({ output_index }) => output_index)).toEqual([1, 2, 3]); + expect(response.output.slice(1)).toEqual(done.map(({ item }) => item)); + expect(events.map(({ data }) => data)).toContain("[DONE]"); + } }, ); - it.each(bufferedReplacementCases)( - "buffers replaceable assistant events through $name", - async ({ replacement, finalText, expected }) => { - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce((async (opts: unknown) => { - const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; - return emitBufferedAssistantReplacement(runId, { replacement, finalText }); - }) as never); - - const client = createOpenAiHttpTestClient(enabledPort); - const stream = client.responses.stream({ - model: "openclaw", - input: "hi", - }); - const deltas: string[] = []; - stream.on("response.output_text.delta", (event) => deltas.push(event.delta)); - - const response = await stream.finalResponse(); - expect({ deltas: deltas.join(""), outputText: response.output_text }).toEqual({ - deltas: expected, - outputText: expected, - }); - expect(response.status).toBe("completed"); - }, - ); - - it("falls back to payload text for streamed function_call responses", async () => { - const port = enabledPort; + it("replays returned tool items into a stateless turn", async () => { agentCommandMock.mockClear(); + const calls = [ + { id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }, + { id: "call_2", name: "get_weather", arguments: '{"city":"Paris"}' }, + ]; agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Let me check that." }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [ - { - id: "call_1", - name: "get_weather", - arguments: '{"city":"Taipei"}', - }, - ], - }, - } as never); - - const res = await postResponses(port, { - stream: true, - model: "openclaw", - input: "check the weather", + payloads: [{ text: "Checking both cities.", mediaUrl: null }], + meta: { durationMs: 0, stopReason: "tool_calls", pendingToolCalls: calls }, + }); + const user = { type: "message", role: "user", content: "Compare the weather." }; + const firstResponse = await post({ input: [user], stream: true, tools: WEATHER_TOOL }); + expect(firstResponse.status).toBe(200); + const first = responseEvent(parseSseEvents(await firstResponse.text())); + const results = calls.map((call, index) => ({ + type: "function_call_output", + call_id: call.id, + output: String(20 + index), + })); + agentCommandMock.mockResolvedValueOnce({ + payloads: [{ text: "Compared.", mediaUrl: null }], + meta: { durationMs: 0 }, + }); + const secondResponse = await post({ + input: [user, ...first.output, ...results], tools: WEATHER_TOOL, }); - - expect(res.status).toBe(200); - const text = await res.text(); - const events = parseSseEvents(text); - const commentaryDeltas = events.filter((event) => event.event === "response.output_text.delta"); - expect( - commentaryDeltas.map((event) => (parseSseData(event) as { delta?: string }).delta), - ).toEqual(["Let me check that."]); - expect( - collectSseEventTypes(events).filter((event) => - [ - "response.output_text.delta", - "response.output_text.done", - "response.output_item.done", - ].includes(event), - ), - ).toEqual([ - "response.output_text.delta", - "response.output_text.done", - "response.output_item.done", - "response.output_item.done", - ]); - const outputTextDone = findSseEvent(events, "response.output_text.done"); - expect((parseSseData(outputTextDone) as { text?: string }).text).toBe("Let me check that."); - - const completed = findSseEvent(events, "response.completed"); - const response = ( - parseSseData(completed) as { - response?: { status?: string; output?: Array> }; - } - ).response; - expect(response?.status).toBe("completed"); - expect(response?.output?.map((item) => item.type)).toEqual(["message", "function_call"]); - expect(response?.output?.[0]?.phase).toBe("commentary"); - expect( - (((response?.output?.[0]?.content as Array> | undefined) ?? [])[0] - ?.text as string | undefined) ?? "", - ).toBe("Let me check that."); - expect(response?.output?.[1]?.name).toBe("get_weather"); - expect(events.map((event) => event.data)).toContain("[DONE]"); + expect(secondResponse.status).toBe(200); + expect(firstAgentOpts(1).sessionKey).not.toBe(firstAgentOpts().sessionKey); + const prompt = firstAgentOpts(1).message; + expect(prompt).toContain("Checking both cities."); + for (const call of calls) { + expect(prompt).toContain( + `tool_call id=${call.id} name=${call.name} arguments=${call.arguments}`, + ); + expect(prompt).toContain(`Tool:${call.id}:`); + } + await ensureResponseConsumed(secondResponse); }); - it("returns every client tool call when an agent invokes multiple tools in one turn (#52288)", async () => { - // Regression #52288: only pendingToolCalls[0] reached the response. - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Calling all three tools now." }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [ - { id: "call_1", name: "create_graph", arguments: '{"nodes":["a","b"]}' }, - { id: "call_2", name: "activate_graph", arguments: "{}" }, - { id: "call_3", name: "get_status", arguments: "{}" }, - ], - }, - } as never); - - const res = await postResponses(port, { - stream: false, - model: "openclaw", - input: "call all three tools", - tools: [ - { type: "function", name: "create_graph", description: "Create graph" }, - { type: "function", name: "activate_graph", description: "Activate graph" }, - { type: "function", name: "get_status", description: "Get status" }, - ], + it("continues an empty client tool result using the previous response", async () => { + const client = createOpenAiHttpTestClient(enabledPort); + mockAgentOnce([{ text: "Let me check that." }], { + stopReason: "tool_calls", + pendingToolCalls: [{ id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }], }); - - expect(res.status).toBe(200); - const json = (await res.json()) as { - status?: string; - output?: Array>; - }; - expect(json.status).toBe("completed"); - expect(json.output?.map((item) => item.type)).toEqual([ - "message", - "function_call", - "function_call", - "function_call", - ]); - expect(json.output?.[0]?.phase).toBe("commentary"); - expect(json.output?.[0]?.content).toEqual([ - { type: "output_text", text: "Calling all three tools now." }, - ]); - expect(json.output?.slice(1).map((item) => item.name)).toEqual([ - "create_graph", - "activate_graph", - "get_status", - ]); - expect(json.output?.slice(1).map((item) => item.call_id)).toEqual([ - "call_1", - "call_2", - "call_3", - ]); - expect(json.output?.[1]?.arguments).toBe('{"nodes":["a","b"]}'); - await ensureResponseConsumed(res); - }); - - it("emits one SSE function_call per pending call at incrementing output_index (#52288)", async () => { - // Regression #52288: SSE emitted one call with a hard-coded output_index. - const port = enabledPort; - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Calling all three tools now." }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [ - { id: "call_1", name: "create_graph", arguments: '{"nodes":["a","b"]}' }, - { id: "call_2", name: "activate_graph", arguments: "{}" }, - { id: "call_3", name: "get_status", arguments: "{}" }, - ], - }, - } as never); - - const res = await postResponses(port, { - stream: true, - model: "openclaw", - input: "call all three tools", - tools: [ - { type: "function", name: "create_graph", description: "Create graph" }, - { type: "function", name: "activate_graph", description: "Activate graph" }, - { type: "function", name: "get_status", description: "Get status" }, - ], - }); - - expect(res.status).toBe(200); - const text = await res.text(); - const events = parseSseEvents(text); - - type FunctionCallEvent = { - output_index: number; - item: { type: string; name?: string; call_id?: string; arguments?: string }; - }; - const addedFunctionCalls = events - .filter((e) => e.event === "response.output_item.added") - .map((e) => JSON.parse(e.data) as FunctionCallEvent) - .filter((evt) => evt.item.type === "function_call"); - expect(addedFunctionCalls.map((evt) => evt.item.name)).toEqual([ - "create_graph", - "activate_graph", - "get_status", - ]); - expect(addedFunctionCalls.map((evt) => evt.output_index)).toEqual([1, 2, 3]); - expect(addedFunctionCalls.map((evt) => evt.item.call_id)).toEqual([ - "call_1", - "call_2", - "call_3", - ]); - - const doneFunctionCalls = events - .filter((e) => e.event === "response.output_item.done") - .map((e) => JSON.parse(e.data) as FunctionCallEvent) - .filter((evt) => evt.item.type === "function_call"); - expect(doneFunctionCalls.map((evt) => evt.output_index)).toEqual([1, 2, 3]); - - const completed = findSseEvent(events, "response.completed"); - const response = ( - parseSseData(completed) as { - response?: { status?: string; output?: Array> }; - } - ).response; - expect(response?.status).toBe("completed"); - expect(response?.output?.map((item) => item.type)).toEqual([ - "message", - "function_call", - "function_call", - "function_call", - ]); - expect(response?.output?.slice(1).map((item) => item.name)).toEqual([ - "create_graph", - "activate_graph", - "get_status", - ]); - expect(response?.output?.slice(1)).toEqual(doneFunctionCalls.map(({ item }) => item)); - expect(events.map((event) => event.data)).toContain("[DONE]"); - }); - - it.each([ - [false, false], - [true, false], - [false, true], - [true, true], - ])( - "replays returned items into a stateless turn (stream=%s, tools=%s)", - async (stream, tools) => { - agentCommandMock.mockClear(); - const calls = [ - { id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}' }, - { id: "call_2", name: "get_weather", arguments: '{"city":"Paris"}' }, - ]; - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Checking both cities." }], - ...(tools ? { meta: { stopReason: "tool_calls", pendingToolCalls: calls } } : {}), - } as never); - const user = { type: "message", role: "user", content: "Compare the weather." }; - const firstResponse = await postResponses(enabledPort, { - model: "openclaw", - input: [user], - stream, - tools: WEATHER_TOOL, - }); - expect(firstResponse.status).toBe(200); - const first = stream - ? ( - parseSseData( - findSseEvent(parseSseEvents(await firstResponse.text()), "response.completed"), - ) as { response: ResponseResource } - ).response - : ((await firstResponse.json()) as ResponseResource); - const results = tools - ? calls.map((call, index) => ({ - type: "function_call_output", - call_id: call.id, - output: String(20 + index), - })) - : [{ ...user, content: "Explain that answer." }]; - agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "Compared." }] } as never); - const secondResponse = await postResponses(enabledPort, { - model: "openclaw", - input: [user, ...first.output, ...results], - tools: WEATHER_TOOL, - }); - expect(secondResponse.status).toBe(200); - expect(firstAgentOpts(1).sessionKey).not.toBe(firstAgentOpts().sessionKey); - const prompt = firstAgentOpts(1).message; - expect(prompt).toContain("Checking both cities."); - if (tools) { - for (const call of calls) { - expect(prompt).toContain( - `tool_call id=${call.id} name=${call.name} arguments=${call.arguments}`, - ); - expect(prompt).toContain(`Tool:${call.id}:`); - } - } else { - expect(prompt).toContain("Explain that answer."); - } - await ensureResponseConsumed(secondResponse); - }, - ); - - it.each([ - [false, ""], - [true, ""], - [false, " \n "], - [true, "0"], - [false, "Sunny, 70F."], - ])("continues a client tool result (stream=%s, output=%s)", async (stream, output) => { - const port = enabledPort; - const client = createOpenAiHttpTestClient(port); - agentCommandMock.mockClear(); - agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "Let me check that." }], - meta: { - stopReason: "tool_calls", - pendingToolCalls: [ - { - id: "call_1", - name: "get_weather", - arguments: '{"city":"Taipei"}', - }, - ], - }, - } as never); - - const firstJson = await client.responses.create({ - stream: false, + const first = await client.responses.create({ model: "openclaw", input: "check the weather", tools: [{ ...WEATHER_TOOL[0], parameters: {}, strict: false }], }); - expect(firstJson.status).toBe("completed"); - const firstOpts = firstAgentOpts() as { sessionKey?: string } | undefined; - expect(firstJson.id).toMatch(/^resp_/); - const firstSessionKey = requireSessionKey(firstOpts?.sessionKey, "first response"); - + expect(first.status).toBe("completed"); + expect(first.id).toMatch(/^resp_/); + const sessionKey = requireSessionKey( + firstAgentOpts().sessionKey as string | undefined, + "first response", + ); agentCommandMock.mockResolvedValueOnce({ - payloads: [{ text: "It is sunny." }], - } as never); - - const request = { - model: "openclaw", - previous_response_id: firstJson.id, - input: [{ type: "function_call_output" as const, call_id: "call_1", output }], - }; - const secondResponse = stream - ? await client.responses.stream(request).finalResponse() - : await client.responses.create(request); - expect(secondResponse.status).toBe("completed"); - expect(secondResponse.output_text).toBe("It is sunny."); - expect(agentCommandMock).toHaveBeenCalledTimes(2); - expect(firstAgentOpts(1)).toMatchObject({ - sessionKey: firstSessionKey, - message: `Tool:call_1: ${output}`, + payloads: [{ text: "It is sunny.", mediaUrl: null }], + meta: { durationMs: 0 }, }); - }); - - it.each([undefined, null].map((output) => ({ output })))( - "rejects malformed function output: %j", - async ({ output }) => { - agentCommandMock.mockClear(); - const response = await postResponses(enabledPort, { + const second = await client.responses + .stream({ model: "openclaw", - input: [{ type: "function_call_output", call_id: "call_1", output }], - }); - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ error: { type: "invalid_request_error" } }); - expect(agentCommandMock).not.toHaveBeenCalled(); - }, - ); + previous_response_id: first.id, + input: [{ type: "function_call_output", call_id: "call_1", output: "" }], + }) + .finalResponse(); + expect(second.status).toBe("completed"); + expect(second.output_text).toBe("It is sunny."); + expect(agentCommandMock).toHaveBeenCalledTimes(2); + expect(firstAgentOpts(1)).toMatchObject({ sessionKey, message: "Tool:call_1: " }); + }); registerOpenResponsesContinuationTests({ getPort: () => enabledPort, @@ -3027,176 +1524,81 @@ describe("OpenResponses HTTP API (e2e)", () => { }); it("enforces URL allowlist and URL part cap for responses inputs", async () => { - const allowlistConfig = buildResponsesUrlPolicyConfig(1); - await writeGatewayConfig(allowlistConfig); - - const allowlistClaim = await acquireTestPortBlock({ offsets: [0, 1, 2, 3, 4] }); - const allowlistPort = allowlistClaim.port; - const allowlistServer = await startServer(allowlistClaim); - try { - agentCommandMock.mockClear(); - - const allowlistBlocked = await postResponses(allowlistPort, { - model: "openclaw", - input: buildUrlInputMessage({ - kind: "input_file", - text: "fetch this", - url: "https://evil.example.org/secret.txt", - }), - }); - await expectInvalidRequest(allowlistBlocked, /invalid request|allowlist|blocked/i); - } finally { - await allowlistServer.close({ reason: "responses allowlist hardening test done" }); - } - - const capConfig = buildResponsesUrlPolicyConfig(0); - await writeGatewayConfig(capConfig); - - const capClaim = await acquireTestPortBlock({ offsets: [0, 1, 2, 3, 4] }); - const capPort = capClaim.port; - const capServer = await startServer(capClaim); - try { - agentCommandMock.mockClear(); - const maxUrlBlocked = await postResponses(capPort, { - model: "openclaw", - input: buildUrlInputMessage({ - kind: "input_file", - text: "fetch this", - url: "https://cdn.example.com/file-1.txt", - }), - }); - await expectInvalidRequest( - maxUrlBlocked, + for (const [maxUrlParts, url, error] of [ + [1, "https://evil.example.org/secret.txt", /invalid request|allowlist|blocked/i], + [ + 0, + "https://cdn.example.com/file-1.txt", /invalid request|Too many URL-based input sources/i, - ); - expect(agentCommandMock).not.toHaveBeenCalled(); - } finally { - await capServer.close({ reason: "responses url cap hardening test done" }); + ], + ] as const) { + await writeGatewayConfig(buildResponsesUrlPolicyConfig(maxUrlParts)); + const claim = await acquireTestPortBlock({ offsets: [0, 1, 2, 3, 4] }); + const server = await startServer(claim); + try { + agentCommandMock.mockClear(); + const res = await postResponses(claim.port, { + model: "openclaw", + input: buildUrlInputMessage({ kind: "input_file", text: "fetch this", url }), + }); + await expectInvalidRequest(res, error); + expect(agentCommandMock).not.toHaveBeenCalled(); + } finally { + await server.close({ reason: "responses URL policy test done" }); + } } }); - it("aborts agent command when streaming client disconnects", { timeout: 15_000 }, async () => { - const port = enabledPort; - const idleRootCount = getActiveGatewayRootWorkCount(); - const agentAborted = createDeferred(); - const finishAgentCleanup = createDeferred(); - const cleanupAdmissionClosed = createDeferred(); - let serverAbortSignal: AbortSignal | undefined; - - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce(async (opts: unknown) => { - const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal; - serverAbortSignal = signal; - if (signal?.aborted) { - agentAborted.resolve(); - } else { - signal?.addEventListener("abort", () => agentAborted.resolve(), { once: true }); + it.each([false, true])( + "aborts agent work when its client disconnects (stream=%s)", + { timeout: 15_000 }, + async (stream) => { + const idleRootCount = getActiveGatewayRootWorkCount(); + const entered = createDeferred(); + const agentAborted = createDeferred(); + const finishAgentCleanup = createDeferred(); + const cleanupAdmissionClosed = createDeferred(); + let serverAbortSignal: AbortSignal | undefined; + agentCommandMock.mockClear(); + agentCommandMock.mockImplementationOnce(async (opts: unknown) => { + const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + serverAbortSignal = signal; + if (signal?.aborted) { + agentAborted.resolve(); + } else { + signal?.addEventListener("abort", () => agentAborted.resolve(), { once: true }); + } + entered.resolve(); + await agentAborted.promise; + cleanupAdmissionClosed.resolve(isGatewaySubordinateWorkAdmissionClosed()); + await finishAgentCleanup.promise; + return undefined; + }); + const clientReq = http.request({ + hostname: "127.0.0.1", + port: enabledPort, + path: "/v1/responses", + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer secret" }, + }); + clientReq.on("error", () => {}); + clientReq.end(JSON.stringify({ stream, model: "openclaw", input: "hi" })); + await entered.promise; + try { + clientReq.destroy(); + await agentAborted.promise; + expect(serverAbortSignal?.aborted).toBe(true); + if (stream) { + expect(await cleanupAdmissionClosed.promise).toBe(false); + expect(getActiveGatewayRootWorkCount()).toBe(idleRootCount + 1); + } + } finally { + finishAgentCleanup.resolve(); } - await agentAborted.promise; - cleanupAdmissionClosed.resolve(isGatewaySubordinateWorkAdmissionClosed()); - await finishAgentCleanup.promise; - return undefined; - }); - - const clientReq = http.request({ - hostname: "127.0.0.1", - port, - path: "/v1/responses", - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - }, - }); - clientReq.on("error", () => {}); - clientReq.end( - JSON.stringify({ - stream: true, - model: "openclaw", - input: "hi", - }), - ); - - await vi.waitFor( - () => { - expect(agentCommandMock).toHaveBeenCalledTimes(1); - }, - { timeout: 5_000, interval: 50 }, - ); - - try { - clientReq.destroy(); - - await vi.waitFor(() => expect(serverAbortSignal?.aborted).toBe(true), { + await vi.waitFor(() => expect(getActiveGatewayRootWorkCount()).toBe(idleRootCount), { timeout: 5_000, interval: 50, }); - expect(await cleanupAdmissionClosed.promise).toBe(false); - expect(getActiveGatewayRootWorkCount()).toBe(idleRootCount + 1); - } finally { - finishAgentCleanup.resolve(); - } - - await vi.waitFor(() => expect(getActiveGatewayRootWorkCount()).toBe(idleRootCount), { - timeout: 5_000, - interval: 50, - }); - }); - - it( - "aborts agent command when non-streaming client disconnects", - { timeout: 15_000 }, - async () => { - const port = enabledPort; - let serverAbortSignal: AbortSignal | undefined; - - agentCommandMock.mockClear(); - agentCommandMock.mockImplementationOnce( - (opts: unknown) => - new Promise((resolve) => { - const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal; - serverAbortSignal = signal; - if (signal?.aborted) { - resolve(undefined); - return; - } - signal?.addEventListener("abort", () => resolve(undefined), { once: true }); - }), - ); - - const clientReq = http.request({ - hostname: "127.0.0.1", - port, - path: "/v1/responses", - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer secret", - }, - }); - clientReq.on("error", () => {}); - clientReq.end( - JSON.stringify({ - model: "openclaw", - input: "hi", - }), - ); - - await vi.waitFor( - () => { - expect(agentCommandMock).toHaveBeenCalledTimes(1); - }, - { timeout: 5_000, interval: 50 }, - ); - - clientReq.destroy(); - - await vi.waitFor( - () => { - expect(serverAbortSignal?.aborted).toBe(true); - }, - { timeout: 5_000, interval: 50 }, - ); }, ); }); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index d7ebed7afa73..e50166f08b62 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -133,10 +133,6 @@ function createResponseSessionScope(params: { }); } -export const testing = { - resolveResponsesLimits, -}; - function writeSseEvent(res: ServerResponse, event: StreamingEvent) { res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); } diff --git a/src/infra/outbound/sanitize-text.test.ts b/src/infra/outbound/sanitize-text.test.ts index b15cca145ced..5c8ebcfafb24 100644 --- a/src/infra/outbound/sanitize-text.test.ts +++ b/src/infra/outbound/sanitize-text.test.ts @@ -1,5 +1,3 @@ -// Verifies plain-text sanitization strips runtime scaffolding, tool-call blocks, -// prompt-data wrappers, and conservative HTML markup. import { describe, expect, it } from "vitest"; import { escapeInternalRuntimeContextDelimiters, @@ -13,42 +11,22 @@ import { stripInternalRuntimeScaffoldingFromPayload } from "./deliver-payload.js import { stripInternalRuntimeScaffolding } from "./protocol-scaffolding.js"; import { sanitizeForPlainText } from "./sanitize-text.js"; -// --------------------------------------------------------------------------- -// sanitizeForPlainText -// --------------------------------------------------------------------------- - describe("sanitizeForPlainText", () => { - // --- line breaks -------------------------------------------------------- - - it("converts
to newline", () => { - expect(sanitizeForPlainText("hello
world")).toBe("hello\nworld"); - }); - - it("converts self-closing
and
variants", () => { - expect(sanitizeForPlainText("a
b")).toBe("a\nb"); - expect(sanitizeForPlainText("a
b")).toBe("a\nb"); - }); - - // --- inline formatting -------------------------------------------------- - - it("converts and to WhatsApp bold", () => { - expect(sanitizeForPlainText("bold")).toBe("*bold*"); - expect(sanitizeForPlainText("bold")).toBe("*bold*"); - }); - - it("converts and to WhatsApp italic", () => { - expect(sanitizeForPlainText("italic")).toBe("_italic_"); - expect(sanitizeForPlainText("italic")).toBe("_italic_"); - }); - - it("converts , , and to WhatsApp strikethrough", () => { - expect(sanitizeForPlainText("deleted")).toBe("~deleted~"); - expect(sanitizeForPlainText("removed")).toBe("~removed~"); - expect(sanitizeForPlainText("old")).toBe("~old~"); - }); - - it("converts to backtick wrapping", () => { - expect(sanitizeForPlainText("foo()")).toBe("`foo()`"); + it.each([ + ["Hello
world this is nice", "Hello\n*world* this is _nice_"], + ["before

inside
after", "before\ninside\nafter"], + ["


", "\n\n"], + ["before\r\nafter", "before\r\nafter"], + ["onetwo", "onetwo"], + ["Ping for access", "Ping for access"], + ["See now", "See https://example.com/path?q=1 now"], + ["", "Contact Support"], + ["", ""], + ["Support ", "Support "], + ["Usage: /btw [side question]", "Usage: /btw [side question]"], + ["a\n\n\nb", "a\n\nb"], + ])("sanitizes %s", (input, expected) => { + expect(sanitizeForPlainText(input)).toBe(expected); }); it("converts attributed inline tags without matching tag-name prefixes", () => { @@ -62,140 +40,38 @@ describe("sanitizeForPlainText", () => { ).toBe("bsc"); }); - // --- block elements ----------------------------------------------------- - - it.each([ - ["

paragraph

", "\nparagraph\n"], - ['before

inside

after', "before\ninside\nafter"], - ['before
inside
after', "before\ninside\nafter"], - ["before
inside
after", "before\ninside\nafter"], - ])("preserves block boundaries in %s", (input, expected) => { - expect(sanitizeForPlainText(input)).toBe(expected); - }); - it("converts headings to bold text with newlines", () => { expect(sanitizeForPlainText("

Title

")).toBe("\n*Title*\n"); - expect(sanitizeForPlainText("

Section

")).toBe("\n*Section*\n"); expect(sanitizeForPlainText('

Markdown

', { style: "markdown" })).toBe( "\n**Markdown**\n", ); }); - it("converts
  • to bullet points", () => { - expect(sanitizeForPlainText("
  • item one
  • item two
  • ")).toBe( - "• item one\n• item two\n", - ); - }); - - it.each([ - ["", { style: "markdown" as const }], - ["", {}], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", {}], - ["

    ", { style: "markdown" as const }], - ["
  • ", { style: "markdown" as const }], - [" ", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["
  • ", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ["", { style: "markdown" as const }], - ])("does not create visible structure from %s", (input, options) => { - expect(sanitizeForPlainText(input, options)).toBe(""); - }); - - it("preserves visible content around an empty element", () => { - expect( - sanitizeForPlainText("before\n\nafter", { - style: "markdown", - }), - ).toBe("before\n\nafter"); - }); - - it.each([ - ["
    ", "\n"], - ["\n", "\n"], - ["\r\n", "\r\n"], - ["

    ", "\n\n"], - ["
    ", "\n\n"], - ["


    ", "\n\n"], - ])("preserves structural breaks in %s", (input, expected) => { - expect(sanitizeForPlainText(input)).toBe(expected); - }); - - it("preserves a wrapped line break between visible text", () => { - expect(sanitizeForPlainText("before\nafter")).toBe("before\nafter"); - }); - - // --- tag stripping ------------------------------------------------------ - - it("strips unknown/remaining tags", () => { - expect(sanitizeForPlainText('text')).toBe("text"); - expect(sanitizeForPlainText('link')).toBe("link"); - expect(sanitizeForPlainText("")).toBe("alert(1)"); - expect(sanitizeForPlainText("visible")).toBe("visible"); - }); - - it("strips colon- and dot-qualified tags", () => { - expect( - sanitizeForPlainText("onetwo"), - ).toBe("onetwo"); - }); + it.each([" ", "
  • ", ""])( + "does not create visible structure from %s", + (input) => { + expect(sanitizeForPlainText(input, { style: "markdown" })).toBe(""); + }, + ); it("keeps stripping tags exposed by malformed tag text", () => { - const sanitized = sanitizeForPlainText( - "before <