diff --git a/packages/adapters/openclaw/src/server/execute-common.ts b/packages/adapters/openclaw/src/server/execute-common.ts index f56a880c..fd0d6484 100644 --- a/packages/adapters/openclaw/src/server/execute-common.ts +++ b/packages/adapters/openclaw/src/server/execute-common.ts @@ -306,6 +306,41 @@ export function isTextRequiredResponse(responseText: string): boolean { return responseText.toLowerCase().includes("text required"); } +function extractResponseErrorMessage(responseText: string): string { + const parsed = parseOpenClawResponse(responseText); + if (!parsed) return responseText; + + const directError = parsed.error; + if (typeof directError === "string") return directError; + if (directError && typeof directError === "object") { + const nestedMessage = (directError as Record).message; + if (typeof nestedMessage === "string") return nestedMessage; + } + + const directMessage = parsed.message; + if (typeof directMessage === "string") return directMessage; + + return responseText; +} + +export function isWakeCompatibilityRetryableResponse(responseText: string): boolean { + if (isTextRequiredResponse(responseText)) return true; + + const normalized = extractResponseErrorMessage(responseText).toLowerCase(); + const expectsStringInput = + normalized.includes("invalid input") && + normalized.includes("expected string") && + normalized.includes("undefined"); + if (expectsStringInput) return true; + + const missingInputField = + normalized.includes("input") && + (normalized.includes("required") || normalized.includes("missing")); + if (missingInputField) return true; + + return false; +} + export async function sendJsonRequest(params: { url: string; method: string; diff --git a/packages/adapters/openclaw/src/server/execute-webhook.ts b/packages/adapters/openclaw/src/server/execute-webhook.ts index 483eb3c0..f76d6729 100644 --- a/packages/adapters/openclaw/src/server/execute-webhook.ts +++ b/packages/adapters/openclaw/src/server/execute-webhook.ts @@ -1,14 +1,18 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { appendWakeText, + appendWakeTextToOpenResponsesInput, buildExecutionState, buildWakeCompatibilityPayload, + isOpenResponsesEndpoint, isTextRequiredResponse, + isWakeCompatibilityRetryableResponse, isWakeCompatibilityEndpoint, readAndLogResponseText, redactForLog, sendJsonRequest, stringifyForLog, + toStringRecord, type OpenClawExecutionState, } from "./execute-common.js"; import { parseOpenClawResponse } from "./parse.js"; @@ -18,12 +22,37 @@ function nonEmpty(value: unknown): string | null { } function buildWebhookBody(input: { + url: string; state: OpenClawExecutionState; context: AdapterExecutionContext["context"]; + configModel: unknown; }): Record { - const { state, context } = input; + const { url, state, context, configModel } = input; const templateText = nonEmpty(state.payloadTemplate.text); const payloadText = templateText ? appendWakeText(templateText, state.wakeText) : state.wakeText; + const isOpenResponses = isOpenResponsesEndpoint(url); + + if (isOpenResponses) { + const openResponsesInput = Object.prototype.hasOwnProperty.call(state.payloadTemplate, "input") + ? appendWakeTextToOpenResponsesInput(state.payloadTemplate.input, state.wakeText) + : payloadText; + + return { + ...state.payloadTemplate, + stream: false, + model: + nonEmpty(state.payloadTemplate.model) ?? + nonEmpty(configModel) ?? + "openclaw", + input: openResponsesInput, + metadata: { + ...toStringRecord(state.payloadTemplate.metadata), + ...state.paperclipEnv, + paperclip_session_key: state.sessionKey, + paperclip_stream_transport: "webhook", + }, + }; + } return { ...state.payloadTemplate, @@ -74,7 +103,16 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): } const headers = { ...state.headers }; - const webhookBody = buildWebhookBody({ state, context }); + if (isOpenResponsesEndpoint(url) && !headers["x-openclaw-session-key"] && !headers["X-OpenClaw-Session-Key"]) { + headers["x-openclaw-session-key"] = state.sessionKey; + } + + const webhookBody = buildWebhookBody({ + url, + state, + context, + configModel: ctx.config.model, + }); const wakeCompatibilityBody = buildWakeCompatibilityPayload(state.wakeText); const preferWakeCompatibilityBody = isWakeCompatibilityEndpoint(url); const initialBody = preferWakeCompatibilityBody ? wakeCompatibilityBody : webhookBody; @@ -110,7 +148,7 @@ export async function executeWebhook(ctx: AdapterExecutionContext, url: string): if (!initialResponse.response.ok) { const canRetryWithWakeCompatibility = - !preferWakeCompatibilityBody && isTextRequiredResponse(initialResponse.responseText); + !preferWakeCompatibilityBody && isWakeCompatibilityRetryableResponse(initialResponse.responseText); if (canRetryWithWakeCompatibility) { await onLog( diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index aa5d4999..e13319ba 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -564,6 +564,40 @@ describe("openclaw adapter execute", () => { expect((body.paperclip as Record).streamTransport).toBe("webhook"); }); + it("uses OpenResponses payload shape for webhook transport against /v1/responses", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/v1/responses", + streamTransport: "webhook", + payloadTemplate: { foo: "bar" }, + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; + expect(body.foo).toBe("bar"); + expect(body.stream).toBe(false); + expect(body.model).toBe("openclaw"); + expect(String(body.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + const metadata = body.metadata as Record; + expect(metadata.PAPERCLIP_RUN_ID).toBe("run-123"); + expect(metadata.paperclip_session_key).toBe("paperclip"); + expect(metadata.paperclip_stream_transport).toBe("webhook"); + expect(body.paperclip).toBeUndefined(); + }); + it("uses wake compatibility payloads for /hooks/wake when transport=webhook", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ ok: true }), { @@ -624,7 +658,53 @@ describe("openclaw adapter execute", () => { expect(fetchMock).toHaveBeenCalledTimes(2); const firstBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as Record; const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; - expect(firstBody.paperclip).toBeTypeOf("object"); + expect(firstBody.model).toBe("openclaw"); + expect(String(firstBody.input ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + expect(secondBody.mode).toBe("now"); + expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); + }); + + it("retries webhook payloads when /v1/responses reports missing string input", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + message: "model: Invalid input: expected string, received undefined", + type: "invalid_request_error", + }, + }), + { + status: 400, + statusText: "Bad Request", + headers: { + "content-type": "application/json", + }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + statusText: "OK", + headers: { + "content-type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute( + buildContext({ + url: "https://agent.example/v1/responses", + streamTransport: "webhook", + }), + ); + + expect(result.exitCode).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body ?? "{}")) as Record; expect(secondBody.mode).toBe("now"); expect(String(secondBody.text ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); });