Describe the bug
Related: likely the same defect as #3594 (two reporters — iOS streaming and "via the SDK" — with input[N].id lengths of 380 and 1108). That issue is currently labeled area:networking; the cause below is not networking.
Summary
On a warm session.resume — a resume of a session the runtime still holds in memory — the reducer commits the incoming model selection and returns early, skipping history sanitization. Reasoning metadata captured from an Anthropic-wire response (reasoning_opaque, the thinking-block signature) survives and is later emitted as the id of an OpenAI Responses reasoning input item:
HTTP 400 invalid_request_error
param: input[N].id
code: string_above_max_length
The item stays in history, so sends against the target model keep failing until it is removed. Observed id lengths track Anthropic signature sizes (380–1152 across runs), consistent with both reports in #3594.
The scrub is skipped on every warm resume, changed model or not. The model change only decides when the stale field becomes fatal — when the newly selected model's wire format differs from the one that produced the stored reasoning (Anthropic Messages → OpenAI Responses).
Changing the provider object is not required: the providers/models registries can be identical across both calls; changing only the selected model is sufficient.
Scope
- Embedder-only.
sessionWasActive: true is produced by exactly one site — the session.resume RPC handler, which has no internal caller. copilot --resume takes the cold path, which omits the flag, so the scrubber runs.
- Not limited to custom providers. No step on the failing path — reasoning ingestion,
assistant.message persistence, the resume reducer, the strip gate, or the history hand-off to the native runtime — tests byokProvider, isByokModel, or the providers/models registry.
- Present in both
app.js and the shipped sdk/index.js.
Root cause
app.js @2691469 (sdk/index.js @2507415):
case "session.resume":
if (e.data.selectedModel && this.setSelectedModelState(e.data.selectedModel), // @2691520 switch committed
/* … */,
e.data.sessionWasActive) break; // @2691989 warm exit
/* … */ (this._chatMessages = Wye(this._chatMessages), /* … */), // @2692188 rebuild
this.shouldStripReasoningOnResume() && hte(this._chatMessages); break; // @2692409 strip
hte (@2555331) is the only JavaScript routine that removes reasoning_opaque / reasoning_text / encrypted_content / phase.
sessionWasActive = !!f @3141854, from an in-memory session lookup @3141491. Not settable by the caller.
- The reducer performs no provider or model comparison; both are applied independently via
updateOptions @3145302, which reaches setSelectedModelState but emits no session.model_change — so the other sanitizer entry point never fires either.
- The survivor becomes the reasoning id:
reasoning_opaque @2842861 → messageReasoningId @2842950 → nGe @2542474 (streamingReasoningId ?? messageReasoningId ?? null). Final serialization is native; cited here as observed HTTP behavior.
Gap
- Two history-preserving model-switch entry points; only
model.set sanitizes (applyModelChange @2674031 → emit @2674453 → rewrite → hte).
COPILOT_STRIP_REASONING_ON_RESUME (@2645581, default = strip) is evaluated only at @2692377 — after the break. Inert here.
- Guards key on model identity alone:
lastEffectiveModelForHistoryRewrite === e @2684165. No wire-format comparison on this path.
- No dependable origin marker.
reasoning_wire_field is persisted (@2688141) with zero reads, but it is a dialect text field written only when reasoning text exists.
A cross-wire id is invalid at any length — the Responses reasoning.id is server-issued and must be replayed as returned. Clamping is not a fix.
Proposed fix
- Type the opaque state (primary). Record provider instance + wire family alongside reasoning state and enforce it where Responses input items are constructed. When provenance is absent or foreign, omit the entire reasoning item, not just its
id. Do not rely on reasoning_wire_field; the ingestion-side nGe path alone is insufficient, as it can mint a fallback id when reasoning text remains.
- Sanitize on warm resume. Add a compatibility-aware pass before the early exit, triggered when the effective wire format changed. Do not move
hte() unconditionally — same-wire resumes must replay reasoning items untouched, and hte also deletes encrypted reasoning and phase.
- Share the transition. Do not reuse
applyModelChange directly: it always emits session.model_change, carries no wire identity, and its rewrite guard starts unset, so even a same-model resume could strip valid history. Factor an atomic helper keyed by effective (provider, wire, model) that no-ops when unchanged.
Tests
Warm cross-wire resume asserting no foreign id reaches the wire; provider-only change with unchanged model id; missing provenance; cold/warm parity; same-wire exact replay (valid ids survive, none fabricated).
Evidence
@github/copilot-linux-x64@1.0.71, package/app.js — sha256 1466298c97677da25f484259e49c450e059f7b426498de8bbbcf007233a488b9, 9,089,681 bytes. Offsets are byte offsets into that file; package/sdk/index.js (2,815,595 bytes) contains the same reducer at @2507415.
Affected version
1.0.71
Steps to reproduce the behavior
Reproduction
Both resumes must reach the same live runtime, session still resident. Shown at the RPC level; every binding exposes the same call.
session.resume { sessionId: S, model: "<anthropic-wire-model>" }
→ send a turn that yields a thinking block
// same runtime, session still resident → sessionWasActive: true
session.resume { sessionId: S, model: "<responses-wire-model>" } // registries unchanged
→ send a turn // → 400
Controls (both pass): cold resume in a fresh process; or switching via model.set.
A single client cannot resume a session it already tracks — attach a second client to the same runtime, per SessionResumeData.SessionWasActive: "an extension joining a session another client was actively driving." No environment overrides needed: with COPILOT_STRIP_REASONING_ON_RESUME unset (default: strip), the signature still survives.
Verified on CLI 1.0.71, two clients on one runtime, CLI defaults.
Expected behavior
Expected: incompatible reasoning metadata removed or translated before the target request.
Actual: the opaque signature becomes a Responses reasoning.id.
Additional context
TL;DR: When I use the CLI with one model family and switch to another (e.g. Opus 5 → GPT-5.6-Reasoning) during session.resume, I get an error message:
{
"message": "Invalid 'input[3].id': string too long. Expected a string with maximum length 64, but got a string with length 592 instead.",
"type": "invalid_request_error",
"param": "input[3].id",
"code": "string_above_max_length"
}
This is because there is no sanitization when switching model families.
Describe the bug
Related: likely the same defect as #3594 (two reporters — iOS streaming and "via the SDK" — with
input[N].idlengths of 380 and 1108). That issue is currently labeledarea:networking; the cause below is not networking.Summary
On a warm
session.resume— a resume of a session the runtime still holds in memory — the reducer commits the incoming model selection and returns early, skipping history sanitization. Reasoning metadata captured from an Anthropic-wire response (reasoning_opaque, the thinking-block signature) survives and is later emitted as theidof an OpenAI Responsesreasoninginput item:The item stays in history, so sends against the target model keep failing until it is removed. Observed id lengths track Anthropic signature sizes (380–1152 across runs), consistent with both reports in #3594.
The scrub is skipped on every warm resume, changed model or not. The model change only decides when the stale field becomes fatal — when the newly selected model's wire format differs from the one that produced the stored reasoning (Anthropic Messages → OpenAI Responses).
Changing the
providerobject is not required: the providers/models registries can be identical across both calls; changing only the selected model is sufficient.Scope
sessionWasActive: trueis produced by exactly one site — thesession.resumeRPC handler, which has no internal caller.copilot --resumetakes the cold path, which omits the flag, so the scrubber runs.assistant.messagepersistence, the resume reducer, the strip gate, or the history hand-off to the native runtime — testsbyokProvider,isByokModel, or the providers/models registry.app.jsand the shippedsdk/index.js.Root cause
app.js@2691469 (sdk/index.js@2507415):hte(@2555331) is the only JavaScript routine that removesreasoning_opaque/reasoning_text/encrypted_content/phase.sessionWasActive=!!f@3141854, from an in-memory session lookup @3141491. Not settable by the caller.updateOptions@3145302, which reachessetSelectedModelStatebut emits nosession.model_change— so the other sanitizer entry point never fires either.reasoning_opaque@2842861 →messageReasoningId@2842950 →nGe@2542474 (streamingReasoningId ?? messageReasoningId ?? null). Final serialization is native; cited here as observed HTTP behavior.Gap
model.setsanitizes (applyModelChange@2674031 → emit @2674453 → rewrite →hte).COPILOT_STRIP_REASONING_ON_RESUME(@2645581, default = strip) is evaluated only at @2692377 — after thebreak. Inert here.lastEffectiveModelForHistoryRewrite === e@2684165. No wire-format comparison on this path.reasoning_wire_fieldis persisted (@2688141) with zero reads, but it is a dialect text field written only when reasoning text exists.A cross-wire id is invalid at any length — the Responses
reasoning.idis server-issued and must be replayed as returned. Clamping is not a fix.Proposed fix
id. Do not rely onreasoning_wire_field; the ingestion-sidenGepath alone is insufficient, as it can mint a fallback id when reasoning text remains.hte()unconditionally — same-wire resumes must replay reasoning items untouched, andhtealso deletes encrypted reasoning andphase.applyModelChangedirectly: it always emitssession.model_change, carries no wire identity, and its rewrite guard starts unset, so even a same-model resume could strip valid history. Factor an atomic helper keyed by effective (provider, wire, model) that no-ops when unchanged.Tests
Warm cross-wire resume asserting no foreign id reaches the wire; provider-only change with unchanged model id; missing provenance; cold/warm parity; same-wire exact replay (valid ids survive, none fabricated).
Evidence
@github/copilot-linux-x64@1.0.71,package/app.js— sha2561466298c97677da25f484259e49c450e059f7b426498de8bbbcf007233a488b9, 9,089,681 bytes. Offsets are byte offsets into that file;package/sdk/index.js(2,815,595 bytes) contains the same reducer at @2507415.Affected version
1.0.71
Steps to reproduce the behavior
Reproduction
Both resumes must reach the same live runtime, session still resident. Shown at the RPC level; every binding exposes the same call.
Controls (both pass): cold resume in a fresh process; or switching via
model.set.A single client cannot resume a session it already tracks — attach a second client to the same runtime, per
SessionResumeData.SessionWasActive: "an extension joining a session another client was actively driving." No environment overrides needed: withCOPILOT_STRIP_REASONING_ON_RESUMEunset (default: strip), the signature still survives.Verified on CLI 1.0.71, two clients on one runtime, CLI defaults.
Expected behavior
Expected: incompatible reasoning metadata removed or translated before the target request.
Actual: the opaque signature becomes a Responses
reasoning.id.Additional context
TL;DR: When I use the CLI with one model family and switch to another (e.g. Opus 5 → GPT-5.6-Reasoning) during
session.resume, I get an error message:{ "message": "Invalid 'input[3].id': string too long. Expected a string with maximum length 64, but got a string with length 592 instead.", "type": "invalid_request_error", "param": "input[3].id", "code": "string_above_max_length" }This is because there is no sanitization when switching model families.