Skip to content

bug(omp): signed Responses replay fails after tag renumbering (0.49.21) #778

Open

Description

@sisttri

What happened?

I reproduced a remaining OMP Responses dispatch refusal on Magic Context 0.49.21 when existing assistant history retains both a textSignature and a leading MC tag.

The saved typed text and native payload text initially match, including the signature's ID and phase. MC then renumbers the typed text's tag, while OMP replays the original native text. The dispatch check refuses the turn with comparison=content.

This is related to #766, but that release fix covers messages whose signature was removed. This case retains its signature. I have confirmed the existing-history case; I have not established that a fresh session running only 0.49.21 produces this history.

Independent reproduction

The script below uses three synthetic messages and a new database. It exercises the published context handler, OMP's actual Responses encoder, and the actual before_provider_request callback. Host event registration and session-manager access use test doubles. No account, API key, model request, transcript, existing database, or personal configuration is required.

Saved text: typed = native MC-prepared text OMP replay Official 0.49.21
Hello §2§ Hello Hello Pass
§2§ Hello §2§ Hello §2§ Hello Pass
§42§ Hello §2§ Hello §42§ Hello Refused

The failing case produces:

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

In a disposable directory, save the script below as repro.ts, then run:

bun add @cortexkit/pi-magic-context@0.49.21 @oh-my-pi/pi-ai@18.12.6
NODE_ENV=test bun repro.ts
NODE_ENV=test bun repro.ts --patched

The script checks the official bundle's SHA-256 and adds internal exports only to a temporary sibling copy. Default mode leaves implementation logic unchanged; its assertions characterize the bug, so a successful reproduction exits 0. --patched changes only that temporary copy and expects all three cases to pass. Every passing case also checks that a subsequent wire-body mutation is rejected.

