Skip to content

bug(omp): tag stripping leaves unsigned text with stale Responses payload and blocks subsequent turns #766

Closed

Description

@sisttri

What happened

With @cortexkit/pi-magic-context 0.49.16 and OMP 18.12.6, an existing Responses session repeatedly refuses new turns before provider dispatch:

provider request does not match this pass's managed dispatch (prepared turn structure);
row=28 expectedRows=73 actualRows=73 expectedRole=assistant actualRole=assistant
expectedCalls=0 actualCalls=0 expectedResults=0 actualResults=0 comparison=content

Resending and restarting do not repair the persisted history. This is an OMP tag-rewrite/native-replay interaction, distinct from a row-count or leading-system-row mismatch.

Reproduction and cause

A minimal affected assistant message has these two representations (provider/model names are illustrative):

const message = {
  role: "assistant", api: "openai-responses", provider: "example", model: "gpt-5",
  stopReason: "stop", timestamp: 1,
  content: [{ type: "text", text: "Hello" }], // textSignature absent after rewrite
  providerPayload: {
    type: "openaiResponsesHistory", provider: "example", dt: true,
    items: [{
      type: "message", role: "assistant", id: "msg_example",
      phase: "final_answer", status: "completed",
      content: [{ type: "output_text", text: "§12§ Hello", annotations: [] }]
    }]
  }
};
  1. registerOmpAssistantMessageStrip returns stripped replacement content through OMP's assistant_message hook.
  2. In OMP 18.12.6, ExtensionRunner.emitAssistantMessage removes textSignature from rewritten text. The SDK updates message.content, but retains the original providerPayload.
  3. On a later turn, piResponsesAssistantTexts(message, model) cannot bind the unsigned block to the native message and expects ["Hello", null].
  4. OMP's actual buildResponsesInput, with native history replay enabled, emits ["§12§ Hello", "final_answer"] from the retained payload. The dispatch fence correctly rejects this mismatch.

Verified the planner half against master 79ce01a522ae5a9d65d6bb2ac81aadf16fa8285a, and the wire half against installed OMP 18.12.6. No live model request is required to reproduce these two different text/phase pairs.

Relevant upstream locations:

Proposed scope for maintainer approval

A local mitigation reconciles only OMP messages before the managed context transform. It uses copy-on-write messages without the stale native payload only if all of the following hold:

  • Responses-family API, same model/provider, assistant message;
  • incremental openaiResponsesHistory payload (dt: true);
  • at least one text block has lost its signature;
  • native output text blocks and visible text blocks correspond one-to-one;
  • every pair is equal after removing only a leading §N§ tag, and at least one pair differs before that removal.

All other mismatches remain subject to the existing refusal. The normalization is gated by resolvePiHarnessKind() === "omp"; it does not change OpenCode adapters, Pi behavior, shared configuration, or database schemas. It repairs request preparation for existing affected history without rewriting session files.

Tradeoff: dropping the stale native payload can change native reasoning replay/cache reuse for those affected messages. This is a narrow local recovery measure, not a request to remove the dispatch fence. A maintainer may prefer an OMP-side invalidation fix or a binding-preserving synchronization of the native payload. Whichever approach is chosen should cover already persisted affected messages as well as new replies.

Local validation

  • Seven affected real messages: actual planner/encoder structure checks fail before mitigation and pass afterward, including messages containing tool calls.
  • Whole original history conversion: 784 normalized rows, content mismatch before mitigation, complete structural match after mitigation. This is an offline raw-history check, not a replay of the original 73-row managed compression pass.
  • 14 regression checks / 53 assertions pass: affected history, no mutation of originals, idempotence, unrelated edits, existing signatures, other providers, malformed payloads, non-incremental history, and the installed OMP-only guard.
  • Two additional sanitized-fixture tests against the current upstream planner pass.
  • Guard tests confirm the mitigation is not invoked for non-OMP harness values and preserves the exact event object there. These are isolation tests, not full OpenCode/Pi/Rust end-to-end suites.
  • Local bundle syntax validation passes. No online post-restart round trip or full upstream test suite has been claimed.

Harness coverage / impact

Harness Proposed change / evidence
OMP Target surface; positive regression coverage using OMP encoder and actual adapter/planner logic.
Pi Shares the package, but OMP guard must remain false; negative guard coverage.
OpenCode 1 Separate adapter; this OMP hook/replay-normalization surface is absent; no source/config/schema change proposed.
OpenCode 2 Separate adapter; this OMP hook/replay-normalization surface is absent; no source/config/schema change proposed.
Rust module OMP host-specific hook is absent; no Rust or shared schema/config change proposed.

Following CONTRIBUTING.md, this issue requests agreement on the fix before a PR. Please confirm the preferred recovery approach and apply design-approved when appropriate. A PR would include sanitized regressions rather than private session data.

