Skip to content

0.49.8: false 'comparison=content' refusal on cross-model assistant history (Responses lane) — 0.49.0 unaffected #714

Open

Description

@Zireael

What happened?

On OMP (Oh-My-Pi) using @cortexkit/pi-magic-context 0.49.8, every turn in a long-lived session is refused with provider request does not match this pass's managed dispatch (prepared turn structure); … comparison=content. The turn never reaches the model. Retrying ("continue") reproduces it deterministically — the refusal recurs at the same row index on every attempt.

0.49.0 does not exhibit this. Pinning back to 0.49.0 makes the turns go through. (Same session, same history, same model.)

The failure signature

Roles, call counts, and result counts all match — only the text content of one assistant row differs. Example (the row index and total counts are stable across retries):

Magic Context could not safely prepare this turn; send your message again.
stage=provider dispatch elapsed=1888ms recovery=no managed result;
refused (provider request does not match this pass's managed dispatch
(prepared turn structure); row=21 expectedRows=32 actualRows=32
expectedRole=assistant actualRole=assistant expectedCalls=1 actualCalls=1
expectedResults=0 actualResults=0 comparison=content)

It also fired at expectedRows=33 actualRows=33 after the conversation grew by one message, i.e. the same row keeps failing as history is appended.

Later in the same session I additionally hit three missing current receipt signature refusals (elapsed 341/390/453 ms) on an otherwise-valid turn.

Environment

  • Plugin: @cortexkit/pi-magic-context 0.49.8 (broken) / 0.49.0 (works)
  • Harness: OMP (opencode-free-omp) on @oh-my-pi/pi-coding-agent 18.11.3, --harness pi
  • Platform: Windows x64 (MSYS2 shell)
  • API: openai-codex-responses (Responses lane)

Repro conditions

The session mixes models/providers. In the failing session the history contained turns from the current Codex model (~2.7k turns) plus two other providers (an OpenRouter model and a Cline model, ~12 and ~10 turns respectively), and the session carries a large number of thinking blocks (~2.1k).

That combination — cross-model assistant history on the Responses lane, with thinking blocks — is what trips the comparison=content check.

Root cause (from reading the 0.49.8 bundle)

The managed-dispatch verifier added in 0.49.1 re-parses the outgoing request (payloadStructure) and compares it row-by-row against the prepared turn (piPreparedStructureMismatch). For assistant text on the Responses lane the two sides use two different extractors that disagree on cross-model messages:

  • Expected side — piResponsesAssistantTexts(projectedMessages[i], model) computes sameModel from message.api/model/provider vs the current model. When sameModel is false it (a) demotes thinking blocks into $$…$$ text and appends them to the text list, and (b) forces phase to undefined via sameModel ? signaturePhase(block) : undefined.
  • Actual side — payloadStructure builds rows from the wire: it skips reasoning items entirely, and piResponsesWireTexts reads only output_text parts, carrying the real phase.

So for any assistant message produced by a different model than the current one, the expected text list contains an extra demoted-thinking entry (and a null phase) that the wire never has. At the first such row the responseTexts comparison fails → comparison=content → the turn is refused. Because it depends only on history composition, it reproduces at a stable row index on every retry.

A second, independent cross-model path with the same outcome: even without a thinking block, a cross-model assistant message whose wire phase is "commentary"/"final_answer" mismatches, because the expected side forces phase → null when sameModel is false while the wire carries the real phase.

Why this looks like a residual case of the 0.49.1–0.49.5 fixes

0.49.2–0.49.5 patched many shapes (leading system/developer entries, consecutive assistant messages on Responses, chat-completions transforms, image-budget trimming, stale tool results, "earlier replies from other models on Responses/Codex"). This one appears to be a remaining instance: earlier replies from other models on the Responses lane that carry thinking blocks and/or a non-null phase. The sameModel branch in piResponsesAssistantTexts is where the expected side diverges from what the wire encoder actually emits.

Other false-positive surfaces I found while reading the same code

These are from code inspection (not each individually reproduced), listed because they share the same root pattern — the check assumes the prepared projection and the host's wire encoder agree exactly, and refuses wherever they legitimately diverge:

  1. Signature placement — piHasManagedSignature scans only the first 8 messages / 32 blocks / 4096 chars each. Once history grows so the injected signature message falls outside that window (or the first 8 messages are tool-results/reasoning with no text block), a valid turn is refused with missing current receipt signature. I hit this in the same session after several turns.
  2. Over-size text is a refusal, not a skip — piResponsesAssistantTexts returning undefined over MAX_TEXT_UNITS sets structuralSkipReason (safe skip), but piResponsesWireTexts returning undefined makes payloadStructure return undefined → comparison=count refusal. Same threshold, opposite outcomes.
  3. Catch-all masks the real cause — piPreparedStructureMismatch wraps everything in try { … } catch { return structureDetail(0, undefined, undefined, "count") }, so any parser exception surfaces as a bogus row=0 … comparison=count instead of the actual trigger.
  4. Lane detection is heuristic — chat requires body.system === undefined, responses requires messages === undefined && Array.isArray(input); a body carrying both, or a system field alongside messages, falls through to raw prepared.messages instead of the correct projection → structural mismatch.

