import { describe, expect, it } from "bun:test";
import { convertAnthropicMessages } from "@oh-my-pi/pi-ai/providers/anthropic";
import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
import type { AssistantMessage, Message, Model, UserMessage } from "@oh-my-pi/pi-ai/types";
* Regression test for: "messages.X.content.Y: `thinking` or `redacted_thinking` blocks in
* the latest assistant message cannot be modified."
*
* Reproduces the shape captured in `~/.omp/logs/http-400-requests/*.json` after Claude
* returns `stop_reason: "max_tokens"` mid-thinking. The provider records an assistant
* message containing only a signed `thinking` block (no `text`, no `tool_use`). When the
* next assistant turn lands without a real user message between them (the user typed an
* empty submission to nudge the agent, which `convertContentBlocks` drops), two
* consecutive assistant messages are sent to Anthropic, each with its own signed thinking
* block. Anthropic rejects this with the 400 above.
*
* `transformMessages` must drop the thinking-only assistant turn so the conversation
* keeps proper `user` / `assistant` alternation regardless of which provider is sending it.
*/
describe("transformMessages drops thinking-only assistant turns", () => {
const model: Model<"anthropic-messages"> = {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-opus-4-7",
name: "Claude Opus 4.7",
baseUrl: "https://api.anthropic.com",
input: ["text", "image"],
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
maxTokens: 8192,
contextWindow: 200000,
reasoning: true,
};
const makeThinkingOnlyAssistant = (
thinking: string,
signature: string,
stopReason: AssistantMessage["stopReason"],
): AssistantMessage => ({
role: "assistant",
content: [{ type: "thinking", thinking, thinkingSignature: signature }],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp: Date.now(),
});
const makeFullAssistant = (text: string, toolCallId: string): AssistantMessage => ({
role: "assistant",
content: [
{ type: "thinking", thinking: "fresh reasoning", thinkingSignature: "sig_fresh" },
{ type: "text", text },
{ type: "toolCall", id: toolCallId, name: "read", arguments: { path: "x" } },
],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
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: Date.now(),
});
it("drops length-truncated thinking-only assistant turn so no consecutive assistants reach the API", () => {
const user: UserMessage = { role: "user", content: "follow-up question", timestamp: 1 };
const truncated = makeThinkingOnlyAssistant("partial reasoning", "sig_truncated", "length");
const emptyUser: UserMessage = {
role: "user",
content: [{ type: "text", text: "" }],
timestamp: 2,
};
const fresh = makeFullAssistant("here is the answer", "toolu_fresh");
const messages: Message[] = [user, truncated, emptyUser, fresh];
const transformed = transformMessages(messages, model);
const truncatedSurvivors = transformed.filter(
m =>
m.role === "assistant" &&
(m as AssistantMessage).content.some(b => b.type === "thinking" && b.thinkingSignature === "sig_truncated"),
);
expect(truncatedSurvivors.length).toBe(0);
const freshSurvivor = transformed.find(
m =>
m.role === "assistant" &&
(m as AssistantMessage).content.some(b => b.type === "thinking" && b.thinkingSignature === "sig_fresh"),
);
expect(freshSurvivor).toBeDefined();
const params = convertAnthropicMessages(messages, model, false);
for (let i = 1; i < params.length; i++) {
expect(
params[i].role === "assistant" && params[i - 1].role === "assistant",
`adjacent assistant turns at index ${i - 1}/${i} would reproduce the 400`,
).toBe(false);
}
const wireThinkingSignatures = params
.filter(p => p.role === "assistant")
.flatMap(p => (Array.isArray(p.content) ? p.content : []))
.filter((block): block is { type: "thinking"; thinking: string; signature: string } => {
return typeof block === "object" && block !== null && (block as { type?: string }).type === "thinking";
})
.map(block => block.signature);
expect(wireThinkingSignatures).toEqual(["sig_fresh"]);
});
it("drops error-stop thinking-only assistant turn AND emits the aborted-turn developer note", () => {
const user: UserMessage = { role: "user", content: "do a thing", timestamp: 1 };
const errored = makeThinkingOnlyAssistant("partial reasoning", "sig_errored", "error");
const nextUser: UserMessage = { role: "user", content: "try again", timestamp: 3 };
const messages: Message[] = [user, errored, nextUser];
const transformed = transformMessages(messages, model);
const erroredSurvivors = transformed.filter(
m =>
m.role === "assistant" &&
(m as AssistantMessage).content.some(b => b.type === "thinking" && b.thinkingSignature === "sig_errored"),
);
expect(erroredSurvivors.length).toBe(0);
const developerNotes = transformed.filter(m => m.role === "developer");
expect(developerNotes.length).toBeGreaterThanOrEqual(1);
});
it("keeps assistant turns that have a `text` block even when stopped at length", () => {
const user: UserMessage = { role: "user", content: "answer me", timestamp: 1 };
const partial: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "thought", thinkingSignature: "sig_keep" },
{ type: "text", text: "partial answer cut off mid-" },
],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "length",
timestamp: 2,
};
const transformed = transformMessages([user, partial], model);
const kept = transformed.find(
m =>
m.role === "assistant" &&
(m as AssistantMessage).content.some(b => b.type === "thinking" && b.thinkingSignature === "sig_keep"),
);
expect(kept).toBeDefined();
});
});