fix: preserve ws reasoning replay (#53856) (thanks @xujingchen1996)

This commit is contained in:
Peter Steinberger 2026-03-27 00:52:31 +00:00
parent cc359d4c9d
commit 6fd1725a06
4 changed files with 213 additions and 10 deletions

View File

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

View File

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

View File

@ -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",
});
});
});
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -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<InputItem, { type: "reasoning" }> | 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<InputItem, { type: "rea
}
}
function extractReasoningSummaryText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (!Array.isArray(value)) {
return "";
}
return value
.map((item) => {
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");