diff --git a/packages/pi-plugin/src/issue-785-request-controls.test.ts b/packages/pi-plugin/src/issue-785-request-controls.test.ts new file mode 100644 index 000000000..a2a48406a --- /dev/null +++ b/packages/pi-plugin/src/issue-785-request-controls.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { + piPreparedStructure, + piPreparedStructureMismatch, +} from "./pi-dispatch-structure"; + +// Issue 785: Oh My Pi's Anthropic provider appends request-control system +// messages (tool_addition/tool_removal blocks, or an empty effort-only control) +// after the context pass. They are not prepared turns, so the dispatch check +// must not count them as conversation rows. + +const anthropic = { + api: "anthropic-messages", + compat: { supportsMidConversationSystem: true }, + input: ["text", "image"], +}; +const head = { role: "user", content: "managed history", timestamp: 1 }; +const prompt = { role: "user", content: "current request", timestamp: 4 }; +const prepared = piPreparedStructure( + [ + head, + { + role: "assistant", + content: [{ type: "toolCall", id: "one", name: "read", arguments: {} }], + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "one", + toolName: "read", + content: [{ type: "text", text: "body" }], + timestamp: 3, + }, + prompt, + ], + anthropic, +); +const wire = [ + head, + { + role: "assistant", + content: [{ type: "tool_use", id: "one", name: "read", input: {} }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "one", content: "body" }], + }, + prompt, +]; +const control = (type: string, name: string) => ({ + type, + tool: { type: "tool_reference", name }, +}); + +test("issue 785: trailing request-control system rows are not prepared turns", () => { + expect( + piPreparedStructureMismatch({ messages: wire }, prepared), + ).toBeUndefined(); + for (const content of [ + [control("tool_addition", "x")], + [control("tool_addition", "x"), control("tool_removal", "y")], + [], + ]) + expect( + piPreparedStructureMismatch( + { messages: [...wire, { role: "system", content }] }, + prepared, + ), + ).toBeUndefined(); +}); + +test("issue 785: unprepared system text rows still refuse", () => { + for (const content of [ + "unprepared instructions", + [{ type: "text", text: "unprepared instructions" }], + [control("tool_addition", "x"), { type: "text", text: "unprepared" }], + ]) { + expect( + piPreparedStructureMismatch( + { messages: [...wire, { role: "system", content }] }, + prepared, + ), + ).toContain("comparison=count"); + expect( + piPreparedStructureMismatch( + { + messages: [ + ...wire.slice(0, 3), + { role: "system", content }, + ...wire.slice(3), + ], + }, + prepared, + ), + ).toContain("comparison="); + } +}); diff --git a/packages/pi-plugin/src/pi-dispatch-structure.ts b/packages/pi-plugin/src/pi-dispatch-structure.ts index 978d4f11c..dbd8c6099 100644 --- a/packages/pi-plugin/src/pi-dispatch-structure.ts +++ b/packages/pi-plugin/src/pi-dispatch-structure.ts @@ -971,6 +971,19 @@ function payloadStructure( (message.role === "system" || message.role === "developer") ) continue; + // Oh My Pi's Anthropic provider appends request-control system messages + // (only tool_addition/tool_removal blocks, or empty effort-only controls) + // while building the request, after the context pass. They are not + // prepared turns. @oh-my-pi/pi-ai/src/providers/anthropic.ts + // insertAnthropicControlMarkers (18.12.6). + if ( + message.role === "system" && + Array.isArray(message.content) && + message.content.every((block) => + ["tool_addition", "tool_removal"].includes(String(object(block)?.type)), + ) + ) + continue; // Responses emits reasoning/text/calls as separate items for one assistant // turn. Only its call/result IDs survive every supported replay mode. if (responses && message.type === "reasoning") continue;