From 9061d1e4c3ee05023feac5a5ad9e6143aa112203 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:27:57 -0700 Subject: [PATCH] fix(agents): preserve string user content when merging turns Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com> Co-authored-by: Vincent Koc --- ...pi-embedded-helpers.validate-turns.test.ts | 20 +++++++++++++++++++ src/agents/pi-embedded-helpers/turns.ts | 18 +++++++++++++++-- ...ed-runner.sanitize-session-history.test.ts | 12 ++--------- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/agents/pi-embedded-helpers.validate-turns.test.ts b/src/agents/pi-embedded-helpers.validate-turns.test.ts index 9e20ad0d48f..3847d5322dd 100644 --- a/src/agents/pi-embedded-helpers.validate-turns.test.ts +++ b/src/agents/pi-embedded-helpers.validate-turns.test.ts @@ -357,6 +357,26 @@ describe("mergeConsecutiveUserTurns", () => { expect(merged.timestamp).toBe(2000); }); + it("preserves string content while merging content", () => { + const previous = { + role: "user", + content: "before", + timestamp: 1000, + } as Extract; + const current = { + role: "user", + content: "after", + timestamp: 2000, + } as Extract; + + const merged = mergeConsecutiveUserTurns(previous, current); + + expect(merged.content).toEqual([ + { type: "text", text: "before" }, + { type: "text", text: "after" }, + ]); + }); + it("backfills timestamp from earlier message when missing", () => { const previous = { role: "user", diff --git a/src/agents/pi-embedded-helpers/turns.ts b/src/agents/pi-embedded-helpers/turns.ts index c64efbe4ff9..5c3871f4984 100644 --- a/src/agents/pi-embedded-helpers/turns.ts +++ b/src/agents/pi-embedded-helpers/turns.ts @@ -10,6 +10,10 @@ type AnthropicContentBlock = { toolUseId?: string; toolCallId?: string; }; +type UserContentBlock = Extract< + Extract["content"], + readonly unknown[] +>[number]; function isToolCallBlock(block: AnthropicContentBlock): boolean { return block.type === "toolUse" || block.type === "toolCall" || block.type === "functionCall"; @@ -350,8 +354,8 @@ export function mergeConsecutiveUserTurns( current: Extract, ): Extract { const mergedContent = [ - ...(Array.isArray(previous.content) ? previous.content : []), - ...(Array.isArray(current.content) ? current.content : []), + ...normalizeUserContentForMerge(previous.content), + ...normalizeUserContentForMerge(current.content), ]; return { @@ -361,6 +365,16 @@ export function mergeConsecutiveUserTurns( }; } +function normalizeUserContentForMerge(content: unknown): UserContentBlock[] { + if (Array.isArray(content)) { + return content as UserContentBlock[]; + } + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + return []; +} + /** * Validates and fixes conversation turn sequences for Anthropic API. * Anthropic requires strict alternating user→assistant pattern. diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 85d8a6e3309..1e0c6653360 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -1141,17 +1141,9 @@ describe("sanitizeSessionHistory", () => { "```", ].join("\n"); const messages = castAgentMessages([ - { - role: "user", - content: [{ type: "text", text: "First" }], - timestamp: nextTimestamp(), - }, + makeUserMessage("First"), makeAssistantMessage([{ type: "text", text: metadataOnlyText }]), - { - role: "user", - content: [{ type: "text", text: "Second" }], - timestamp: nextTimestamp(), - }, + makeUserMessage("Second"), ]); const sanitized = await sanitizeSessionHistory({