Suggestion

For the sameModel === false branch, the expected-side text extraction should mirror exactly what the Responses wire encoder emits for cross-model messages: don't count demoted thinking as output_text, and don't null the phase. More generally, when the prepared projection and the wire encoder can legitimately disagree about content (as opposed to structure), preferring a skip-with-warning over a hard refusal would avoid blocking the whole turn — the safety intent (don't send an unmanaged request) is preserved, since structure (roles/counts/ids) is still verified.

Happy to provide the full session log excerpt or a minimal repro if useful.

Activity

  1. magic-alfonso commented on Oct 17, 2026

    @magic-alfonso

    Thank you for the careful report, and for reading the bundle; it narrows this down a lot.

    We're reproducing it now on a real Oh My Pi 18.11.3 host with cross-model history on the Responses lane: replies from other models with thinking blocks, a non-null phase, and both together. We'll compare against 18.11.1, where these shapes passed in our earlier tests, and check exactly what Oh My Pi's encoder puts on the wire for another model's reply at each version. The fix will make the check match what Oh My Pi really sends, rather than skip content comparisons. That comparison is what stops Magic Context from sending history it didn't prepare (issue 692 was one such case).

    We're also checking each of the four other surfaces you listed: the signature window, the size-limit asymmetry, the catch-all hiding the real cause, and lane detection.

    On 0.49.0: it passes because it has no dispatch check at all, which is also why it can't catch the cases the check was added for. Please treat it as a stopgap only.

  2. magic-alfonso commented on Oct 17, 2026

    @magic-alfonso

    We found it, and it isn't the cross-model history after all. Thank you for the detailed write-up; it pointed straight at the right code.

    Cause. On the Codex lane, Oh My Pi stores every native item a Codex reply produced (reasoning, the message and any tool call) and replays them as they are. Our check only recognised cached replies that contained just the message. So after the model's first tool call, every later turn refused at that reply's row. That matches your stable row index exactly. It happens on 18.10.4, 18.11.1 and 18.11.3 alike; 18.11.3 changed only tool-call ID handling.

    Your two cross-model claims. These didn't reproduce on any of the three versions. Oh My Pi turns another model's thinking into <think> text and drops that model's text signature, so no phase is sent, and that is what our expected side already produces. Your session most likely also had ordinary Codex tool turns, and those are what refused.

    Fix. The check now accepts Codex's full cached replies: reasoning is skipped and tool calls are matched by ID. It still requires every text we prepared to match the cached text exactly, so history we didn't prepare still refuses. It passed 102 of 102 real-host runs across the three versions, including a real Codex tool turn through Oh My Pi's own parser. It ships in the next patch.

    The other four surfaces:

    • Catch-all: now reports where the comparison failed instead of row=0 … comparison=count.
    • Size limit: can't produce a different outcome, because a whole-conversation limit refuses first.
    • Lane detection: fails closed and classifies all 12 request builders correctly.
    • Signature window: didn't reproduce on the normal path. There's one gap from source, on recovery replays. Could you send the three missing current receipt signature lines together with the log lines just before each one? That would show whether those turns ran without Magic Context's history injection.

    0.49.0 passes only because it has no check at all; you can move off it once the next patch is out.

  3. magic-alfonso commented on Oct 17, 2026

    @magic-alfonso

    0.49.10 is out with the fix (@cortexkit/pi-magic-context@0.49.10).

    The cause was on our side. On the Codex lane, Oh My Pi replays each earlier reply's cached native items: its reasoning, the message and any tool call. Magic Context's pre-send check only recognised cached replies that held a message alone, so every turn after the model's first tool call was refused. It now skips the reasoning items and matches tool calls by their ids, and every text we prepared must still match exactly, so history we didn't prepare is still refused. We verified it on real Oh My Pi 18.10.4, 18.11.1 and 18.11.3 hosts.

    Your two cross-model suspects didn't reproduce on their own, so they aren't part of this fix. If the missing current receipt signature refusals continue on 0.49.10, please paste those lines along with the log lines just before each one.

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