fix: normalize malformed assistant replay content (#82748)

This commit is contained in:
Peter Steinberger
2026-05-17 02:53:18 +01:00
parent ad8ae05f37
commit ab595dec0f
3 changed files with 16 additions and 5 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- System prompts: clarify MEMORY guidance over generic TTS hints in the embedded speech-core/system-prompt scaffolding so agents prefer memory-store usage over speech defaults. Fixes #81930. Thanks @giodl73-repo.
- Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos.
- Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns.
- Agents/replay: normalize malformed assistant replay content before transport conversion while preserving empty-stop replay repair, so bad provider history no longer crashes with non-iterable content. Fixes #43795. (#82748) Thanks @IWhatsskill.
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
- CLI/web: resolve provider-scoped web search/fetch SecretRefs for `infer web ... --provider ...` while leaving unrelated plugin secrets untouched. Fixes #82621. Thanks @leno23.

View File

@@ -126,6 +126,15 @@ describe("normalizeAssistantReplayContent", () => {
expect(repaired.content).toEqual([{ type: "text", text: FALLBACK_TEXT }]);
});
it("converts mid-turn zero-usage null stop turns to a replay sentinel", () => {
const falseSuccessStop = bedrockAssistant(null, "stop");
const messages = [userMessage("hello"), falseSuccessStop, userMessage("retry")];
const out = normalizeAssistantReplayContent(messages);
expect(out).not.toBe(messages);
const repaired = out[1] as AgentMessage & { content: { type: string; text: string }[] };
expect(repaired.content).toEqual([{ type: "text", text: FALLBACK_TEXT }]);
});
it("preserves empty content with non-error stopReasons (toolUse, length) untouched", () => {
// Boundary lock: only `stopReason:"error"` should trip the sentinel
// substitution. `toolUse` and `length` are reachable in practice when a

View File

@@ -59,6 +59,7 @@ type ModelSnapshotEntry = {
modelApi?: string | null;
modelId?: string;
};
type AssistantReplayMessage = Extract<AgentMessage, { role: "assistant" }>;
type ProviderReplayHookParams = {
config?: OpenClawConfig;
@@ -355,7 +356,7 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent
touched = true;
continue;
}
let assistantMessage = message;
let assistantMessage: AssistantReplayMessage = message;
let replayContent = (message as { content?: unknown }).content;
if (typeof replayContent === "string") {
const normalized = normalizeAssistantReplayTextContent(message, replayContent);
@@ -368,7 +369,7 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent
if (!Array.isArray(replayContent)) {
replayContent =
replayContent != null && typeof replayContent === "object" ? [replayContent] : [];
assistantMessage = { ...message, content: replayContent } as AgentMessage;
assistantMessage = { ...message, content: replayContent } as AssistantReplayMessage;
touched = true;
}
if (Array.isArray(replayContent)) {
@@ -398,10 +399,10 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent
// or completion and no content. Leaving other non-error empty-content
// turns untouched preserves silent-reply semantics on every other code
// path.
const stopReason = (message as { stopReason?: unknown }).stopReason;
if (stopReason === "error" || isZeroUsageEmptyStopAssistantTurn(message)) {
const stopReason = (assistantMessage as { stopReason?: unknown }).stopReason;
if (stopReason === "error" || isZeroUsageEmptyStopAssistantTurn(assistantMessage)) {
out.push({
...message,
...assistantMessage,
content: [{ type: "text", text: STREAM_ERROR_FALLBACK_TEXT }],
});
touched = true;