From 6fd1725a06369df05dcb02f1f79d68e1d64f1860 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 00:52:31 +0000 Subject: [PATCH] fix: preserve ws reasoning replay (#53856) (thanks @xujingchen1996) --- CHANGELOG.md | 1 + src/agents/openai-ws-connection.ts | 12 ++- src/agents/openai-ws-stream.test.ts | 131 +++++++++++++++++++++++++++- src/agents/openai-ws-stream.ts | 79 +++++++++++++++-- 4 files changed, 213 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7a13e770c2..771ecb2aa02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - CLI/message send: write manual `openclaw message send` deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617. - CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider - Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so `/status` shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana. +- OpenAI/WS: restore reasoning blocks for Responses WebSocket runs and keep reasoning/tool-call replay metadata intact so resumed sessions do not lose or break follow-up reasoning-capable turns. (#53856) Thanks @xujingchen1996. - Agents/errors: surface provider quota/reset details when available, but keep HTML/Cloudflare rate-limit pages on the generic fallback so raw error pages are not shown to users. (#54512) Thanks @bugkill3r. - Claude CLI: switch the bundled Claude CLI backend to `stream-json` output so watchdogs see progress on long runs, and keep session/usage metadata even when Claude finishes with an empty result line. (#49698) Thanks @felear2022. - Claude CLI/MCP: always pass a strict generated `--mcp-config` overlay for background Claude CLI runs, including the empty-server case, so Claude does not inherit ambient user/global MCP servers. (#54961) Thanks @markojak. diff --git a/src/agents/openai-ws-connection.ts b/src/agents/openai-ws-connection.ts index 95479da69c4..a44e3afc832 100644 --- a/src/agents/openai-ws-connection.ts +++ b/src/agents/openai-ws-connection.ts @@ -58,10 +58,10 @@ export type OutputItem = status?: "in_progress" | "completed"; } | { - type: "reasoning"; + type: "reasoning" | `reasoning.${string}`; id: string; content?: string; - summary?: string; + summary?: unknown; }; export interface ResponseCreatedEvent { @@ -198,7 +198,13 @@ export type InputItem = } | { type: "function_call"; id?: string; call_id?: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string } - | { type: "reasoning"; content?: string; encrypted_content?: string; summary?: string } + | { + type: "reasoning"; + id?: string; + content?: string; + encrypted_content?: string; + summary?: string; + } | { type: "item_reference"; id: string }; export type ToolChoice = diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index ad6e6b01616..344a05f4162 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -587,6 +587,35 @@ describe("convertMessagesToInputItems", () => { typeof convertMessagesToInputItems >[0]); expect(items.map((item) => item.type)).toEqual(["reasoning", "message"]); + expect(items[0]).toMatchObject({ type: "reasoning", id: "rs_test" }); + }); + + it("replays reasoning blocks when signature type is reasoning.*", () => { + const msg = { + role: "assistant" as const, + content: [ + { + type: "thinking" as const, + thinking: "internal reasoning...", + thinkingSignature: JSON.stringify({ + type: "reasoning.summary", + id: "rs_summary", + }), + }, + { type: "text" as const, text: "Here is my answer." }, + ], + stopReason: "stop", + api: "openai-responses", + provider: "openai", + model: "gpt-5.2", + 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_summary" }); }); it("returns empty array for empty messages", () => { @@ -625,7 +654,7 @@ describe("buildAssistantMessageFromResponse", () => { }; expect(tc).toBeDefined(); expect(tc.name).toBe("exec"); - expect(tc.id).toBe("call_abc"); + expect(tc.id).toBe("call_abc|item_2"); expect(tc.arguments).toEqual({ arg: "value" }); }); @@ -675,6 +704,106 @@ describe("buildAssistantMessageFromResponse", () => { expect(msg.phase).toBe("final_answer"); expect(msg.content[0]?.text).toBe("Final answer"); }); + + it("maps reasoning output items to thinking blocks with signature", () => { + const response = { + id: "resp_reasoning", + object: "response", + created_at: Date.now(), + status: "completed", + model: "gpt-5.2", + output: [ + { + type: "reasoning", + id: "rs_123", + summary: [{ text: "Plan step A" }, { text: "Plan step B" }], + }, + { + type: "message", + id: "item_1", + role: "assistant", + content: [{ type: "output_text", text: "Final answer" }], + }, + ], + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + } as unknown as ResponseObject; + const msg = buildAssistantMessageFromResponse(response, modelInfo); + const thinkingBlock = msg.content.find((c) => c.type === "thinking") as + | { type: "thinking"; thinking: string; thinkingSignature?: string } + | undefined; + expect(thinkingBlock?.thinking).toBe("Plan step A\nPlan step B"); + expect(thinkingBlock?.thinkingSignature).toBe( + JSON.stringify({ id: "rs_123", type: "reasoning" }), + ); + }); + + it("maps reasoning.* output items to thinking blocks", () => { + const response = { + id: "resp_reasoning_kind", + object: "response", + created_at: Date.now(), + status: "completed", + model: "gpt-5.2", + output: [ + { + type: "reasoning.summary", + id: "rs_456", + content: "Derived hidden reasoning", + }, + ], + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + } as unknown as ResponseObject; + const msg = buildAssistantMessageFromResponse(response, modelInfo); + const thinkingBlock = msg.content[0] as + | { type: "thinking"; thinking: string; thinkingSignature?: string } + | 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" }), + ); + }); + + it("preserves function call item ids for replay when reasoning is present", () => { + const response = { + id: "resp_tool_reasoning", + object: "response", + created_at: Date.now(), + status: "completed", + model: "gpt-5.2", + output: [ + { + type: "reasoning", + id: "rs_tool", + content: "Thinking before tool call", + }, + { + type: "function_call", + id: "fc_tool", + call_id: "call_tool", + name: "exec", + arguments: '{"arg":"value"}', + }, + ], + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + } as ResponseObject; + + const assistant = buildAssistantMessageFromResponse(response, modelInfo); + const toolCall = assistant.content.find((item) => item.type === "toolCall") as + | { type: "toolCall"; id: string } + | undefined; + expect(toolCall?.id).toBe("call_tool|fc_tool"); + + const replayItems = convertMessagesToInputItems([assistant] as Parameters< + typeof convertMessagesToInputItems + >[0]); + expect(replayItems.map((item) => item.type)).toEqual(["reasoning", "function_call"]); + expect(replayItems[1]).toMatchObject({ + type: "function_call", + call_id: "call_tool", + id: "fc_tool", + }); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index 5936a7fa442..38b086b45d2 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -31,8 +31,6 @@ import type { Context, Message, StopReason, - TextContent, - ToolCall, } from "@mariozechner/pi-ai"; import { OpenAIWebSocketManager, @@ -330,21 +328,33 @@ function contentToOpenAIParts(content: unknown, modelOverride?: ReplayModelInfo) return parts; } +function isReplayableReasoningType(value: unknown): value is "reasoning" | `reasoning.${string}` { + return typeof value === "string" && (value === "reasoning" || value.startsWith("reasoning.")); +} + +function toReplayableReasoningId(value: unknown): string | null { + const id = toNonEmptyString(value); + return id && id.startsWith("rs_") ? id : null; +} + function parseReasoningItem(value: unknown): Extract | null { if (!value || typeof value !== "object") { return null; } const record = value as { type?: unknown; + id?: unknown; content?: unknown; encrypted_content?: unknown; summary?: unknown; }; - if (record.type !== "reasoning") { + if (!isReplayableReasoningType(record.type)) { return null; } + const reasoningId = toReplayableReasoningId(record.id); return { type: "reasoning", + ...(reasoningId ? { id: reasoningId } : {}), ...(typeof record.content === "string" ? { content: record.content } : {}), ...(typeof record.encrypted_content === "string" ? { encrypted_content: record.encrypted_content } @@ -364,6 +374,41 @@ function parseThinkingSignature(value: unknown): Extract { + if (typeof item === "string") { + return item.trim(); + } + if (!item || typeof item !== "object") { + return ""; + } + const record = item as { text?: unknown }; + return typeof record.text === "string" ? record.text.trim() : ""; + }) + .filter(Boolean) + .join("\n") + .trim(); +} + +function extractResponseReasoningText(item: unknown): string { + if (!item || typeof item !== "object") { + return ""; + } + const record = item as { summary?: unknown; content?: unknown }; + const summaryText = extractReasoningSummaryText(record.summary); + if (summaryText) { + return summaryText; + } + return typeof record.content === "string" ? record.content.trim() : ""; +} + /** Convert pi-ai tool array to OpenAI FunctionToolDefinition[]. */ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[] { if (!tools || tools.length === 0) { @@ -535,7 +580,7 @@ export function buildAssistantMessageFromResponse( response: ResponseObject, modelInfo: { api: string; provider: string; id: string }, ): AssistantMessage { - const content: (TextContent | ToolCall)[] = []; + const content: AssistantMessage["content"] = []; let assistantPhase: OpenAIResponsesAssistantPhase | undefined; for (const item of response.output ?? []) { @@ -561,9 +606,11 @@ export function buildAssistantMessageFromResponse( if (!toolName) { continue; } + const callId = toNonEmptyString(item.call_id); + const itemId = toNonEmptyString(item.id); content.push({ type: "toolCall", - id: toNonEmptyString(item.call_id) ?? `call_${randomUUID()}`, + id: callId ? (itemId ? `${callId}|${itemId}` : callId) : `call_${randomUUID()}`, name: toolName, arguments: (() => { try { @@ -573,8 +620,28 @@ export function buildAssistantMessageFromResponse( } })(), }); + } else { + if (!isReplayableReasoningType(item.type)) { + continue; + } + const reasoning = extractResponseReasoningText(item); + if (!reasoning) { + continue; + } + const reasoningId = toReplayableReasoningId(item.id); + content.push({ + type: "thinking", + thinking: reasoning, + ...(reasoningId + ? { + thinkingSignature: JSON.stringify({ + id: reasoningId, + type: item.type, + }), + } + : {}), + } as AssistantMessage["content"][number]); } - // "reasoning" items are informational only; skip. } const hasToolCalls = content.some((c) => c.type === "toolCall");