mirror of https://github.com/openclaw/openclaw.git
fix: preserve ws reasoning replay (#53856) (thanks @xujingchen1996)
This commit is contained in:
parent
cc359d4c9d
commit
6fd1725a06
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue