From 3bc6eebacf3a6497eb82a66e02ca8644cfa3dde2 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 22 Oct 2026 12:43:26 +0200 Subject: [PATCH] fix(ai): hold Anthropic system updates past dropped assistant messages A mid-conversation system message was placed before checking whether the next assistant message converts to any blocks. When it did not (empty text, or another model's reasoning-only reply), the update landed before a user message and Claude rejected the request with a 400. closes #11091 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/api/anthropic-messages.ts | 6 ++- .../ai/test/transcript-tool-changes.test.ts | 46 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0c9fcee1d..74aa15c4d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Claude models that accept mid-conversation system messages rejecting a session with a 400 (`role 'system' must precede an 'assistant' message`) when a system update was followed by an assistant reply with nothing to send to Anthropic, such as an empty reply or another model's reasoning-only reply ([#11091](https://github.com/earendil-works/pi/issues/11091)) + ## [1.4.0] - 2026-10-21 ### Breaking Changes diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 761ccd1e9..b43b11c0e 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1375,7 +1375,9 @@ function convertMessages( const params: MessageParam[] = []; const assistantLevels = new Map(); // Later system messages are held back and emitted directly before the next assistant - // message (or at the end of the transcript). Anthropic requires `tool_result` blocks to + // message that is sent (or at the end of the transcript). Assistant messages that convert to + // no blocks are dropped, so holding past them keeps an update from landing before a user + // message (#11091). Anthropic requires `tool_result` blocks to // immediately follow their `tool_use`, so a system message between them is rejected; this // also mirrors where the managed-effort system messages are inserted. As a result an // update placed before a user message in the transcript lands after it on the wire. @@ -1442,7 +1444,6 @@ function convertMessages( }); } } else if (msg.role === "assistant") { - flushPendingSystemMessages(); const blocks: ContentBlockParam[] = []; for (const block of msg.content) { @@ -1497,6 +1498,7 @@ function convertMessages( } } if (blocks.length === 0) continue; + flushPendingSystemMessages(); const messageIndex = params.length; params.push({ role: "assistant", diff --git a/packages/ai/test/transcript-tool-changes.test.ts b/packages/ai/test/transcript-tool-changes.test.ts index 03127cd24..caaa7fe12 100644 --- a/packages/ai/test/transcript-tool-changes.test.ts +++ b/packages/ai/test/transcript-tool-changes.test.ts @@ -1,7 +1,7 @@ import { Type } from "typebox"; import { describe, expect, test } from "vitest"; import { getModel, streamSimple } from "../src/compat.ts"; -import type { Api, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts"; +import type { Api, AssistantMessage, Context, Message, Model, SystemMessage, Tool } from "../src/types.ts"; import { resolveTranscriptTools } from "../src/utils/transcript.ts"; class PayloadCaptured extends Error {} @@ -244,6 +244,50 @@ describe("transcript system messages", () => { expect(payload.messages.at(-1)?.content.map((block) => block.type)).toEqual(["text"]); }); + // #11091 + test("holds an Anthropic update past an assistant message that converts to no blocks", async () => { + const reply = (text: string, provider: string, api: Api, model: string): AssistantMessage => ({ + role: "assistant", + content: [{ type: "text", text }], + api, + provider, + model, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 3, + }); + const messages: Message[] = [ + { role: "system", content: "base prompt", timestamp: 0 }, + { role: "user", content: "hi", timestamp: 1 }, + { role: "system", content: "updated guidance", timestamp: 2 }, + // Another model's empty reply: Anthropic rejects empty text blocks, so it is dropped. + reply("", "openai-codex", "openai-codex-responses", "gpt-x"), + { role: "user", content: "again", timestamp: 4 }, + ]; + const roles = (payload: AnthropicPayload) => payload.messages.map((message) => message.role); + + // No later assistant: the update goes at the end. + const atEnd = await capturePayload(anthropicNativeModel, { messages }); + expect(roles(atEnd)).toEqual(["user", "user", "system"]); + + // A later assistant that is sent: the update goes directly before it. + const beforeAssistant = await capturePayload(anthropicNativeModel, { + messages: [ + ...messages, + reply("answer", anthropicNativeModel.provider, anthropicNativeModel.api, anthropicNativeModel.id), + { role: "user", content: "third", timestamp: 5 }, + ], + }); + expect(roles(beforeAssistant)).toEqual(["user", "user", "system", "assistant", "user"]); + }); + test("folds Anthropic updates into the system prompt without native support", async () => { const model: Model<"anthropic-messages"> = { ...modelBase,