fix(claude-sdk-oauth): keep the Claude binding across a model-selector excursion and report the recorded invalidation cause - #1749
Merged
Conversation
…pped invalidation cause Two failing regressions for senpi#1747: - leaving this provider through the model selector must keep the binding and the sidecar so the return trip reattaches at the recorded prefix; - a binding invalidated with a recorded ledger reason must report THAT reason on the next turn instead of the no-record default registry_miss.
…nd name the recorded cause model_select treated "the new model is not this provider" as an invalidation and destroyed a resumable binding, so returning to the same Claude model re-sent the whole conversation. It now uses the existing keepBindingThenClose, like thinking_level_select and the in-provider switchSessionModel failure branch: the live SDK session closes, the binding and its sidecar survive, and the return trip reattaches at the recorded prefix. Identity drift, an unconfirmed SDK session id, a missing transcript and a diverged sent stream still flatten. The ledger invalidation reason was written and never read back, so any genuine invalidation surfaced as the no-record default registry_miss. The newest binding ledger record is now carried into the continuity decision input (re-read from the branch on restart, retired by the next marker), and a bootstrap/flatten that would report registry_miss names that cause instead. Fixes #1747
This was referenced Sep 16, 2026
code-yeongyu
added a commit
that referenced
this pull request
Sep 16, 2026
…-security-contract test(claude-sdk-oauth): pin the provider-excursion contract shipped in #1749
Altairpaca
pushed a commit
to Altairpaca/senpi
that referenced
this pull request
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1747
Summary
Cycling the model selector off a Claude model and back re-sent the entire conversation, and then misreported why. Two independent defects, both in the
claude-sdk-oauthcontinuity layer:model_selecttreated "the newly selected model is not this provider" as an invalidation, even though the module already had the non-destructive path for exactly this situation. Leaving the provider now closes the live SDK session and KEEPS the binding, exactly like a thinking-level change, so returning to the same Claude model reattaches at the recorded prefix and sends only the messages added while away.registry_miss. The newest binding ledger record is now carried into the continuity decision input, and a cold-seed that would have saidregistry_missnames the recorded cause instead.No safety net was weakened:
identityDriftstill flattens onmodel_changed, an unconfirmed SDK session id is still refused (session_unconfirmed), and a missing transcript or a diverged sent stream still flattens.registry_missnow means what it says - no record was ever found.Root cause
Defect 1 -
packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts:101-110(onmain):invalidateBinding(session-registry-wiring.ts:36-46) runsforgetBinding,deleteStoredBindingand appends the ledger record.keepBindingThenClose(session-registry-wiring.ts:48-52) closes the live query and keeps the binding;thinking_level_select(:112-114) and the in-providerswitchSessionModelfailure branch already use it. The excursion now uses it too.Defect 2 - the ledger record written at
session-registry-wiring.ts:32-34(BINDING_ENTRY_TYPE,BindingInvalidationinsession-binding.ts:8-16) had exactly one writer and zero readers. With no binding left, the next turn cannot reachdecideFromBinding, so the user-visible reason came from the no-binding defaults:session-stream.ts:105(decision.kind === "bootstrap" ? "registry_miss") andsession-continuity.ts:182,196,199.Fix:
invalidationReasonFromBranch(session-binding.ts) returns the reason of the newest binding ledger record when that record is an invalidation (a later marker retires it);session-registry-wiring.tsrecords it in process memory as it appends, re-reads it from the branch onsession_start(a restart carries the ledger, not the process maps) and retires it with the next marker;session-stream.tspasses it intoContinuityDecisionInput.invalidationReason;session-continuity.tsswaps it in for abootstrap/flattenthat would otherwise reportregistry_miss. Reasons that are already observation vocabulary (model_selected,extensions_removed,assistant_rewritten) pass throughsanitizeReason;compaction,tree_changedandforkmap totainted_compaction,branch_divergedandtainted_fork, so no arbitrary ledger string can reach an observation. Decision classification is otherwise untouched - no kind moves and no other reason changes.Tests
Two new regression files under
packages/coding-agent/test/suite/regressions/, written and proven RED before any production change:1747-model-select-keeps-binding.test.ts- drives the realmodel_selecthandler with a model whose provider is notclaude-sdk-oauthand asserts the in-memory binding is still resolvable, the stored sidecar was not deleted, no invalidation record was appended, and the live session was closed; then selects the same Claude model again and asserts the decision is areattachat the recorded prefix whose delta is only the turn taken while away.1747-invalidation-reason-continuity.test.ts- restarts a session whose ledger carries{ invalidated: true, reason: "model_selected" }, runs a real turn throughstreamClaudeSdkOauthand asserts the emitted continuity observation (the one the transcript notice renders) isflatten/model_selected, neverregistry_miss; the second case provesregistry_missstill appears when nothing was ever recorded.Regression guard
The existing continuity and session-registry tests were NOT edited, and all stay green. Two of them looked like they might encode the destructive behavior as the contract; neither does:
test/claude-sdk-oauth-model-switch.test.ts- "tears the session down when the model leaves this provider" asserts only that nosetModelcall was made and that the live registry entry is gone. Both remain true: the fix closes the live session and keeps only the binding.test/claude-sdk-oauth-session-registry-wiring.test.ts- "does not continue incrementally after switching away from and back to the provider" asserts the live entry is gone and that the decision is not adelta. It hand-feedsbinding: undefinedintodecideNativeContinuityrather than reading the process binding map, so it never encoded "the binding is destroyed"; its real contract (no incremental continuation on a dead live query) still holds, because the return trip is areattach, not adelta.Verification
Run on a second machine (this repo's tests are never run on the authoring host). Focused files plus every existing
claude-sdk-oauthcontinuity / session-registry suite, all from the pushed branch.RED (test-only commit 094e0b8, before the fix)
3 failed, 1 passed - each failure behavioral, not a missing import
GREEN (HEAD 02d34a5, after the fix)
21 files, 155 tests, all passing
Static gates on the same machine at the same commit:
biome check --error-on-warningsover the touched provider directory and both new test files - clean;tsc --noEmitover the workspace - exit 0;node scripts/check-pr-changelog.mjs --base origin/main-PASS - changes.md coverage complete; changelog entry updated. The remote working copy was removed afterwards and verified gone.Related
This is the SECOND cause of the same user-visible symptom (a Claude turn re-sending the whole conversation and blaming
registry_miss). The first cause is #1472 - harness argument normalization classified asassistant_rewritten- addressed by the open PR #1498. The two are independent: this PR does not touch the eval-summary clamp path (session-commit-boundary.ts) that #1498 owns.Summary by cubic
Fixes #1747: cycling the model selector off a Claude model and back re-sent the entire conversation and then misreported why, because the excursion destroyed a resumable binding and the recorded invalidation cause was never read back.
Bug Fixes
claude-sdk-oauthprovider now closes the live SDK session but keeps the binding, so returning to the same Claude model reattaches at the recorded prefix and sends only the messages added while away.registry_misson the next turn;registry_missnow strictly means no binding record was ever found.compaction→tainted_compaction,tree_changed→branch_diverged,fork→tainted_fork).Written for commit 02d34a5. Summary will update on new commits.