Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/12920-aging-tool-result-block-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(compression):** progressive aging now appends its `[COMPRESSED:aging:…]` annotation after a turn's `tool_result` blocks instead of in front of them, so Anthropic no longer rejects aged conversations with "`tool_use` ids were found without `tool_result` blocks immediately after" ([#12920](https://github.com/diegosouzapw/OmniRoute/pull/12920)).
15 changes: 14 additions & 1 deletion open-sse/services/compression/messageContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export function isTextBlock(value: unknown): value is TextBlock {
);
}

export function isToolResultBlock(value: unknown): boolean {
return (
!!value && typeof value === "object" && (value as { type?: unknown }).type === "tool_result"
);
}

export function extractTextContent(content: ChatMessageLike["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
Expand Down Expand Up @@ -82,7 +88,14 @@ export function replaceTextContent(msg: ChatMessageLike, newText: string): ChatM
});

if (!replaced) {
return { ...msg, content: [{ type: "text", text: newText }, ...msg.content] };
// Anthropic requires every `tool_result` block to sit at the start of the
// user turn that answers a `tool_use`; a text block in front of them makes
// upstream reject the whole request with "tool_use ids were found without
// tool_result blocks immediately after" (#12890). Append the annotation in
// that case, and keep prepending everywhere else.
return msg.content.some(isToolResultBlock)
? { ...msg, content: [...msg.content, { type: "text", text: newText }] }
: { ...msg, content: [{ type: "text", text: newText }, ...msg.content] };
}

return { ...msg, content };
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/compression/aging-tool-result-order-12890.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
replaceTextContent,
type ChatMessageLike,
} from "../../../open-sse/services/compression/messageContent.ts";
import { applyAging } from "../../../open-sse/services/compression/progressiveAging.ts";

// ─── #12890 — aged tool_result turns must keep tool_result first ─────────────
// The Anthropic Messages API requires the `tool_result` blocks answering a
// `tool_use` to lead the following user message. Aging a tool-result-only user
// turn used to prepend the `[COMPRESSED:aging:…]` annotation, producing
// ["text", "tool_result"] and a 400 from upstream.

function toolResultTurn(id: string): ChatMessageLike {
return {
role: "user",
content: [{ type: "tool_result", tool_use_id: id, content: "ls: 3 files" }],
};
}

function blockTypes(msg: unknown): string[] {
const content = (msg as ChatMessageLike).content;
return Array.isArray(content) ? content.map((b) => (b as { type?: string }).type ?? "") : [];
}

describe("aging a tool_result turn (#12890)", () => {
it("keeps tool_result first through applyAging", () => {
// distanceFromEnd of index 2 is 5 (> moderate: 3) → the fullSummary tier,
// which is where setContent/replaceTextContent injects the tag.
const messages: ChatMessageLike[] = [
{ role: "user", content: "start the task" },
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_01", name: "bash", input: {} }],
},
toolResultTurn("toolu_01"),
{ role: "assistant", content: "three files" },
{ role: "user", content: "and now the second one" },
{ role: "assistant", content: "done" },
{ role: "user", content: "thanks" },
{ role: "assistant", content: "you are welcome" },
];

const { messages: aged } = applyAging(messages);
const types = blockTypes(aged[2]);

assert.deepEqual(types, ["tool_result", "text"], `got ${JSON.stringify(types)}`);
const annotation = (aged[2] as ChatMessageLike).content as Array<{ text?: string }>;
assert.match(annotation[1].text ?? "", /^\[COMPRESSED:aging:/);
});

it("still puts the annotation first when the turn carries no tool_result", () => {
const msg: ChatMessageLike = {
role: "user",
content: [{ type: "image", source: { foo: 1 } }],
};

const out = replaceTextContent(msg, "NEWTEXT");

assert.deepEqual(blockTypes(out), ["text", "image"]);
});
});
Loading