From 78d3fce5f9b98196b671892cbfb010adc42df457 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 23:53:41 +0100 Subject: [PATCH] fix: preserve OpenAI encrypted reasoning replay --- CHANGELOG.md | 1 + docs/reference/transcript-hygiene.md | 1 + src/agents/openai-ws-connection.ts | 3 +- src/agents/openai-ws-message-conversion.ts | 92 +++++---- src/agents/openai-ws-request.ts | 3 + src/agents/openai-ws-stream.e2e.test.ts | 27 ++- src/agents/openai-ws-stream.test.ts | 213 ++++++++++++++++++++- src/agents/openai-ws-types.ts | 5 +- 8 files changed, 294 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88922dae625..ae5efb0f2af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Daemon/service: only emit hard-coded version-manager paths such as `~/.volta/bin`, `~/.asdf/shims`, `~/.bun/bin`, and fnm/pnpm fallbacks into gateway and node service PATHs when the directories exist, so `openclaw doctor` no longer flags `gateway.path.non-minimal` against a PATH the daemon just wrote. Env-driven roots and stable user-bin dirs remain unconditional. Fixes #71944; carries forward #71964. Thanks @Sanjays2402. - CLI/startup: disable Node's module compile cache automatically for live source-checkout launchers so in-place `pnpm build` updates are visible to the next `openclaw` CLI invocation. Fixes #73037. Thanks @LouisGameDev. - Agents/group chat: move `NO_REPLY` mechanics into channel-aware direct/group prompts and suppress the duplicate generic silent-reply section for auto-reply runs, so always-on group agents get one consistent stay-silent instruction. Thanks @vincentkoc. +- Providers/OpenAI: preserve encrypted empty-summary Responses reasoning items in WebSocket replay and request `reasoning.encrypted_content` on reasoning turns so GPT-5.4/GPT-5.5 sessions do not lose required `rs_*` state beside `msg_*` items. Fixes #73053. Thanks @odb36777. - Channels/commands: make generated `/dock-*` commands switch the active session reply route through `session.identityLinks` instead of falling through to normal chat. Fixes #69206; carries forward #73033. Thanks @clawbones and @michaelatamuk. - Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar. - Gateway/startup: keep primary-model startup prewarm on scoped metadata preparation, let native approval bootstraps retry outside channel startup, and skip the global hook runner when no `gateway_start` hook is registered, so clean post-ready sidecar work stays off the critical path. Refs #72846. Thanks @RayWoo, @livekm0309, and @mrz1836. diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 198167d3da8..922c75eae63 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -112,6 +112,7 @@ external end-user instructions. - Image sanitization only. - Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts, and drop replayable OpenAI reasoning after a model route switch. +- Preserve replayable OpenAI Responses reasoning item payloads, including encrypted empty-summary items, so manual/WebSocket replay keeps required `rs_*` state paired with assistant output items. - No tool call id sanitization. - Tool result pairing repair may move real matched outputs and synthesize Codex-style `aborted` outputs for missing tool calls. - No turn validation or reordering. diff --git a/src/agents/openai-ws-connection.ts b/src/agents/openai-ws-connection.ts index d911945f94c..b181f85fbed 100644 --- a/src/agents/openai-ws-connection.ts +++ b/src/agents/openai-ws-connection.ts @@ -78,7 +78,8 @@ export type OutputItem = | { type: "reasoning" | `reasoning.${string}`; id: string; - content?: string; + content?: unknown; + encrypted_content?: string; summary?: unknown; }; diff --git a/src/agents/openai-ws-message-conversion.ts b/src/agents/openai-ws-message-conversion.ts index 10d749c037b..e4864ce35bc 100644 --- a/src/agents/openai-ws-message-conversion.ts +++ b/src/agents/openai-ws-message-conversion.ts @@ -28,6 +28,9 @@ type ReplayableReasoningItem = Extract; type ReplayableReasoningSignature = { type: "reasoning" | `reasoning.${string}`; id?: string; + content?: unknown; + encrypted_content?: string; + summary?: unknown; }; type ToolCallReplayId = { callId: string; itemId?: string }; export type PlannedTurnInput = { @@ -166,26 +169,10 @@ function toReplayableReasoningId(value: unknown): string | null { return id && id.startsWith("rs_") ? id : null; } -function toReasoningSignature(value: unknown): ReplayableReasoningSignature | null { - if (!value || typeof value !== "object") { - return null; - } - const record = value as { type?: unknown; id?: unknown }; - if (!isReplayableReasoningType(record.type)) { - return null; - } - const reasoningId = toReplayableReasoningId(record.id); - return { - type: record.type, - ...(reasoningId ? { id: reasoningId } : {}), - }; -} - -function encodeThinkingSignature(signature: ReplayableReasoningSignature): string { - return JSON.stringify(signature); -} - -function parseReasoningItem(value: unknown): ReplayableReasoningItem | null { +function toReasoningSignature( + value: unknown, + options?: { requireReplayableId?: boolean }, +): ReplayableReasoningSignature | null { if (!value || typeof value !== "object") { return null; } @@ -200,14 +187,37 @@ function parseReasoningItem(value: unknown): ReplayableReasoningItem | null { return null; } const reasoningId = toReplayableReasoningId(record.id); + if (options?.requireReplayableId && !reasoningId) { + return null; + } return { - type: "reasoning", + type: record.type, ...(reasoningId ? { id: reasoningId } : {}), - ...(typeof record.content === "string" ? { content: record.content } : {}), + ...(record.content !== undefined ? { content: record.content } : {}), ...(typeof record.encrypted_content === "string" ? { encrypted_content: record.encrypted_content } : {}), - ...(typeof record.summary === "string" ? { summary: record.summary } : {}), + ...(record.summary !== undefined ? { summary: record.summary } : {}), + }; +} + +function encodeThinkingSignature(signature: ReplayableReasoningSignature): string { + return JSON.stringify(signature); +} + +function parseReasoningItem(value: unknown): ReplayableReasoningItem | null { + const signature = toReasoningSignature(value); + if (!signature) { + return null; + } + return { + type: "reasoning", + ...(signature.id ? { id: signature.id } : {}), + ...(signature.content !== undefined ? { content: signature.content } : {}), + ...(signature.encrypted_content !== undefined + ? { encrypted_content: signature.encrypted_content } + : {}), + ...(signature.summary !== undefined ? { summary: signature.summary } : {}), }; } @@ -216,8 +226,7 @@ function parseThinkingSignature(value: unknown): ReplayableReasoningItem | null return null; } try { - const signature = toReasoningSignature(JSON.parse(value)); - return signature ? parseReasoningItem(signature) : null; + return parseReasoningItem(JSON.parse(value)); } catch { return null; } @@ -271,7 +280,25 @@ function extractResponseReasoningText(item: unknown): string { if (summaryText) { return summaryText; } - return normalizeOptionalString(record.content) ?? ""; + if (typeof record.content === "string") { + return normalizeOptionalString(record.content) ?? ""; + } + if (Array.isArray(record.content)) { + return record.content + .map((part) => { + if (typeof part === "string") { + return part.trim(); + } + if (!part || typeof part !== "object") { + return ""; + } + return normalizeOptionalString((part as { text?: unknown }).text) ?? ""; + }) + .filter(Boolean) + .join("\n") + .trim(); + } + return ""; } export function convertTools( @@ -573,21 +600,16 @@ export function buildAssistantMessageFromResponse( if (!isReplayableReasoningType(item.type)) { continue; } + const reasoningSignature = toReasoningSignature(item, { requireReplayableId: true }); const reasoning = extractResponseReasoningText(item); - if (!reasoning) { + if (!reasoning && !reasoningSignature) { continue; } - const reasoningId = toReplayableReasoningId(item.id); content.push({ type: "thinking", thinking: reasoning, - ...(reasoningId - ? { - thinkingSignature: encodeThinkingSignature({ - id: reasoningId, - type: item.type, - }), - } + ...(reasoningSignature + ? { thinkingSignature: encodeThinkingSignature(reasoningSignature) } : {}), } as AssistantMessage["content"][number]); } diff --git a/src/agents/openai-ws-request.ts b/src/agents/openai-ws-request.ts index d8f911f474f..77ec086a759 100644 --- a/src/agents/openai-ws-request.ts +++ b/src/agents/openai-ws-request.ts @@ -169,6 +169,9 @@ export function buildOpenAIWebSocketResponseCreatePayload(params: { reasoning.summary = streamOpts.reasoningSummary; } extraParams.reasoning = reasoning; + if (reasoning.effort && reasoning.effort !== "none") { + extraParams.include = ["reasoning.encrypted_content"]; + } } const textVerbosity = resolveOpenAITextVerbosity( diff --git a/src/agents/openai-ws-stream.e2e.test.ts b/src/agents/openai-ws-stream.e2e.test.ts index 21fdd28420c..51341d92fbf 100644 --- a/src/agents/openai-ws-stream.e2e.test.ts +++ b/src/agents/openai-ws-stream.e2e.test.ts @@ -27,6 +27,7 @@ import type { OutputItem, ResponseObject } from "./openai-ws-connection.js"; const API_KEY = process.env.OPENAI_API_KEY; const LIVE = isLiveTestEnabled(["OPENAI_LIVE_TEST"]) && !!API_KEY; +const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_MODEL || "gpt-5.4"; const testFn = LIVE ? it : it.skip; type OpenAIWsStreamModule = typeof import("./openai-ws-stream.js"); @@ -39,8 +40,8 @@ let openAIWsConnectionModule: OpenAIWsConnectionModule; const model = { api: "openai-responses" as const, provider: "openai", - id: "gpt-5.4", - name: "gpt-5.4", + id: LIVE_MODEL_ID, + name: LIVE_MODEL_ID, contextWindow: 128_000, maxTokens: 4_096, reasoning: true, @@ -185,7 +186,13 @@ function parseReasoningSignature(value: string | undefined) { return null; } try { - return JSON.parse(value) as { id?: unknown; type?: unknown }; + return JSON.parse(value) as { + id?: unknown; + type?: unknown; + content?: unknown; + encrypted_content?: unknown; + summary?: unknown; + }; } catch { return null; } @@ -220,9 +227,19 @@ function extractReasoningText(item: { summary?: unknown; content?: unknown }): s } function toExpectedReasoningSignature(item: { id?: string; type: string }) { + const record = item as { + content?: unknown; + encrypted_content?: unknown; + summary?: unknown; + }; return { type: item.type, ...(typeof item.id === "string" && item.id.startsWith("rs_") ? { id: item.id } : {}), + ...(record.content !== undefined ? { content: record.content } : {}), + ...(typeof record.encrypted_content === "string" + ? { encrypted_content: record.encrypted_content } + : {}), + ...(record.summary !== undefined ? { summary: record.summary } : {}), }; } @@ -372,7 +389,7 @@ describe("OpenAI WebSocket e2e", () => { item.type === "reasoning" || item.type.startsWith("reasoning."), ); const replayableReasoningItems = rawReasoningItems.filter( - (item) => extractReasoningText(item).length > 0, + (item) => typeof item.id === "string" && item.id.startsWith("rs_"), ); const thinkingBlocks = extractThinkingBlocks(firstDone); expect(thinkingBlocks).toHaveLength(replayableReasoningItems.length); @@ -481,7 +498,7 @@ describe("OpenAI WebSocket e2e", () => { stopReason: "stop", api: "openai-responses", provider: "openai", - model: "gpt-5.4", + model: LIVE_MODEL_ID, usage: { input: 0, output: 0, diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index 03ff3a0fb9a..b680cc20372 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -1067,7 +1067,42 @@ describe("convertMessagesToInputItems", () => { typeof convertMessagesToInputItems >[0]); expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]); - expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_test" }); + expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_test", summary: [] }); + }); + + it("replays encrypted reasoning content from thinking signatures", () => { + const msg = { + role: "assistant" as const, + content: [ + { + type: "thinking" as const, + thinking: "", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id: "rs_encrypted", + encrypted_content: "encrypted-payload", + summary: [], + }), + }, + { type: "text" as const, text: "Here is my answer." }, + ], + stopReason: "stop", + api: "openai-responses", + provider: "openai", + model: "gpt-5.4", + usage: {}, + timestamp: 0, + }; + const items = convertMessagesToInputItems([msg] as Parameters< + typeof convertMessagesToInputItems + >[0]); + expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]); + expect(items[0]).toMatchObject({ + type: "reasoning", + id: "rs_encrypted", + encrypted_content: "encrypted-payload", + summary: [], + }); }); it("replays reasoning blocks when signature type is reasoning.*", () => { @@ -1440,7 +1475,11 @@ describe("buildAssistantMessageFromResponse", () => { | undefined; expect(thinkingBlock?.thinking).toBe("Plan step A\nPlan step B"); expect(thinkingBlock?.thinkingSignature).toBe( - JSON.stringify({ id: "rs_123", type: "reasoning" }), + JSON.stringify({ + type: "reasoning", + id: "rs_123", + summary: [{ text: "Plan step A" }, { text: "Plan step B" }], + }), ); }); @@ -1466,9 +1505,11 @@ describe("buildAssistantMessageFromResponse", () => { | undefined; expect(thinkingBlock?.type).toBe("thinking"); expect(thinkingBlock?.thinking).toBe("Derived hidden reasoning"); - expect(thinkingBlock?.thinkingSignature).toBe( - JSON.stringify({ id: "rs_456", type: "reasoning.summary" }), - ); + expect(JSON.parse(thinkingBlock?.thinkingSignature ?? "{}")).toEqual({ + type: "reasoning.summary", + id: "rs_456", + content: "Derived hidden reasoning", + }); }); it("prefers reasoning summary text over fallback content and preserves item order", () => { @@ -1502,9 +1543,12 @@ describe("buildAssistantMessageFromResponse", () => { | { type: "thinking"; thinking: string; thinkingSignature?: string } | undefined; expect(thinkingBlock?.thinking).toBe("Plan A\nPlan B"); - expect(thinkingBlock?.thinkingSignature).toBe( - JSON.stringify({ id: "rs_789", type: "reasoning.summary" }), - ); + expect(JSON.parse(thinkingBlock?.thinkingSignature ?? "{}")).toEqual({ + type: "reasoning.summary", + id: "rs_789", + content: "hidden fallback content", + summary: ["Plan A", { text: "Plan B" }, { nope: true }], + }); }); it("drops invalid reasoning ids from thinking signatures while preserving the visible block", () => { @@ -1528,6 +1572,57 @@ describe("buildAssistantMessageFromResponse", () => { expect(msg.content).toEqual([{ type: "thinking", thinking: "Hidden reasoning" }]); }); + it("preserves encrypted-only reasoning items with empty visible thinking", () => { + const response = { + id: "resp_encrypted_reasoning", + object: "response", + created_at: Date.now(), + status: "completed", + model: "gpt-5.4", + output: [ + { + type: "reasoning", + id: "rs_encrypted_empty", + encrypted_content: "encrypted-payload", + summary: [], + }, + { + type: "message", + id: "msg_encrypted_empty", + role: "assistant", + content: [{ type: "output_text", text: "NO_REPLY" }], + }, + ], + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + } as unknown as ResponseObject; + + const msg = buildAssistantMessageFromResponse(response, modelInfo); + expect(msg.content.map((block) => block.type)).toEqual(["thinking", "text"]); + const thinkingBlock = msg.content[0] as { + type: "thinking"; + thinking: string; + thinkingSignature?: string; + }; + expect(thinkingBlock.thinking).toBe(""); + expect(JSON.parse(thinkingBlock.thinkingSignature ?? "{}")).toEqual({ + encrypted_content: "encrypted-payload", + id: "rs_encrypted_empty", + summary: [], + type: "reasoning", + }); + + const replayItems = convertMessagesToInputItems([msg] as Parameters< + typeof convertMessagesToInputItems + >[0]); + expect(replayItems.map((item) => item.type)).toEqual(["reasoning", "message"]); + expect(replayItems[0]).toMatchObject({ + type: "reasoning", + id: "rs_encrypted_empty", + encrypted_content: "encrypted-payload", + summary: [], + }); + }); + it("preserves function call item ids for replay when reasoning is present", () => { const response = { id: "resp_tool_reasoning", @@ -1824,6 +1919,7 @@ describe("createOpenAIWebSocketStreamFn", () => { releaseWsSession("sess-fallback"); releaseWsSession("sess-boundary-http-fallback"); releaseWsSession("sess-full-context-replay"); + releaseWsSession("sess-encrypted-full-context-replay"); releaseWsSession("sess-incremental"); releaseWsSession("sess-full"); releaseWsSession("sess-onpayload"); @@ -3030,6 +3126,105 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(sent2.input).toEqual([]); }); + it("replays encrypted-only reasoning when websocket must send full context", async () => { + const sessionId = "sess-encrypted-full-context-replay"; + const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); + + const ctx1 = { + systemPrompt: "You are helpful.", + messages: [userMsg("Run ls")] as Parameters[0], + tools: [], + }; + const turn1Response = { + id: "resp_turn1_encrypted_reasoning", + object: "response", + created_at: Date.now(), + status: "completed", + model: "gpt-5.4", + output: [ + { + type: "reasoning", + id: "rs_turn1_encrypted", + encrypted_content: "encrypted-payload", + summary: [], + }, + { + type: "function_call", + id: "fc_turn1", + call_id: "call_turn1", + name: "exec", + arguments: '{"cmd":"ls"}', + }, + ], + usage: { input_tokens: 12, output_tokens: 8, total_tokens: 20 }, + } as ResponseObject; + + const stream1 = streamFn( + modelStub as Parameters[0], + ctx1 as Parameters[1], + ); + const done1 = (async () => { + for await (const _ of await resolveStream(stream1)) { + /* consume */ + } + })(); + + await new Promise((r) => setImmediate(r)); + const manager = MockManager.lastInstance!; + manager.simulateEvent({ type: "response.completed", response: turn1Response }); + await done1; + + const ctx2 = { + systemPrompt: "You are helpful. Use the updated instruction.", + messages: [ + userMsg("Run ls"), + buildAssistantMessageFromResponse(turn1Response, modelStub), + toolResultMsg("call_turn1|fc_turn1", "TOOL_OK"), + ] as Parameters[0], + tools: [], + }; + + const stream2 = streamFn( + modelStub as Parameters[0], + ctx2 as Parameters[1], + ); + const done2 = (async () => { + for await (const _ of await resolveStream(stream2)) { + /* consume */ + } + })(); + + await new Promise((r) => setImmediate(r)); + manager.simulateEvent({ + type: "response.completed", + response: makeResponseObject("resp_turn2", "Done"), + }); + await done2; + + const sent2 = manager.sentEvents[1] as { + previous_response_id?: string; + input: Array>; + }; + expect(sent2.previous_response_id).toBeUndefined(); + expect(sent2.input).toEqual([ + { type: "message", role: "user", content: "Run ls" }, + { + type: "reasoning", + id: "rs_turn1_encrypted", + encrypted_content: "encrypted-payload", + summary: [], + }, + { + type: "function_call", + id: "fc_turn1", + call_id: "call_turn1", + name: "exec", + arguments: '{"cmd":"ls"}', + }, + { type: "function_call_output", call_id: "call_turn1", output: "TOOL_OK" }, + ]); + }); + it("sends instructions (system prompt) in each request", async () => { const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-tools"); const ctx = { @@ -3353,6 +3548,7 @@ describe("createOpenAIWebSocketStreamFn", () => { const sent = MockManager.lastInstance!.sentEvents[0] as Record; expect(sent.type).toBe("response.create"); expect(sent.reasoning).toEqual({ effort: "high", summary: "auto" }); + expect(sent.include).toEqual(["reasoning.encrypted_content"]); }); it("defaults response.create reasoning effort to high for reasoning models", async () => { @@ -3382,6 +3578,7 @@ describe("createOpenAIWebSocketStreamFn", () => { const sent = MockManager.lastInstance!.sentEvents[0] as Record; expect(sent.type).toBe("response.create"); expect(sent.reasoning).toEqual({ effort: "high" }); + expect(sent.include).toEqual(["reasoning.encrypted_content"]); }); it("forwards shared reasoning to response.create reasoning effort", async () => { diff --git a/src/agents/openai-ws-types.ts b/src/agents/openai-ws-types.ts index 115b54efea7..224639c44e0 100644 --- a/src/agents/openai-ws-types.ts +++ b/src/agents/openai-ws-types.ts @@ -24,9 +24,9 @@ export type InputItem = | { type: "reasoning"; id?: string; - content?: string; + content?: unknown; encrypted_content?: string; - summary?: string; + summary?: unknown; } | { type: "item_reference"; id: string }; @@ -59,6 +59,7 @@ export interface ResponseCreateEvent { temperature?: number; top_p?: number; metadata?: Record; + include?: string[]; reasoning?: { effort?: "none" | "low" | "medium" | "high" | "xhigh"; summary?: "auto" | "concise" | "detailed";