From c2dd39b1f6c702676dd9495fc46ae4ecdbc0e232 Mon Sep 17 00:00:00 2001 From: tsankotsanev <76694544+tsankotsanev@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:42:20 +0200 Subject: [PATCH] pi: skip Oh My Pi request-control system rows in the dispatch check Oh My Pi's Anthropic provider (insertAnthropicControlMarkers in pi-ai/src/providers/anthropic.ts, 18.12.6) appends request-control system messages while it builds the request: tool_addition/tool_removal blocks when the session's recorded tool set differs from the current one, and empty effort-only controls. That happens after the context pass, so the prepared plan never contains them. payloadStructure skipped system/developer messages only before the first conversation row, so a trailing control was counted as an extra row and every turn was refused with "prepared turn structure ... expectedRole=missing actualRole=system comparison=count". Oh My Pi records the control only after a successful request, so an affected session could never recover on its own. Skip system messages whose content consists only of tool_addition or tool_removal blocks (an empty array included). System rows carrying any other content are still counted and compared, so an unprepared system text row keeps refusing. Refs #785 --- .../src/issue-785-request-controls.test.ts | 97 +++++++++++++++++++ .../pi-plugin/src/pi-dispatch-structure.ts | 13 +++ 2 files changed, 110 insertions(+) create mode 100644 packages/pi-plugin/src/issue-785-request-controls.test.ts 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;