Environment / diagnostics

  • Magic Context: 0.49.16
  • Client: OMP 18.12.6
  • Platform: macOS
  • Bun: 1.4.2
  • API: openai-responses

Activity

  1. sisttri commented on Oct 23, 2026

    @sisttri
    Author

    I hit this immediately after running /fork, then /rename in OMP. The next prompt was refused with comparison=content; retrying and restarting the forked session did not help.

    Comparing the parent and forked transcripts showed that all seven affected messages already had the same text, signature state, and native payload in the parent. Forking copied the existing inconsistency. Adding or removing the rename record made no difference to the offline request payload.

    So /fork may expose the existing replay mismatch when the new session prepares its first request, but I have not isolated that transition yet. /rename currently appears unrelated. Please include the first request after a fork in regression coverage, and compare fork-only with fork-then-rename.

  2. magic-alfonso commented on Oct 23, 2026

    @magic-alfonso

    Thanks for the precise report and the reproduction, it makes this easy to check. The planner and dispatch check you point at are ours, and we're verifying each step against Oh My Pi's own source now, including the first request after a fork. We'll reply here with what we find and which fix we pick. Please hold off on a pull request until then.

  3. magic-alfonso commented on Oct 23, 2026

    @magic-alfonso

    Confirmed. Your trace is exact on Oh My Pi 18.12.3 and 18.12.6. A saved session copies the mismatch through a restart and through /fork, with or without /rename.

    We're fixing it on our side by making the request check follow exactly what Oh My Pi replays for these messages. Nothing is rewritten in the request, the session files or the native payload, so native reasoning and phase replay stay intact, and affected sessions recover without any edit. We considered dropping the stale payload, as you proposed, but that loses the text's phase and can lose native-only reasoning items. Signature, structure and every other kind of mismatch still refuse as they do today.

    The fix ships in the next patch, crediting you. Thanks again for the precise report and for holding the PR.

  4. sisttri commented on Oct 23, 2026

    @sisttri
    Author

    I hit this again with a newly generated assistant reply containing two tool calls. The persisted text was Hello, while its native payload was equivalent to §12§ Hello\n with phase: commentary.

    stage=provider dispatch elapsed=232ms
    row=911 expectedRows=914 actualRows=914
    expectedRole=assistant actualRole=assistant
    expectedCalls=2 actualCalls=2 expectedResults=0 actualResults=0
    comparison=content
    

    This adds an important case to the original report: stripPersistedAssistantText() also calls .trim() and strips tag notation beyond the leading prefix. My initial prefix-only workaround missed the trailing newline. This supersedes the leading-prefix-only matching criterion in the issue's local mitigation proposal. I agree that preserving native replay, phase and reasoning is preferable for the upstream fix you described.

    Exact path to inspect

    • stripPersistedAssistantText: tag cleanup followed by .trim().
    • strippedAssistantContent / OMP hook: returns rewritten text.
    • OMP 18.12.6, packages/coding-agent/src/extensibility/extensions/runner.ts, emitAssistantMessage: removes the rewritten block's textSignature; packages/coding-agent/src/sdk.ts, transformAssistantMessage: assigns replacement content while retaining providerPayload.
    • piResponsesAssistantTexts plans typed text/null phase; OMP packages/ai/src/providers/openai-shared.ts, buildResponsesInput, replays native text/commentary phase.

    Portable reproduction — no private environment required

    I tested this in a separate directory with a freshly installed @oh-my-pi/pi-ai@18.12.6 under Bun 1.4.2. All text, model/provider names, IDs, tools and arguments below are synthetic. No API key, model request, session transcript, database, global configuration or local hotfix is required.

    git clone https://github.com/cortexkit/magic-context.git mc-repro
    git -C mc-repro checkout 79ce01a522ae5a9d65d6bb2ac81aadf16fa8285a
    mkdir omp-repro
    cd omp-repro
    bun add @oh-my-pi/pi-ai@18.12.6
    # Save the following TypeScript as repro.ts here.
    MC_ROOT=../mc-repro bun repro.ts

    The script starts from the post-OMP-rewrite message shape, JSON-round-trips it, and exercises the real cleanup function, planner and OMP encoder. It isolates the mismatch; it does not instantiate the host's extension runner or the full context pipeline.

    // Run with Bun from a directory containing @oh-my-pi/pi-ai@18.12.6.
    // MC_ROOT points to a Magic Context checkout; no DB/session/credentials needed.
    import assert from 'node:assert/strict';
    import {resolve, dirname} from 'node:path';
    import {pathToFileURL} from 'node:url';
    const root = process.env.MC_ROOT;
    assert(root, 'Set MC_ROOT to a Magic Context checkout');
    const load = (path: string) => import(pathToFileURL(path).href);
    const {piResponsesAssistantTexts} = await load(resolve(root,
      'packages/pi-plugin/src/pi-dispatch-responses-history.ts'));
    const {stripPersistedAssistantText} = await load(resolve(root,
      'packages/plugin/src/hooks/magic-context/tag-content-primitives.ts'));
    const sdkEntry = Bun.resolveSync('@oh-my-pi/pi-ai', import.meta.dir);
    const {buildResponsesInput} = await load(resolve(dirname(sdkEntry),
      'providers/openai-shared.ts'));
    const model = {api:'openai-responses', provider:'example', id:'gpt-5',
      input:['text'], identity:{class:'gpt'}, compat:{}};
    
    for (const rawText of ['§12§ Hello\n', '  Hello\n', 'Hello §12§\n']) {
      const cleaned = stripPersistedAssistantText(rawText);
      assert.equal(cleaned, 'Hello');
      const calls = [1,2].map(n => ({type:'toolCall',
        id:`call_example_${n}|fc_example_${n}`, name:`example_tool_${n}`,
        arguments:{value:n}}));
      // State after OMP accepts the assistant_message rewrite: textSignature
      // removed from the rewritten block, original providerPayload retained.
      const message = JSON.parse(JSON.stringify({role:'assistant',
        api:model.api, provider:model.provider, model:model.id,
        stopReason:'toolUse', timestamp:1,
        content:[{type:'text', text:cleaned}, ...calls],
        providerPayload:{type:'openaiResponsesHistory', provider:model.provider,
          dt:true, items:[{type:'message', role:'assistant', id:'msg_example',
            phase:'commentary', status:'completed', content:[{
              type:'output_text', text:rawText, annotations:[]}]},
            ...[1,2].map(n => ({type:'function_call', id:`fc_example_${n}`,
              call_id:`call_example_${n}`, name:`example_tool_${n}`,
              arguments:JSON.stringify({value:n}), status:'completed'}))]}}));
      const before = JSON.stringify(message);
      const planned = piResponsesAssistantTexts(message, model);
      const input = buildResponsesInput({model, context:{messages:[message]},
        nativeHistory:{replay:true}, repairOrphanOutputs:true});
      const actual = input.filter(x=>x.type==='message' && x.role==='assistant')
        .flatMap(x=>x.content.filter(p=>p.type==='output_text')
          .map(p=>JSON.stringify([p.text,x.phase ?? null])));
      assert.deepEqual(planned, [JSON.stringify(['Hello', null])]);
      assert.deepEqual(actual, [JSON.stringify([rawText, 'commentary'])]);
      assert.notDeepEqual(planned, actual); // Characterizes the reported bug.
      assert.deepEqual(input.filter(x=>x.type==='function_call').map(x=>({
        call_id:x.call_id,name:x.name,arguments:x.arguments})),
        [1,2].map(n=>({call_id:`call_example_${n}`,name:`example_tool_${n}`,
          arguments:JSON.stringify({value:n})})));
      assert.equal(JSON.stringify(message), before);
      console.log(JSON.stringify({rawText, cleaned, planned, actual, calls:2}));
    }
    console.log('Reproduced all 3 text/phase mismatches without a model request.');

    Expected output on the pinned baseline: all three cases report planned ["Hello",null] versus actual [rawText,"commentary"], with both tool calls preserved, followed by Reproduced all 3 text/phase mismatches without a model request. These assertions characterize the bug; they intentionally expect inequality and should change when converted to the fixed OMP-path regression.

    Acceptance coverage for the upstream fix

    • An OMP-path planner/dispatch regression should match the actual encoder for all three cleanup cases, preserving the native payload and commentary phase.
    • Cover native-only reasoning, signed/unmodified text, multiple text blocks, tool IDs/arguments, and already persisted messages after restart/fork.
    • Keep rejection coverage for genuine mutations after preparation (text/phase/tool arguments/signatures); do not simply relax the fence or apply broad whitespace normalization at dispatch.
    • Keep the behavior host-specific and verify stock Pi / OpenCode paths are unchanged.

    My expanded local mitigation passes 19 tests / 63 assertions. Latest full-history offline replay also changes from a content mismatch to a match, but its row count differs from the live pass because the complete live configuration was not replayed. I have not claimed an online round-trip after that update. This is separate from the tag-snapshot initialization issue #769.

  5. magic-alfonso commented on Oct 25, 2026

    @magic-alfonso

    Shipped in 0.49.21 (@cortexkit/pi-magic-context). This is on Oh My Pi's OpenAI Responses route, when Oh My Pi drops a reply's text signature after Magic Context strips its tag. The request check now accepts Oh My Pi's replayed saved text if Magic Context's own tag cleanup, applied to that text, gives exactly what Magic Context prepared. That covers a trailing newline and tags elsewhere in the text, as well as the leading tag.

    Anything else still refuses: a signature that's present, a different model, provider or API, a different payload type, or a different number of text pieces. Restart Oh My Pi after updating; there's no migration. Thanks, @sisttri.

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