Complete standalone repro.ts
// Portable offline reproduction. Requires Bun, no model request or credentials.
// In a disposable directory: bun add @cortexkit/pi-magic-context@0.49.21 @oh-my-pi/pi-ai@18.12.6
// NODE_ENV=test bun /path/to/magic-context-signed-ordinal-repro.ts
// Optional differential check: add --patched (patches ONLY a temporary harness copy).
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {dirname,join} from 'node:path';
import {tmpdir} from 'node:os';
import {pathToFileURL} from 'node:url';
import {createHash,randomUUID} from 'node:crypto';
process.env.NODE_ENV='test';
const patched=process.argv.includes('--patched');
const mcEntry=Bun.resolveSync('@cortexkit/pi-magic-context',process.cwd());
const mcDist=dirname(mcEntry);
const aiEntry=Bun.resolveSync('@oh-my-pi/pi-ai',process.cwd());
assert.equal(JSON.parse(fs.readFileSync(join(mcDist,'../package.json'),'utf8')).version,'0.49.21');
assert.equal(JSON.parse(fs.readFileSync(join(dirname(aiEntry),'../package.json'),'utf8')).version,'18.12.6');
let source=fs.readFileSync(mcEntry,'utf8');
assert.equal(createHash('sha256').update(source).digest('hex'),'5ee37f6ab08768ae87b5aca65af99dd64e8dde2de22982c9d8da399acd41b665','Requires unmodified official 0.49.21 bundle');
if(patched){
 const old='part.text === block.text.replace(/^§\\d+§ /u, "")';
 const replacement='(part.text === block.text.replace(/^§\\d+§ /u, "") || host === "omp" && /^§\\d+§ /u.test(part.text) && /^§\\d+§ /u.test(block.text) && part.text.replace(/^§\\d+§ /u, "") === block.text.replace(/^§\\d+§ /u, ""))';
 assert.equal(source.split(old).length,2);
 source=source.replace(old,replacement);
}
const entry=join(mcDist,`.signed-ordinal-repro-${randomUUID()}.js`);
// Export private helpers in a disposable sibling so relative chunk imports resolve.
fs.writeFileSync(entry,source+'\nexport {registerPiContextHandler,clearContextHandlerSession,piAgentHistory,buildPiPreparedStructure,piPreparedStructureMismatch};\n',{flag:'wx'});
const load=(p:string)=>import(pathToFileURL(p).href);
const {Database2:Database}=await load(join(mcDist,'index-2qtx0q6h.js'));
const {initializeDatabase}=await load(join(mcDist,'index-dbgg6hsf.js'));
const {runMigrations}=await load(join(mcDist,'index-3c3ztwgc.js'));
const {setHarness2}=await load(join(mcDist,'index-0h2mkab2.js'));
const {drainPiTagSnapshotSession}=await load(join(mcDist,'index-5j6vwg4z.js'));
const {registerPiContextHandler,clearContextHandlerSession,piAgentHistory,buildPiPreparedStructure,piPreparedStructureMismatch}=await load(entry);
const {buildResponsesInput,encodeTextSignatureV1}=await load(join(dirname(aiEntry),'providers/openai-shared.ts'));
try {
globalThis[Symbol.for('magic-context.pi.harness-detection')]={kind:'omp',via:'test-override'};
setHarness2('pi');
const dir=fs.mkdtempSync(join(tmpdir(),'mc-signed-ordinal-'));
const model={api:'openai-responses',provider:'example',id:'gpt-5',input:['text'],identity:{class:'gpt'},compat:{},contextWindow:200000,maxTokens:16000};
for(const [caseIndex,rawText] of ['Hello','§2§ Hello','§42§ Hello'].entries()) {
 const sid=`synthetic-case-${caseIndex}`;
 const signature=encodeTextSignatureV1('msg_example','final_answer');
 const assistant={role:'assistant',api:model.api,provider:model.provider,model:model.id,stopReason:'stop',timestamp:2,content:[{type:'text',text:rawText,textSignature:signature}],providerPayload:{type:'openaiResponsesHistory',provider:model.provider,dt:true,items:[{type:'message',role:'assistant',id:'msg_example',phase:'final_answer',status:'completed',content:[{type:'output_text',text:rawText,annotations:[]}]}]}};
 const messages=JSON.parse(JSON.stringify([{role:'user',content:'Say hello',timestamp:1},assistant,{role:'user',content:'Continue',timestamp:3}]));
 const entries=messages.map((message,i)=>({type:'message',id:`entry_${i}`,parentId:i?`entry_${i-1}`:null,timestamp:new Date(i+1).toISOString(),message}));
 const db=new Database(`${dir}/${sid}.db`);initializeDatabase(db);runMigrations(db);
 const handlers=new Map();const refusals=[];let aborted=false;
 const pi={on:(n,f)=>handlers.set(n,f),registerCommand:()=>{},appendEntry:(_type,data)=>{if(data?.message)refusals.push(data.message)}};
 const ctx={cwd:dir,hasUI:false,signal:new AbortController().signal,ui:{notify:()=>{}},abort:()=>{aborted=true},model,getContextUsage:()=>({tokens:100,percent:0.05,contextWindow:200000}),sessionManager:{getSessionId:()=>sid,getBranch:()=>entries,getEntries:()=>entries,getEntry:id=>entries.find(e=>e.id===id),getLeafId:()=>entries.at(-1).id}};
 registerPiContextHandler(pi as never,{db,protectedTags:20});
 handlers.get('agent_start')?.({},ctx);
 const result=await handlers.get('context')({messages},ctx);
 assert(!aborted,JSON.stringify(refusals));assert(result?.messages);
 const transformed=result.messages.find(m=>m.role==='assistant');
 const input=buildResponsesInput({model,context:{messages:piAgentHistory(result.messages,model.api)},nativeHistory:{replay:true},repairOrphanOutputs:true});
 const mismatch=piPreparedStructureMismatch({input},buildPiPreparedStructure(result.messages,model,undefined,'omp'));
 handlers.get('before_provider_request')({payload:{input}},ctx);
 assert.equal(transformed.content.find(p=>p.type==='text').textSignature,signature);
 assert.deepEqual(transformed.providerPayload,assistant.providerPayload);
 assert.equal(aborted,!patched && rawText==='§42§ Hello');
 console.log(JSON.stringify({rawText,preparedText:transformed.content.find(p=>p.type==='text').text,wireText:input.find(x=>x.type==='message'&&x.role==='assistant')?.content[0].text,aborted,mismatch:mismatch??'MATCH',refusals}));
 if(!aborted){
  input.find(x=>x.type==='message'&&x.role==='assistant').content[0].text+=' CHANGED';
  assert(piPreparedStructureMismatch({input},buildPiPreparedStructure(result.messages,model,undefined,'omp')),'Post-prepare body mutation must still refuse');
 }
 clearContextHandlerSession(sid);await drainPiTagSnapshotSession(sid);db.close();
}

console.log(patched?'Patched copy: all three cases pass; post-prepare body mutations refuse.':'Official release: untagged and unchanged-ordinal controls pass; signed ordinal renumbering reproduces the refusal.');
fs.rmSync(dir,{recursive:true,force:true});
} finally { fs.unlinkSync(entry); }

Code path and expected behavior

In piResponsesAssistantTexts() at the 0.49.21 release commit, the signed binding uses:

part.text === (block.text as string).replace(/^§\d+§ /u, "")

This strips only the prepared side. When the native text also has a leading tag, binding fails. The unsigned fallback excludes blocks containing textSignature, so the planner falls back to the prepared text while OMP replays the native text. Different ordinals then cause the refusal. The unchanged-ordinal control passes because the fallback text already equals the wire text.

I would expect MC's own ordinal-only renumbering to preserve replay eligibility when the existing ID/phase and API/model/provider checks pass, without rewriting the native payload or weakening the dispatch check.

