mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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
This commit is contained in:
parent
db774055e3
commit
3bc6eebacf
3 changed files with 53 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1375,7 +1375,9 @@ function convertMessages(
|
|||
const params: MessageParam[] = [];
|
||||
const assistantLevels = new Map<number, AnthropicEffort>();
|
||||
// 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",
|
||||
|
|
|
|||
|
|
@ -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<AnthropicPayload>(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<AnthropicPayload>(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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue