import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { FailoverError } from "../agents/failover-error.js"; import { createClientToolNameConflictError } from "../agents/pi-tool-definition-adapter.js"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js"; import { agentCommand, getFreePort, installGatewayTestHooks, startGatewayServerWithRetries, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); let enabledServer: Awaited>; let enabledPort: number; let openResponsesTesting: { resetResponseSessionState(): void; storeResponseSessionAt( responseId: string, sessionKey: string, now: number, scope?: { authSubject: string; agentId: string; requestedSessionKey?: string }, ): void; lookupResponseSessionAt( responseId: string | undefined, now: number, scope?: { authSubject: string; agentId: string; requestedSessionKey?: string }, ): string | undefined; getResponseSessionIds(): string[]; }; beforeAll(async () => { ({ __testing: openResponsesTesting } = await import("./openresponses-http.js")); const started = await startGatewayServerWithRetries({ port: await getFreePort(), opts: { host: "127.0.0.1", auth: { mode: "none" }, controlUiEnabled: false, openResponsesEnabled: true, }, }); enabledPort = started.port; enabledServer = started.server; }); afterAll(async () => { await enabledServer?.close({ reason: "openresponses enabled suite done" }); }); beforeEach(() => { openResponsesTesting.resetResponseSessionState(); }); async function startServer(port: number, opts?: { openResponsesEnabled?: boolean }) { const { startGatewayServer } = await import("./server.js"); const serverOpts = { host: "127.0.0.1", auth: { mode: "none" as const }, controlUiEnabled: false, } as const; return await startGatewayServer( port, opts?.openResponsesEnabled === undefined ? serverOpts : { ...serverOpts, openResponsesEnabled: opts.openResponsesEnabled }, ); } async function startTokenServer(port: number, opts?: { openResponsesEnabled?: boolean }) { const { startGatewayServer } = await import("./server.js"); const serverOpts = { host: "127.0.0.1", auth: { mode: "token" as const, token: "secret" }, controlUiEnabled: false, } as const; return await startGatewayServer( port, opts?.openResponsesEnabled === undefined ? { ...serverOpts, openResponsesEnabled: true } : { ...serverOpts, openResponsesEnabled: opts.openResponsesEnabled }, ); } async function writeGatewayConfig(config: Record) { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { throw new Error("OPENCLAW_CONFIG_PATH is required for gateway config tests"); } await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); } async function postResponses(port: number, body: unknown, headers?: Record) { const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { "content-type": "application/json", "x-openclaw-scopes": "operator.write", ...headers, }, body: JSON.stringify(body), }); return res; } type SseEvent = { event?: string; data: string }; function parseSseEvents(text: string): SseEvent[] { const events: SseEvent[] = []; const lines = text.split("\n"); let currentEvent: string | undefined; let currentData: string[] = []; for (const line of lines) { if (line.startsWith("event: ")) { currentEvent = line.slice("event: ".length); } else if (line.startsWith("data: ")) { currentData.push(line.slice("data: ".length)); } else if (line.trim() === "" && currentData.length > 0) { events.push({ event: currentEvent, data: currentData.join("\n") }); currentEvent = undefined; currentData = []; } } return events; } function collectSseEventTypes(events: readonly SseEvent[]): string[] { const eventTypes: string[] = []; for (const event of events) { if (event.event) { eventTypes.push(event.event); } } return eventTypes; } function findSseEvent(events: SseEvent[], eventName: string): SseEvent { const event = events.find((candidate) => candidate.event === eventName); if (!event) { throw new Error(`expected SSE event ${eventName}`); } return event; } function parseSseData(event: SseEvent): unknown { return JSON.parse(event.data) as unknown; } function requireSessionKey(value: string | undefined, label: string): string { if (!value) { throw new Error(`expected ${label} sessionKey`); } return value; } function firstAgentOpts(callIndex = 0): Record { const call = agentCommand.mock.calls[callIndex]; if (!call) { throw new Error(`expected agentCommand call #${callIndex + 1}`); } return call[0] as Record; } async function ensureResponseConsumed(res: Response) { if (res.bodyUsed) { return; } try { await res.text(); } catch { // Ignore drain failures; best-effort to release keep-alive sockets in tests. } } const WEATHER_TOOL = [ { type: "function", name: "get_weather", description: "Get weather", }, ] as const; 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 }, }, ], }, ]; } function buildResponsesUrlPolicyConfig(maxUrlParts: number) { return { gateway: { http: { endpoints: { responses: { enabled: true, maxUrlParts, files: { allowUrl: true, urlAllowlist: ["cdn.example.com", "*.assets.example.com"], }, images: { allowUrl: true, urlAllowlist: ["images.example.com"], }, }, }, }, }, }; } async function expectInvalidRequest( res: Response, messagePattern: RegExp, ): Promise<{ type?: string; message?: string } | undefined> { 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 ?? "").toMatch(messagePattern); return json.error; } describe("OpenResponses HTTP API (e2e)", () => { it("handles OpenResponses request parsing and validation", async () => { const port = enabledPort; const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => { agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads, meta } as never); }; try { 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" }, 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); agentCommand.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(agentCommand).toHaveBeenCalledTimes(0); await ensureResponseConsumed(resInvalidModel); mockAgentOnce([{ text: "hello" }]); const resHeader = await postResponses( port, { model: "openclaw", input: "hi" }, { "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 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); 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); 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" }, ); expect(resModelOverride.status).toBe(200); const optsModelOverride = firstAgentOpts(); expect((optsModelOverride as { model?: string } | undefined)?.model).toBe("openai/gpt-5.4"); await ensureResponseConsumed(resModelOverride); agentCommand.mockClear(); const resInvalidOverride = await postResponses( port, { model: "openclaw", input: "hi" }, { "x-openclaw-model": "openai/" }, ); 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(agentCommand).toHaveBeenCalledTimes(0); await ensureResponseConsumed(resInvalidOverride); agentCommand.mockClear(); agentCommand.mockRejectedValueOnce(createClientToolNameConflictError(["exec"])); const resToolConflict = await postResponses(port, { model: "openclaw", input: "hi", tools: WEATHER_TOOL, }); 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); const optsUser = firstAgentOpts(); expect((optsUser as { sessionKey?: string } | undefined)?.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); const optsString = firstAgentOpts(); expect((optsString as { message?: string } | undefined)?.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); const optsArray = firstAgentOpts(); expect((optsArray as { message?: string } | undefined)?.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: [ { type: "message", role: "user", content: [ { type: "input_text", text: "read this" }, { type: "input_file", source: { type: "base64", media_type: "text/plain", data: Buffer.from("hello").toString("base64"), filename: "hello.txt", }, }, ], }, ], }); 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').toString("base64"), filename: 'test"> after', ); expect(inputFileInjectionPrompt).not.toContain(''); expect((inputFileInjectionPrompt.match(/; } | undefined )?.clientTools ?? []; expect(clientTools).toHaveLength(1); expect(clientTools[0]?.function?.name).toBe("get_time"); expect(clientTools[0]?.function?.strict).toBe(true); await ensureResponseConsumed(resToolChoice); const resUnknownTool = await postResponses(port, { model: "openclaw", input: "hi", tools: WEATHER_TOOL, tool_choice: { type: "function", function: { name: "unknown_tool" } }, }); expect(resUnknownTool.status).toBe(400); await ensureResponseConsumed(resUnknownTool); mockAgentOnce([{ text: "ok" }]); const resMaxTokens = await postResponses(port, { model: "openclaw", input: "hi", max_output_tokens: 123, }); expect(resMaxTokens.status).toBe(200); const optsMaxTokens = firstAgentOpts(); expect( (optsMaxTokens as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams ?.maxTokens, ).toBe(123); await ensureResponseConsumed(resMaxTokens); mockAgentOnce([{ text: "ok" }]); const resSampling = await postResponses(port, { model: "openclaw", input: "hi", temperature: 0.2, top_p: 0.9, }); expect(resSampling.status).toBe(200); const samplingStreamParams = ( firstAgentOpts() as { streamParams?: { temperature?: number; topP?: number } } | undefined )?.streamParams; expect(samplingStreamParams?.temperature).toBe(0.2); expect(samplingStreamParams?.topP).toBe(0.9); await ensureResponseConsumed(resSampling); agentCommand.mockClear(); const resInvalidTemperature = await postResponses(port, { model: "openclaw", input: "hi", temperature: 999, }); expect(resInvalidTemperature.status).toBe(400); const invalidTemperatureJson = (await resInvalidTemperature.json()) as { error?: { type?: string; message?: string }; }; expect(invalidTemperatureJson.error?.type).toBe("invalid_request_error"); expect(invalidTemperatureJson.error?.message ?? "").toMatch(/temperature/i); expect(agentCommand).toHaveBeenCalledTimes(0); agentCommand.mockClear(); const resInvalidTopP = await postResponses(port, { model: "openclaw", input: "hi", top_p: 5, }); expect(resInvalidTopP.status).toBe(400); const invalidTopPJson = (await resInvalidTopP.json()) as { error?: { type?: string; message?: string }; }; expect(invalidTopPJson.error?.type).toBe("invalid_request_error"); expect(invalidTopPJson.error?.message ?? "").toMatch(/top_p/i); expect(agentCommand).toHaveBeenCalledTimes(0); mockAgentOnce([{ text: "ok" }], { agentMeta: { usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 }, }, }); const resUsage = await postResponses(port, { stream: false, model: "openclaw", input: "hi", }); expect(resUsage.status).toBe(200); const usageJson = (await resUsage.json()) as Record; expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 }); 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(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 ensureResponseConsumed(resNoUser); } finally { // shared server } }); it("streams OpenResponses SSE events", async () => { const port = enabledPort; try { agentCommand.mockClear(); agentCommand.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]"); 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"); agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }], } as never); const resFallback = await postResponses(port, { stream: true, model: "openclaw", input: "hi", }); expect(resFallback.status).toBe(200); const fallbackText = await resFallback.text(); expect(fallbackText).toContain("[DONE]"); expect(fallbackText).toContain("hello"); agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }], } as never); const resTypeMatch = await postResponses(port, { stream: true, model: "openclaw", input: "hi", }); expect(resTypeMatch.status).toBe(200); const typeText = await resTypeMatch.text(); const typeEvents = parseSseEvents(typeText); for (const event of typeEvents) { if (event.data === "[DONE]") { continue; } const parsed = JSON.parse(event.data) as { type?: string }; expect(event.event).toBe(parsed.type); } } finally { // shared server } }); it("maps provider format failures to OpenResponses 400 failed responses", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.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(agentCommand).toHaveBeenCalledTimes(1); }); it("treats write-scoped HTTP callers as non-owner and admin-scoped callers as owner", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); const writeScopeResponse = await postResponses(port, { model: "openclaw", input: "hi", }); expect(writeScopeResponse.status).toBe(200); const writeScopeOpts = firstAgentOpts() as { senderIsOwner?: boolean } | undefined; expect(writeScopeOpts?.senderIsOwner).toBe(false); await ensureResponseConsumed(writeScopeResponse); agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); const adminScopeResponse = await postResponses( port, { model: "openclaw", input: "hi" }, { "x-openclaw-scopes": "operator.admin, operator.write" }, ); expect(adminScopeResponse.status).toBe(200); const adminScopeOpts = firstAgentOpts() as { senderIsOwner?: boolean } | undefined; expect(adminScopeOpts?.senderIsOwner).toBe(true); await ensureResponseConsumed(adminScopeResponse); agentCommand.mockClear(); agentCommand.mockImplementationOnce((async (opts: unknown) => buildAssistantDeltaResult({ opts, emit: emitAgentEvent, deltas: ["he", "llo"], text: "hello", })) as never); const streamingResponse = await postResponses( port, { stream: true, model: "openclaw", input: "hi" }, { "x-openclaw-scopes": "operator.admin, operator.write" }, ); expect(streamingResponse.status).toBe(200); const streamingOpts = firstAgentOpts() as { senderIsOwner?: boolean } | undefined; expect(streamingOpts?.senderIsOwner).toBe(true); const streamingEvents = parseSseEvents(await streamingResponse.text()); expect(streamingEvents.map((event) => event.event)).toContain("response.completed"); }); it("treats shared-secret bearer callers as owner operators", async () => { const port = await getFreePort(); const server = await startTokenServer(port); try { agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { method: "POST", headers: { authorization: "Bearer secret", "content-type": "application/json", "x-openclaw-scopes": "operator.approvals", }, body: JSON.stringify({ model: "openclaw", input: "hi", }), }); expect(res.status).toBe(200); const firstCall = firstAgentOpts() as { senderIsOwner?: boolean } | undefined; expect(firstCall?.senderIsOwner).toBe(true); await ensureResponseConsumed(res); } finally { await server.close({ reason: "openresponses token auth owner test done" }); } }); it("preserves assistant text alongside non-stream function_call output", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.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: false, model: "openclaw", input: "check the weather", tools: WEATHER_TOOL, }); expect(res.status).toBe(200); const json = (await res.json()) as { status?: string; output?: Array>; }; expect(json.status).toBe("incomplete"); expect(json.output?.map((item) => item.type)).toEqual(["message", "function_call"]); expect(json.output?.[0]?.phase).toBe("commentary"); expect( ((json.output?.[0]?.content as Array> | undefined)?.[0]?.text as | string | undefined) ?? "", ).toBe("Let me check that."); expect(json.output?.[1]?.name).toBe("get_weather"); await ensureResponseConsumed(res); }); it("falls back to payload text for streamed function_call responses", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.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", tools: WEATHER_TOOL, }); expect(res.status).toBe(200); const text = await res.text(); const events = parseSseEvents(text); 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("incomplete"); 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]"); }); it("returns every client tool call when an agent invokes multiple tools in one turn (#52288)", async () => { // Pre-fix: the non-streaming `/v1/responses` handler read only // `pendingToolCalls[0]`, so a turn that called three client tools // collapsed to a single `function_call` item. Here we mock three pending // calls and assert the response surfaces all three in arrival order // alongside the assistant text. This locks in the contract for callers // who run multi-tool agents (graph orchestration, planners, etc.). const port = enabledPort; agentCommand.mockClear(); agentCommand.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" }, ], }); expect(res.status).toBe(200); const json = (await res.json()) as { status?: string; output?: Array>; }; expect(json.status).toBe("incomplete"); expect(json.output?.map((item) => item.type)).toEqual([ "message", "function_call", "function_call", "function_call", ]); 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 () => { // Streaming counterpart to the non-streaming regression above. Pre-fix // the streaming branch hard-coded `output_index: 1` and only emitted // one `output_item.added`/`done` pair, so multi-tool turns silently // dropped every call past the first. Verify that: // - we get one `output_item.added` and one `output_item.done` for // each pending call, // - their `output_index` values count up monotonically from 1 (the // assistant message owns index 0), and // - the final `response.completed` payload contains the assistant // message followed by all three function_call items in order. const port = enabledPort; agentCommand.mockClear(); agentCommand.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("incomplete"); 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(events.map((event) => event.data)).toContain("[DONE]"); }); it("reuses the prior session when previous_response_id is provided", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "Let me check that." }], meta: { stopReason: "tool_calls", pendingToolCalls: [ { id: "call_1", name: "get_weather", arguments: '{"city":"Taipei"}', }, ], }, } as never); const firstResponse = await postResponses(port, { stream: false, model: "openclaw", input: "check the weather", tools: WEATHER_TOOL, }); expect(firstResponse.status).toBe(200); const firstJson = (await firstResponse.json()) as { id?: string }; const firstOpts = firstAgentOpts() as { sessionKey?: string } | undefined; expect(firstJson.id).toMatch(/^resp_/); const firstSessionKey = requireSessionKey(firstOpts?.sessionKey, "first response"); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "It is sunny." }], } as never); const secondResponse = await postResponses(port, { stream: false, model: "openclaw", previous_response_id: firstJson.id, input: [{ type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." }], }); expect(secondResponse.status).toBe(200); const secondOpts = firstAgentOpts(1) as { sessionKey?: string } | undefined; expect(secondOpts?.sessionKey).toBe(firstSessionKey); await ensureResponseConsumed(secondResponse); }); it("reuses prior sessions across different user values when auth scope matches", async () => { const port = enabledPort; agentCommand.mockClear(); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "First turn." }], } as never); const firstResponse = await postResponses(port, { stream: false, model: "openclaw", user: "alice", input: "hello", }); expect(firstResponse.status).toBe(200); const firstJson = (await firstResponse.json()) as { id?: string }; const firstOpts = firstAgentOpts() as { sessionKey?: string } | undefined; expect(firstOpts?.sessionKey ?? "").toContain("openresponses-user:alice"); agentCommand.mockResolvedValueOnce({ payloads: [{ text: "Second turn." }], } as never); const secondResponse = await postResponses(port, { stream: false, model: "openclaw", user: "bob", previous_response_id: firstJson.id, input: "hello again", }); expect(secondResponse.status).toBe(200); const secondOpts = firstAgentOpts(1) as { sessionKey?: string } | undefined; expect(secondOpts?.sessionKey).toBe(firstOpts?.sessionKey); await ensureResponseConsumed(secondResponse); }); it("stores response session mappings when the response is emitted", async () => { const port = enabledPort; agentCommand.mockClear(); let release: ((value: { payloads: Array<{ text: string }> }) => void) | undefined; agentCommand.mockImplementationOnce( () => new Promise<{ payloads: Array<{ text: string }> }>((resolve) => { release = resolve; }) as never, ); const responsePromise = postResponses(port, { stream: false, model: "openclaw", input: "delayed hello", }); await vi.waitFor(() => { expect(agentCommand.mock.calls).toHaveLength(1); }); expect(openResponsesTesting.getResponseSessionIds()).toStrictEqual([]); release?.({ payloads: [{ text: "hello" }] }); const res = await responsePromise; expect(res.status).toBe(200); const json = (await res.json()) as { id?: string }; expect(json.id).toMatch(/^resp_/); expect(openResponsesTesting.getResponseSessionIds()).toEqual([json.id]); await ensureResponseConsumed(res); }); it("caps response session cache by evicting the oldest entries", () => { for (let i = 0; i < 505; i += 1) { openResponsesTesting.storeResponseSessionAt(`resp_${i}`, `session_${i}`, i); } expect(openResponsesTesting.getResponseSessionIds()).toHaveLength(500); expect(openResponsesTesting.lookupResponseSessionAt("resp_0", 505)).toBeUndefined(); expect(openResponsesTesting.lookupResponseSessionAt("resp_4", 505)).toBeUndefined(); expect(openResponsesTesting.lookupResponseSessionAt("resp_5", 505)).toBe("session_5"); expect(openResponsesTesting.lookupResponseSessionAt("resp_504", 505)).toBe("session_504"); }); it("does not reuse cached sessions when the auth subject changes", () => { openResponsesTesting.storeResponseSessionAt("resp_1", "session_1", 100, { authSubject: "subject:a", agentId: "main", }); expect( openResponsesTesting.lookupResponseSessionAt("resp_1", 101, { authSubject: "subject:a", agentId: "main", }), ).toBe("session_1"); expect( openResponsesTesting.lookupResponseSessionAt("resp_1", 101, { authSubject: "subject:b", agentId: "main", }), ).toBeUndefined(); }); it("blocks unsafe URL-based file/image inputs", async () => { const port = enabledPort; agentCommand.mockClear(); const blockedPrivate = await postResponses(port, { model: "openclaw", input: buildUrlInputMessage({ kind: "input_file", url: "http://127.0.0.1:6379/info", }), }); await expectInvalidRequest(blockedPrivate, /invalid request|private|internal|blocked/i); const blockedMetadata = await postResponses(port, { model: "openclaw", input: buildUrlInputMessage({ kind: "input_image", url: "http://metadata.google.internal/computeMetadata/v1", }), }); await expectInvalidRequest(blockedMetadata, /invalid request|blocked|metadata|internal/i); const blockedScheme = await postResponses(port, { model: "openclaw", input: buildUrlInputMessage({ kind: "input_file", url: "file:///etc/passwd", }), }); await expectInvalidRequest(blockedScheme, /invalid request|http or https/i); expect(agentCommand).not.toHaveBeenCalled(); }); it("enforces URL allowlist and URL part cap for responses inputs", async () => { const allowlistConfig = buildResponsesUrlPolicyConfig(1); await writeGatewayConfig(allowlistConfig); const allowlistPort = await getFreePort(); const allowlistServer = await startServer(allowlistPort, { openResponsesEnabled: true }); try { agentCommand.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 capPort = await getFreePort(); const capServer = await startServer(capPort, { openResponsesEnabled: true }); try { agentCommand.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, /invalid request|Too many URL-based input sources/i, ); expect(agentCommand).not.toHaveBeenCalled(); } finally { await capServer.close({ reason: "responses url cap hardening test done" }); } }); it("aborts agent command when streaming client disconnects", { timeout: 15_000 }, async () => { const port = enabledPort; let serverAbortSignal: AbortSignal | undefined; agentCommand.mockClear(); agentCommand.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({ stream: true, model: "openclaw", input: "hi", }), ); await vi.waitFor( () => { expect(agentCommand).toHaveBeenCalledTimes(1); }, { timeout: 5_000, interval: 50 }, ); clientReq.destroy(); await vi.waitFor( () => { expect(serverAbortSignal?.aborted).toBe(true); }, { 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; agentCommand.mockClear(); agentCommand.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(agentCommand).toHaveBeenCalledTimes(1); }, { timeout: 5_000, interval: 50 }, ); clientReq.destroy(); await vi.waitFor( () => { expect(serverAbortSignal?.aborted).toBe(true); }, { timeout: 5_000, interval: 50 }, ); }, ); });