Repository navigation
bug(omp): signed Responses replay fails after tag renumbering (0.49.21) #778
Description
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 --patchedThe 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; Bun1.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
magic-alfonso commentedon Oct 25, 2026
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 whatctx_reducetargets, 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:
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.
sisttri commentedon Oct 26, 2026
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
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.magic-alfonso commentedon Oct 26, 2026
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_reducecan 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.