Repository navigation
0.49.16: false 'comparison=role' refusal — the host pulls a tool result forward across an interrupting message, the prepared projection does not model the move #751
Description
What happened?
On OMP (Oh-My-Pi) with @cortexkit/pi-magic-context 0.49.16, a turn is refused at the provider-dispatch stage with a comparison=role mismatch:
Magic Context could not safely prepare this turn; send your message again.
stage=provider dispatch elapsed=1676ms recovery=no managed result;
refused (provider request does not match this pass's managed dispatch
(prepared turn structure); row=57 expectedRows=60 actualRows=60
expectedRole=user actualRole=toolResult expectedCalls=0 actualCalls=0
expectedResults=0 actualResults=1 comparison=role)
Roles, call counts and result counts all match. The two sides simply disagree about the order of two adjacent rows: the prepared projection says user, toolResult, the wire says toolResult, user.
This is a different signature and a different root cause from #714 (that one is comparison=content on cross-model assistant text). Here the request the host actually sends is well-formed — every function_call is immediately followed by its function_call_output — so the fence is refusing a request it should accept.
Root cause
The host (@oh-my-pi/pi-ai → src/providers/transform-messages.ts) enforces a strict invariant: a tool result must occupy the tool-call window opened by its assistant turn. When a non-toolResult message interrupts that window, the host does not leave the result behind — it pulls the real result forward into the window and drops it from its original position:
// transform-messages.ts
const takeRealToolResult = (id: string, afterIndex: number): ToolResultMessage | undefined => {
const entries = realToolResultsById.get(toolCallPairingKey(id, originScope));
...
if (entry.consumed || entry.index <= afterIndex) continue;
entry.consumed = true;
return entry.msg;
};
const flushPendingToolCalls = (timestamp: number): void => {
...
const realToolResult = takeRealToolResult(tc.id, pendingToolCallsStartIndex);
if (realToolResult) { result.push(realToolResult); ...; continue; } // <- real result MOVED here
result.push({ role: "toolResult", ..., content: [{ type: "text", text: "No result provided" }], ... });
};realToolResultsById is populated during the dedup pass over the whole transcript, so takeRealToolResult finds a result that appears later in the history and emits it here. The flush runs on every boundary message — user, developer, and any other role (OMP's custom notices land in that last branch).
The plugin's projectMissingToolResults (packages/pi-plugin/src/pi-dispatch-structure.ts) models the same window but not the move:
const flush = (boundaryTimestamp) => {
for (const call of pending) {
const key = pairingId(call.id);
if (resolved.has(key) || (lastResult.get(key) ?? -1) > start)
continue; // <- real result exists later: skip the stub
projected.push({ role: "toolResult", toolCallId: call.id, ..., content: [{ type: "text", text: "No result provided" }], ... });
resolved.add(key);
}
pending = [];
};The lastResult guard correctly avoids synthesizing a "No result provided" stub — but the real result is then left at its original index while the interrupting message keeps its slot. The expected row sequence and the wire row sequence are the same multiset rotated by one position at the boundary, and the fence's row-by-row role comparison fails.
Net effect: the plugin's projection is right about what the rows are and wrong about where they sit. Because the fence is a strict positional comparison, a legitimate turn is blocked.
Minimal reproducer (4 messages, deterministic)
Run against the shipped 0.49.16 bundle and the real host transformMessages:
import { buildPiPreparedStructure, piPreparedStructureMismatch } from "@cortexkit/pi-magic-context/dist/index.js";
import { transformMessages } from "@oh-my-pi/pi-ai/src/providers/transform-messages";
const model = { id: "gpt-6.1-sol", api: "openai-codex-responses", provider: "openai-codex", ... };
// assistant tool call, then a host-injected `custom` notice, then the tool result
const messages = [
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 1 },
{ role: "assistant", content: [{ type: "toolCall", id: "call_X", name: "wait", arguments: {} }],
api: "openai-codex-responses", provider: "openai-codex", model: "gpt-6.1-sol", timestamp: 2 },
{ role: "custom", customType: "eval-state-context", content: "<system-reminder>state</system-reminder>",
display: false, attribution: "agent", timestamp: 3 },
{ role: "toolResult", toolCallId: "call_X", toolName: "wait",
content: [{ type: "text", text: "done" }], isError: false, timestamp: 4 },
];
const prepared = buildPiPreparedStructure(messages, model, undefined);
// what the host actually puts on the wire (the result is pulled forward):
const payload = { model: model.id, input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] },
{ type: "function_call", call_id: "call_X", name: "wait", arguments: "{}" },
{ type: "function_call_output", call_id: "call_X", output: "done" },
{ type: "message", role: "user", content: [{ type: "input_text", text: "<system-reminder>state</system-reminder>" }] },
]};
piPreparedStructureMismatch(payload, prepared);
// => row=2 expectedRows=4 actualRows=4 expectedRole=user actualRole=toolResult
// expectedCalls=0 actualCalls=0 expectedResults=0 actualResults=1 comparison=roleObserved row sequences for the same input:
| side | rows |
|---|---|
plugin (prepared.responsesMessages) |
user | assistant(1 call) | user | toolResult(1 result) |
host (transformMessages) |
user | assistant | toolResult | custom |
The control case — tool call immediately followed by its result — agrees, so the trigger is specifically the interrupting message.
The same divergence reproduces with a plain user message between the call and its result, so it is not specific to the custom role.
Real occurrence
- Session
01a1329b-dc64-75d2-b3aa-e5f2f78dbeaa(~/.omp/agent/sessions/--D--Coding-eQualizer-edge-extension--/2026-10-13T00-13-57-732Z_01a1329b-dc64-75d2-b3aa-e5f2f78dbeaa.jsonl) - Model
openai-codex/gpt-6.1-sol(apiopenai-codex-responses) - When 2026-10-21T09:17:55.741Z
- Log
magic-context.logline 87118 —transform completed in 1323.3ms (60 messages, …)immediately followed by the refusal, so the context pass itself succeeded and only the dispatch fence failed.
The relevant tail of that pass (reconstructed from the plugin's own LKG snapshot, which holds the exact 60 prepared messages):
idx 55 toolResult read
idx 56 assistant toolCall call_1ef47c86…|fc_09ff1ca8… (name "wait")
idx 57 custom customType "eval-state-context" <- host-injected notice
idx 58 toolResult call_1ef47c86…|fc_09ff1ca8… (name "wait", isError)
idx 59 custom customType "async-result"
The host's transform emits:
idx 55 toolResult read
idx 56 assistant toolCall call_1ef47c86…|fc_09ff1ca8…
idx 57 toolResult wait "§5372§ Previous OMP process exited before this tool returned; its outcome is unknown." <- REAL result, pulled forward
idx 58 custom <system-reminder>Eval runtime state is host-retained…</system-reminder>
idx 59 custom <system-notice>Background job ReviewCaptureDriver104m has completed…</system-notice>
i.e. the real wait result (not a synthesized stub) is moved ahead of the injected notice, exactly as takeRealToolResult does. The plugin's projection keeps it at index 58.
Realistic triggers are common in OMP:
- a host-injected
customnotice (lsp-late-diagnostic,async-result,eval-state-context, …) landing between a tool call and its result; - a user interjection between a tool call and its result;
- a tool result that arrives late because the previous OMP process died mid-tool (the
waitcase above).
Suggestion
Model the host's forward-pull in projectMissingToolResults: when a boundary message interrupts an open tool-call window and lastResult shows a real result for that call later in the transcript, move that result into the window slot and mark it consumed so it is not emitted again at its original position. The pairing key already matches the host's (pairingId ⇄ toolCallPairingKey), so this is a local change to the flush function.
The same gap exists in the chat-completions plan (piChatHistoryPlan): it also keeps the real result in place instead of moving it, and its orphan path emits a <stale-tool-result> note where the host would have moved the real result.
Two broader notes, in case they are worth a separate fix:
- The fence compares rows positionally. Whenever the host legitimately reorders rows (this forward-pull, and the
realToolResultsByIdpull-in it uses), a positional comparison cannot succeed. A structural comparison that tolerates this specific rotation — or a skip-with-warning instead of a hard refusal when only order differs — would keep the safety intent (the request is still MC-managed) without blocking valid turns. projectMissingToolResultsandpiChatHistoryPlaneach re-derive the host's tool-call window rules independently. The host's rules have already moved several times (thetakeRealToolResultpull, the_dupid rewrite, theopaqueCompositeCallIdsscoping). A shared oracle — the host'stransformMessagesas the single source of truth, exercised in the plugin's test suite — would have caught this class of drift at the point it was introduced.
Diagnostics
- Plugin version 0.49.16
- Host
@oh-my-pi/pi-coding-agent/@oh-my-pi/pi-ai18.12.5 - Client OMP (
opencode-free-omp), harnesspi - Platform windows x64
- API
openai-codex-responses(Responses lane) - Log output
[2026-10-21T09:17:55.388Z] [magic-context][01a1329b-dc64-75d2-b3aa-e5f2f78dbeaa] transform completed in 1323.3ms (60 messages, 28 targets, watermark: 5351)
[2026-10-21T09:17:55.492Z] [magic-context][01a1329b-dc64-75d2-b3aa-e5f2f78dbeaa] transform stage: stage=lkgCapture elapsed=87.8ms reusedPrefix=0
[2026-10-21T09:17:55.741Z] [magic-context][pi] CONTEXT REFUSED "Magic Context could not safely prepare this turn; send your message again. stage=provider dispatch elapsed=1676ms recovery=no managed result; refused (provider request does not match this pass's managed dispatch (prepared turn structure); row=57 expectedRows=60 actualRows=60 expectedRole=user actualRole=toolResult expectedCalls=0 actualCalls=0 expectedResults=0 actualResults=1 comparison=role)"
[2026-10-21T09:17:55.804Z] [magic-context][pi] OMP context counts session=01a1329b-dc64-75d2-b3aa-e5f2f78dbeaa side_contexts=0 context_passes=1 dispatch_fence=on
Activity
magic-alfonso commentedon Oct 21, 2026
Thank you, @Zireael. This is a precise diagnosis, and the four-message reproducer makes it easy to confirm.
You're right on both counts. The host's
transformMessagesmoves the real tool result into the window opened by its call when another message interrupts it. OurprojectMissingToolResultsonly skips the placeholder and leaves the result where it was, so the rows come out rotated and the positional check refuses a valid request. The chat-completions plan has the same gap.We're keeping the dispatch check strict, since its job is to prove the request is the one Magic Context prepared, and making the projection follow the host exactly. That covers the forward move on both the Responses and chat-completions paths, for each supported Oh My Pi and Pi version. We're also taking your suggestion of an oracle test that runs the real host
transformMessagesagainst our projection over many randomized message sequences, so drift like this is caught when it happens.The fix ships in the next patch, and we'll post here when it's out.
magic-alfonso commentedon Oct 22, 2026
Fixed in 0.49.18. Thanks @Zireael for the exact four-message reproducer and for suggesting the real-host check.
When a message lands between a tool call and its result, Oh My Pi moves the result up next to its call. Magic Context's dispatch check now projects that move exactly, on both request formats. It's checked against the real host code of three Oh My Pi versions and two Pi versions over 2,000 random histories each. The move applies only on providers where Oh My Pi's own code does it, and every other provider keeps the previous behaviour.