fix: preserve OpenAI encrypted reasoning replay

This commit is contained in:
Peter Steinberger
2026-04-27 23:53:41 +01:00
parent ea2d95e23e
commit 78d3fce5f9
8 changed files with 294 additions and 51 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -78,7 +78,8 @@ export type OutputItem =
| {
type: "reasoning" | `reasoning.${string}`;
id: string;
content?: string;
content?: unknown;
encrypted_content?: string;
summary?: unknown;
};

View File

@@ -28,6 +28,9 @@ type ReplayableReasoningItem = Extract<InputItem, { type: "reasoning" }>;
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]);
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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<typeof convertMessagesToInputItems>[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<typeof streamFn>[0],
ctx1 as Parameters<typeof streamFn>[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<typeof convertMessagesToInputItems>[0],
tools: [],
};
const stream2 = streamFn(
modelStub as Parameters<typeof streamFn>[0],
ctx2 as Parameters<typeof streamFn>[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<Record<string, unknown>>;
};
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<string, unknown>;
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<string, unknown>;
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 () => {

View File

@@ -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<string, string>;
include?: string[];
reasoning?: {
effort?: "none" | "low" | "medium" | "high" | "xhigh";
summary?: "auto" | "concise" | "detailed";