diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd8031a2e7..226ed3a6cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-runner/replay-history.test.ts b/src/agents/pi-embedded-runner/replay-history.test.ts index c532336c9ca..cd1f8cf6eff 100644 --- a/src/agents/pi-embedded-runner/replay-history.test.ts +++ b/src/agents/pi-embedded-runner/replay-history.test.ts @@ -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 diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/pi-embedded-runner/replay-history.ts index 47343a19c01..813bba802a2 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/pi-embedded-runner/replay-history.ts @@ -59,6 +59,7 @@ type ModelSnapshotEntry = { modelApi?: string | null; modelId?: string; }; +type AssistantReplayMessage = Extract; 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;