Skip to content

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

Closed

Description

@Zireael

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=role

Observed 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 (api openai-codex-responses)
  • When 2026-10-21T09:17:55.741Z
  • Log magic-context.log line 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 custom notice (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 wait case 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:

  1. The fence compares rows positionally. Whenever the host legitimately reorders rows (this forward-pull, and the realToolResultsById pull-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.
  2. projectMissingToolResults and piChatHistoryPlan each re-derive the host's tool-call window rules independently. The host's rules have already moved several times (the takeRealToolResult pull, the _dup id rewrite, the opaqueCompositeCallIds scoping). A shared oracle — the host's transformMessages as 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-ai 18.12.5
  • Client OMP (opencode-free-omp), harness pi
  • 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

  1. magic-alfonso commented on Oct 21, 2026

    @magic-alfonso

    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 transformMessages moves the real tool result into the window opened by its call when another message interrupts it. Our projectMissingToolResults only 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 transformMessages against 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.

  2. magic-alfonso commented on Oct 22, 2026

    @magic-alfonso

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions