fix(ai): kept assistant tool_use blocks trailing on Anthropic replay

Anthropic's replay validator rejects any non-tool_use block after a tool_use inside an assistant message. A mid-turn server-side fallback handoff persisted after a completed tool call (fallback block + continued text/tool calls, persisted since #4182) replayed verbatim and 400ed every subsequent request, wedging the session. Stable-partition assistant wire blocks in convertAnthropicMessages: non-tool_use blocks first in original order (preserving the thinking-signature/fallback chain), tool_use blocks trailing, with an identity fast-path so already-valid turns stay byte-identical for prompt caching. Same family as the cross-provider text-after-tool_use shape in #544.

Fixes #4781
This commit is contained in:
Victor Araújo
2026-07-07 10:46:12 -03:00
parent ac2ea80fa3
commit d23e3296c1
3 changed files with 292 additions and 1 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Anthropic replay 400s (`tool_use ids were found without tool_result blocks immediately after`) when a persisted assistant turn carries content after a completed tool call — such as a mid-turn `server-side-fallback` handoff (fallback block plus continued text/tool calls after the primary model's `tool_use`) or trailing text from cross-provider replays — by stable-partitioning assistant content so all `tool_use` blocks trail the non-`tool_use` chain. ([#4781](https://github.com/can1357/oh-my-pi/issues/4781), [#544](https://github.com/can1357/oh-my-pi/issues/544))
## [16.3.11] - 2026-07-06
### Fixed
+34
View File
@@ -3529,6 +3529,40 @@ export function convertAnthropicMessages(
});
}
}
// Anthropic's replay validator rejects any non-`tool_use` block that
// appears after a `tool_use` inside an assistant turn (400:
// "tool_use ids were found without tool_result blocks immediately
// after: <id>"). A persisted turn can violate this when a mid-turn
// server-side fallback handoff lands after the primary model already
// emitted a tool_use — the replayed content is then e.g.
// [thinking, text, tool_use, fallback, text, tool_use] — and also for
// the older cross-provider [text, tool_use, text] shape (issue #544).
// Stable-partition into [...non-tool_use, ...tool_use], preserving each
// side's relative order: the non-tool_use chain (thinking → text →
// fallback → text) carries thinking signatures and the fallback
// boundary marker whose order Anthropic verifies, while tool_use blocks
// are unsigned and safe to defer to the tail. Fast-path untouched when
// already in order so prompt-cache prefixes stay byte-identical.
let sawToolUse = false;
let needsPartition = false;
for (const block of blocks) {
if (block.type === "tool_use") {
sawToolUse = true;
} else if (sawToolUse) {
needsPartition = true;
break;
}
}
if (needsPartition) {
const nonToolUse: ContentBlockParam[] = [];
const toolUse: ContentBlockParam[] = [];
for (const block of blocks) {
if (block.type === "tool_use") toolUse.push(block);
else nonToolUse.push(block);
}
blocks.length = 0;
blocks.push(...nonToolUse, ...toolUse);
}
if (blocks.length === 0) continue;
params.push({
role: "assistant",
@@ -17,7 +17,8 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { convertAnthropicMessages, streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic";
import { AnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic-client";
import type { AssistantMessage, Context, Model } from "@oh-my-pi/pi-ai/types";
import type { MessageParam } from "@oh-my-pi/pi-ai/providers/anthropic-wire";
import type { AssistantMessage, Context, Model, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
const fableModel: Model<"anthropic-messages"> = buildModel({
@@ -328,3 +329,255 @@ describe("anthropic fallback content-block replay policy", () => {
expect(params[0]?.content).toEqual([{ type: "text", text: "continued" }]);
});
});
describe("anthropic assistant replay block ordering (tool_use partition)", () => {
// Anthropic's replay validator rejects any assistant turn that carries a
// non-`tool_use` content block AFTER a `tool_use` block
// (`messages.N: tool_use ids were found without tool_result blocks
// immediately after`). This bites when a mid-turn server-side fallback
// (`server-side-fallback-2026-06-01`) lands AFTER the primary model already
// emitted a tool_use, leaving persisted content shaped like
// [thinking, text, tool_use, fallback, text, tool_use]. The same validator
// rejects the older cross-provider [text, tool_use, text] shape (issue
// #544). `convertAnthropicMessages` defends against both by stable-
// partitioning every assistant wire message: all non-tool_use blocks first
// (original relative order), then all tool_use blocks (original relative
// order). Already-valid messages must serialize byte-identically.
function assistant(content: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 0,
};
}
function toolResult(id: string, name: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId: id,
toolName: name,
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: 0,
};
}
function assistantParam(params: MessageParam[]): MessageParam {
const asst = params.filter(p => p.role === "assistant");
if (asst.length !== 1 || !Array.isArray(asst[0]?.content)) {
throw new Error("expected exactly one assistant param with structured content blocks");
}
return asst[0];
}
it("mid-turn fallback repro: defers trailing tool_use, keeps the fallback marker in place", () => {
const params = convertAnthropicMessages(
[
assistant([
{ type: "thinking", thinking: "plan", thinkingSignature: "sig-1" },
{ type: "text", text: "before" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
{ type: "fallback", from: { model: "claude-fable-5" }, to: { model: "claude-opus-4-8" } },
{ type: "text", text: "after" },
{ type: "toolCall", id: "call_b", name: "grep", arguments: {} },
{ type: "toolCall", id: "call_c", name: "glob", arguments: {} },
]),
toolResult("call_a", "read"),
toolResult("call_b", "grep"),
toolResult("call_c", "glob"),
{ role: "user", content: "next", timestamp: 0 },
],
fableModel,
false,
{ serverSideFallbackEnabled: true },
);
expect(assistantParam(params).content).toEqual([
{ type: "thinking", thinking: "plan", signature: "sig-1" },
{ type: "text", text: "before" },
{ type: "fallback", from: { model: "claude-fable-5" }, to: { model: "claude-opus-4-8" } },
{ type: "text", text: "after" },
{ type: "tool_use", id: "call_a", name: "read", input: {} },
{ type: "tool_use", id: "call_b", name: "grep", input: {} },
{ type: "tool_use", id: "call_c", name: "glob", input: {} },
]);
});
it("opt-out drops the fallback marker but still defers tool_use to the tail", () => {
const params = convertAnthropicMessages(
[
assistant([
{ type: "thinking", thinking: "plan", thinkingSignature: "sig-1" },
{ type: "text", text: "before" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
{ type: "fallback", from: { model: "claude-fable-5" }, to: { model: "claude-opus-4-8" } },
{ type: "text", text: "after" },
{ type: "toolCall", id: "call_b", name: "grep", arguments: {} },
{ type: "toolCall", id: "call_c", name: "glob", arguments: {} },
]),
toolResult("call_a", "read"),
toolResult("call_b", "grep"),
toolResult("call_c", "glob"),
{ role: "user", content: "next", timestamp: 0 },
],
fableModel,
false,
// serverSideFallbackEnabled omitted → fallback block dropped
);
expect(assistantParam(params).content).toEqual([
{ type: "thinking", thinking: "plan", signature: "sig-1" },
{ type: "text", text: "before" },
{ type: "text", text: "after" },
{ type: "tool_use", id: "call_a", name: "read", input: {} },
{ type: "tool_use", id: "call_b", name: "grep", input: {} },
{ type: "tool_use", id: "call_c", name: "glob", input: {} },
]);
});
it("issue-544 family: text after a tool_use is reordered before the tool_use", () => {
const params = convertAnthropicMessages(
[
assistant([
{ type: "text", text: "before" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
{ type: "text", text: "after" },
]),
toolResult("call_a", "read"),
{ role: "user", content: "next", timestamp: 0 },
],
fableModel,
false,
{ serverSideFallbackEnabled: true },
);
expect(assistantParam(params).content).toEqual([
{ type: "text", text: "before" },
{ type: "text", text: "after" },
{ type: "tool_use", id: "call_a", name: "read", input: {} },
]);
});
it("identity fast-path: already-valid thinking→text→tool_use serializes in unchanged order", () => {
const params = convertAnthropicMessages(
[
assistant([
{ type: "thinking", thinking: "plan", thinkingSignature: "sig-1" },
{ type: "text", text: "before" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
]),
toolResult("call_a", "read"),
{ role: "user", content: "next", timestamp: 0 },
],
fableModel,
false,
{ serverSideFallbackEnabled: true },
);
expect(assistantParam(params).content).toEqual([
{ type: "thinking", thinking: "plan", signature: "sig-1" },
{ type: "text", text: "before" },
{ type: "tool_use", id: "call_a", name: "read", input: {} },
]);
});
it("interleaved signed thinking: signature-chain order preserved, tool_use deferred to the tail", () => {
const params = convertAnthropicMessages(
[
assistant([
{ type: "thinking", thinking: "first", thinkingSignature: "sig-1" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
{ type: "thinking", thinking: "second", thinkingSignature: "sig-2" },
{ type: "toolCall", id: "call_b", name: "grep", arguments: {} },
]),
toolResult("call_a", "read"),
toolResult("call_b", "grep"),
{ role: "user", content: "next", timestamp: 0 },
],
fableModel,
false,
{ serverSideFallbackEnabled: true },
);
expect(assistantParam(params).content).toEqual([
{ type: "thinking", thinking: "first", signature: "sig-1" },
{ type: "thinking", thinking: "second", signature: "sig-2" },
{ type: "tool_use", id: "call_a", name: "read", input: {} },
{ type: "tool_use", id: "call_b", name: "grep", input: {} },
]);
});
it("partition is localized: all other wire messages serialize byte-identically", () => {
// Prompt-cache contract: partitioning a poisoned assistant turn must be a
// LOCAL rewrite of that turn's own content — every OTHER wire message
// (the earlier valid assistant turn whose cached prefix must survive, its
// tool_result, the poisoned turn's tool_results, and the trailing user
// turn) must serialize to the exact same bytes. If the reorder leaked past
// the turn boundary (mutated a shared/adjacent message) or the slow path
// diverged from an already-ordered fast path, cached prefixes up to the
// reordered turn would be invalidated. Two histories, identical except the
// poisoned turn is pre-ordered to the partition result in (b): (a) fires
// the partition, (b) takes the fast path. Bytes must match everywhere but
// the poisoned param, which must converge to the same content either way.
const validAssistant = assistant([
{ type: "thinking", thinking: "plan", thinkingSignature: "sig-1" },
{ type: "text", text: "before" },
{ type: "toolCall", id: "call_v", name: "list", arguments: {} },
]);
const poisonedContent: AssistantMessage["content"] = [
{ type: "text", text: "poison-a" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
{ type: "fallback", from: { model: "claude-fable-5" }, to: { model: "claude-opus-4-8" } },
{ type: "text", text: "poison-b" },
{ type: "toolCall", id: "call_b", name: "grep", arguments: {} },
];
// Same blocks, hand-ordered to exactly what the stable partition emits:
// non-tool_use chain first (order preserved), then the tool_use tail.
const preOrderedContent: AssistantMessage["content"] = [
{ type: "text", text: "poison-a" },
{ type: "fallback", from: { model: "claude-fable-5" }, to: { model: "claude-opus-4-8" } },
{ type: "text", text: "poison-b" },
{ type: "toolCall", id: "call_a", name: "read", arguments: {} },
{ type: "toolCall", id: "call_b", name: "grep", arguments: {} },
];
const history = (poisoned: AssistantMessage["content"]) => [
{ role: "user", content: "start", timestamp: 0 } as const,
validAssistant,
toolResult("call_v", "list"),
assistant(poisoned),
toolResult("call_a", "read"),
toolResult("call_b", "grep"),
{ role: "user", content: "next", timestamp: 0 } as const,
];
const a = convertAnthropicMessages(history(poisonedContent), fableModel, false, {
serverSideFallbackEnabled: true,
});
const b = convertAnthropicMessages(history(preOrderedContent), fableModel, false, {
serverSideFallbackEnabled: true,
});
expect(a.length).toBe(b.length);
const poisonedIdx = a.findIndex(
p =>
p.role === "assistant" &&
Array.isArray(p.content) &&
p.content.some(block => block.type === "tool_use" && block.id === "call_a"),
);
expect(poisonedIdx).toBeGreaterThanOrEqual(0);
for (let i = 0; i < a.length; i++) {
if (i === poisonedIdx) continue;
expect(JSON.stringify(a[i])).toBe(JSON.stringify(b[i]));
}
// Convergence: partition (a) and fast path (b) yield identical final content.
expect(a[poisonedIdx]).toEqual(b[poisonedIdx]);
});
});