The optional local comparison accepts two leading tags with equal remaining text, under an OMP-only guard. It fixes this reproduction while preserving native text, signature metadata, and phase. This is a candidate approach for review, not a request to bypass arbitrary signature or content mismatches.

Diagnostics and scope

  • Magic Context: @cortexkit/pi-magic-context@0.49.21, unmodified published baseline.
  • OMP / encoder: @oh-my-pi/pi-ai@18.12.6; Bun 1.4.2; macOS.
  • Route reproduced: openai-responses, same model/provider, incremental native history (dt: true).
  • Validation is offline; no provider acceptance or complete OMP lifecycle test is claimed. Fork/rename is not needed by this reproduction and has not been established as the cause.
  • Suggested regression scope: ordinal-only changes, genuine body/ID/phase mismatches, and post-preparation mutations. Preserve native reasoning items and tool-call checks; keep any exception OMP-specific and verify shared Pi behavior. No change to OpenCode adapters or the Rust module is proposed; their full suites have not been run.

Could you confirm the intended handling of this signed historical shape and the preferred fix scope before a PR?

Activity

  1. magic-alfonso commented on Oct 25, 2026

    @magic-alfonso

    Thanks for the precise, self-contained reproduction. Your reading of the code is right. In piResponsesAssistantTexts, the signed binding strips a leading tag only from the prepared text (pi-dispatch-responses-history.ts:223). So a signed native text that kept its own leading tag never binds, the check falls back to the prepared text, and OMP's replay of the native text then differs.

    The refusal itself is the check doing its job, though, and a comparison that accepts two different leading tags isn't the fix we'd take. In your failing case the wire would show the model §42§ on a message Magic Context numbers §2§. Tag numbers are what ctx_reduce targets, so a stale number in the request can make a later drop hit the wrong item. That's the same reason we kept the comparison strict in issue 686. A message that keeps refusing every turn isn't acceptable either, so the fix has to make the request carry the right number (or none), not let a mismatched one through.

    Two questions that would help us size it:

    1. Have you seen this on a real session, not only the synthetic one? If so, did the affected assistant messages come from before 0.49.15? That's the release where Oh My Pi's saved assistant text started having the copied tag stripped at save time, which is what normally keeps the saved text and the native payload from carrying a stale tag.
    2. Roughly how many such messages does an affected session hold?

    This is queued behind work we're finishing now, so a fix will take a little while. Please hold off on a PR until we've settled the shape; we'll post here when we have.

  2. sisttri commented on Oct 26, 2026

    @sisttri
    Author

    Yes, I encountered this in a real existing OMP session; the synthetic reproduction was derived from that failure.

    1. Age / version evidence

    In the original affected session, all three matching assistant messages have both message and persistence-entry timestamps approximately four days before the 0.49.15 release. This supports the pre-fix-history explanation. The records I checked do not establish the exact plugin version loaded when those messages were generated, so I cannot name that version reliably.

    I also found the same stored shape in another existing session, with timestamps approximately 12 hours after that release. Publication time does not prove that a running OMP process had upgraded/restarted, so I am not claiming this demonstrates a failure of the 0.49.15 save-time fix.

    2. Counts

    History checked Matching assistant messages Evidence
    Original affected session 3 Real failure; official 0.49.21 offline context-handler/encoder/dispatch replay also refuses
    Second existing session 3 Stored-shape match only; not independently confirmed as a signed-ordinal dispatch failure

    Each matching message has one signed text block. I counted only Responses incremental native history (dt: true) where the typed/native text initially matches exactly, both carry a leading tag, and signature ID/phase match the corresponding native item. The count is 3 in both the historical branch inspected and the complete transcript for each session; it excludes unsigned cases from #766. These are selected sessions, not a prevalence estimate.

    In the original session's captured offline preparation, all three matching messages survive, but only one has its ordinal changed. That is the dispatch-blocking case; the other two retain equal typed/native ordinals. No private transcript or database is needed for the standalone reproduction already in this issue.

    I agree with your point about ctx_reduce: allowing a stale ordinal through is not a sufficient fix. The local comparison demonstrated the mismatch, but did not validate subsequent tag-target selection.

  3. magic-alfonso commented on Oct 26, 2026

    @magic-alfonso

    Thanks, that's exactly what we needed. Messages saved about four days before 0.49.15 fit the explanation: the model's own tag was stored in both the typed text and the signed native copy before the save-time cleanup existed. So when Magic Context renumbers that message, the native copy Oh My Pi replays no longer matches, and the turn is refused.

    As you noted, the fix can't let the stale number through. For these old messages the request has to carry either the current number or no number at all, so a later ctx_reduce can never pick the wrong item. That's the direction we'll take. The fix is queued behind work we're finishing first, and we'll post here when it lands. Your standalone reproduction is enough on our side, so no private data is needed.

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