Skip to content

fix(claude-sdk-oauth): keep the Claude binding across a model-selector excursion and report the recorded invalidation cause - #1749

Merged
code-yeongyu merged 4 commits into
mainfrom
fix/1747-model-select-binding-continuity
Sep 16, 2026
Merged

code-yeongyu merged 4 commits into
mainfrom
fix/1747-model-select-binding-continuity

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Sep 16, 2026

Copy link
Copy Markdown
Owner

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-oauth continuity layer:

  1. A provider excursion destroyed a resumable binding. model_select treated "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.
  2. The recorded invalidation cause never reached the user. The reason was appended to the ledger and never read back, so the next turn reported the no-record default registry_miss. The newest binding ledger record is now carried into the continuity decision input, and a cold-seed that would have said registry_miss names the recorded cause instead.

No safety net was weakened: identityDrift still flattens on model_changed, an unconfirmed SDK session id is still refused (session_unconfirmed), and a missing transcript or a diverged sent stream still flattens. registry_miss now 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 (on main):

pi.on("model_select", async (event, ctx) => {
  const sessionId = ctx.sessionManager.getSessionId();
  if (event.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) {
    closeSession(sessionId, "model_selected");       // line 104
    await invalidateBinding(pi, ctx, "model_selected"); // line 105 - destroys the sidecar
    return;
  }
  if (!(await switchSessionModel(sessionId, event.model.id))) {
    keepBindingThenClose(sessionId, "model_selected"); // line 109 - keeps it
  }
});

invalidateBinding (session-registry-wiring.ts:36-46) runs forgetBinding, deleteStoredBinding and 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-provider switchSessionModel failure 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, BindingInvalidation in session-binding.ts:8-16) had exactly one writer and zero readers. With no binding left, the next turn cannot reach decideFromBinding, so the user-visible reason came from the no-binding defaults: session-stream.ts:105 (decision.kind === "bootstrap" ? "registry_miss") and session-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.ts records it in process memory as it appends, re-reads it from the branch on session_start (a restart carries the ledger, not the process maps) and retires it with the next marker; session-stream.ts passes it into ContinuityDecisionInput.invalidationReason; session-continuity.ts swaps it in for a bootstrap/flatten that would otherwise report registry_miss. Reasons that are already observation vocabulary (model_selected, extensions_removed, assistant_rewritten) pass through sanitizeReason; compaction, tree_changed and fork map to tainted_compaction, branch_diverged and tainted_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 real model_select handler with a model whose provider is not claude-sdk-oauth and 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 a reattach at 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 through streamClaudeSdkOauth and asserts the emitted continuity observation (the one the transcript notice renders) is flatten/model_selected, never registry_miss; the second case proves registry_miss still 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 no setModel call 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 a delta. It hand-feeds binding: undefined into decideNativeContinuity rather 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 a reattach, not a delta.

Verification

Run on a second machine (this repo's tests are never run on the authoring host). Focused files plus every existing claude-sdk-oauth continuity / 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
$ vitest --run "--reporter=verbose" "test/suite/regressions/1747-model-select-keeps-binding.test.ts" "test/suite/regressions/1747-invalidation-reason-continuity.test.ts"

 RUN  v4.1.11 /private/tmp/discord7-1747/repo/packages/coding-agent

 × test/suite/regressions/1747-model-select-keeps-binding.test.ts > issue #1747 model selector keeps a resumable Claude binding > keeps the binding and the sidecar when the selected model leaves this provider 9ms
   → expected undefined to match object { …(2) }
 × test/suite/regressions/1747-model-select-keeps-binding.test.ts > issue #1747 model selector keeps a resumable Claude binding > reattaches at the recorded prefix when the same Claude model is selected again 2ms
   → expected { kind: 'bootstrap' } to match object { kind: 'reattach', …(2) }
 × test/suite/regressions/1747-invalidation-reason-continuity.test.ts > issue #1747 recorded invalidation cause reaches the next turn > names the recorded reason instead of registry_miss 52ms
   → expected [ { kind: 'flatten', …(4) } ] to deep equally contain ObjectContaining{…}
 ✓ test/suite/regressions/1747-invalidation-reason-continuity.test.ts > issue #1747 recorded invalidation cause reaches the next turn > still reports registry_miss when no invalidation was ever recorded 2ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  test/suite/regressions/1747-invalidation-reason-continuity.test.ts > issue #1747 recorded invalidation cause reaches the next turn > names the recorded reason instead of registry_miss
AssertionError: expected [ { kind: 'flatten', …(4) } ] to deep equally contain ObjectContaining{…}

- Expected:
ObjectContaining {
  "kind": "flatten",
  "reason": "model_selected",
}

+ Received:
[
  {
    "collapsedDirectives": 0,
    "deltaMessages": 2,
    "kind": "flatten",
    "payloadBytes": 290,
    "reason": "registry_miss",
  },
]

 ❯ test/suite/regressions/1747-invalidation-reason-continuity.test.ts:132:20
    130|   const observed = await restartAndPrompt("issue-1747-recorded", "mode…
    131|
    132|   expect(observed).toContainEqual(expect.objectContaining({ kind: "fla…
       |                    ^
    133|   expect(observed.map((observation) => observation.reason)).not.toCont…
    134|  });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯

 FAIL  test/suite/regressions/1747-model-select-keeps-binding.test.ts > issue #1747 model selector keeps a resumable Claude binding > keeps the binding and the sidecar when the selected model leaves this provider
AssertionError: expected undefined to match object { …(2) }

- Expected:
{
  "sdkSessionId": "01a0a86c-7f34-71f6-9747-6618618882e5",
  "sentCount": 1,
}

+ Received:
undefined

 ❯ test/suite/regressions/1747-model-select-keeps-binding.test.ts:69:34
     67|
     68|   expect(getSession(SESSION_ID)).toBeUndefined();
     69|   expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: turn.sd…
       |                                  ^
     70|   expect(await readStoredBinding(turn.sessionFile)).toMatchObject({ sd…
     71|   expect(turn.extension.persisted).toEqual([{ customType: BINDING_ENTR…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/3]⎯

 FAIL  test/suite/regressions/1747-model-select-keeps-binding.test.ts > issue #1747 model selector keeps a resumable Claude binding > reattaches at the recorded prefix when the same Claude model is selected again
AssertionError: expected { kind: 'bootstrap' } to match object { kind: 'reattach', …(2) }

- Expected
+ Received

  {
-   "from": 1,
-   "kind": "reattach",
-   "sdkSessionId": "01a0a86c-7f3c-7606-a75e-2a99f2ca6908",
+   "kind": "bootstrap",
  }

 ❯ test/suite/regressions/1747-model-select-keeps-binding.test.ts:102:20
    100|   });
    101|
    102|   expect(decision).toMatchObject({ kind: "reattach", sdkSessionId: tur…
       |                    ^
    103|   expect(awayHashes).toHaveLength(3);
    104|  });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/3]⎯


 Test Files  2 failed (2)
      Tests  3 failed | 1 passed (4)
   Start at  13:14:37
   Duration  929ms (transform 999ms, setup 26ms, import 1.52s, tests 65ms, environment 0ms)

error: script "test" exited with code 1
EXIT=1

GREEN (HEAD 02d34a5, after the fix)

21 files, 155 tests, all passing
HEAD 02d34a5ae style(claude-sdk-oauth): satisfy biome and the sent-stream type in the #1747 regressions
$ vitest --run "--reporter=verbose" "test/suite/regressions/1747-model-select-keeps-binding.test.ts" "test/suite/regressions/1747-invalidation-reason-continuity.test.ts" test/claude-sdk-oauth-continuity-decision.test.ts test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts test/claude-sdk-oauth-session-registry-wiring.test.ts test/claude-sdk-oauth-session-registry.test.ts test/claude-sdk-oauth-model-switch.test.ts test/claude-sdk-oauth-binding-persistence.test.ts test/claude-sdk-oauth-restart-binding-drift.test.ts test/claude-sdk-oauth-unconfirmed-binding.test.ts test/claude-sdk-oauth-bootstrap-classification.test.ts test/claude-sdk-oauth-flatten-demotion.test.ts test/claude-sdk-oauth-restored-security.test.ts test/claude-sdk-oauth-observability.test.ts test/claude-sdk-oauth-diagnostic-render.test.ts "test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts" "test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts" "test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts" "test/suite/regressions/7925-claude-sdk-oauth-resident-commit-boundary.test.ts" "test/suite/regressions/723-claude-sdk-oauth-timeout-abort-retry-continuity.test.ts" "test/suite/regressions/1432-claude-sdk-oauth-failover-reattach.test.ts"

 RUN  v4.1.11 /private/tmp/discord7-1747/repo/packages/coding-agent

 ✓ test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts > claude-sdk-oauth: content-less user messages must not break sent-stream continuity > excludes a content-less user message from the transmitted set 1ms
 ✓ test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts > claude-sdk-oauth: content-less user messages must not break sent-stream continuity > stays a delta when a transient content-less user message disappears 6ms
 ✓ test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts > claude-sdk-oauth: content-less user messages must not break sent-stream continuity > treats a plain append as a delta 0ms
 ✓ test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts > claude-sdk-oauth: content-less user messages must not break sent-stream continuity > still detects a genuine rewrite of already-sent history 0ms
 ✓ test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts > claude-sdk-oauth: content-less user messages must not break sent-stream continuity > keeps whitespace-only and empty-text-block user messages hashed (fail-closed) 0ms
 ✓ test/suite/regressions/790-claude-sdk-oauth-empty-user-continuity.test.ts > claude-sdk-oauth: content-less user messages must not break sent-stream continuity > treats the disappearance of a whitespace-only user message as divergence 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > reattaches with system_prompt_changed when only the prompt hash drifted 1ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > reattaches with toolset_changed when only the toolset hash drifted 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > reports the prompt first when both fingerprint halves drifted 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > reattaches with the drift reason instead of registry_miss behind a prefix digest 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > lets a sent-stream divergence dominate the drift reason 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > still flattens fail-closed when the model drifts 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > reattaches with account_changed when the account drifts on a shared-root lane 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > flattens with cross_root_unsupported when the account drifts on the config-dir lane 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth restart binding drift (#7884) > still flattens transcript_missing before any drift is considered 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth fingerprint midnight stability (#7884) > stays stable across midnight when extension appends follow the cwd line 6ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth fingerprint midnight stability (#7884) > stays stable across midnight for the bare generated prompt shape 0ms
 ✓ test/claude-sdk-oauth-restart-binding-drift.test.ts > claude-sdk-oauth fingerprint midnight stability (#7884) > stays fail-closed when a trailing append changes content 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > sends only the delta when the live session still matches 1ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > bootstraps when there is neither a live entry nor a persisted binding 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > reattaches to the same session when the query is gone but the binding survives 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > reattaches rather than flattens when the restart fingerprint changed 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > reattaches rather than flattens when the model changed 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > forks at the recorded boundary when a rewrite was committed 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > forks at the last shared boundary when history was rolled back 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > forks when an already-sent message was rewritten in place 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > flattens only when no transcript is available to resume 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > never flattens while a live resident session exists with boundaries 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > flattens with registry_miss when a diverged binding has no assistant boundary 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > fails closed instead of pairing a restored divergence with the wrong assistant boundary 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > forks at the pre-turn boundary when the same turn is retried after a timeout abort 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > cold-seeds a retried first turn that has no assistant boundary to fork at 0ms
 ✓ test/claude-sdk-oauth-continuity-decision.test.ts > claude-sdk-oauth native continuity decisions > flattens when a hash divergence has no assistant boundary to fork at 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > ignores a stale checkpoint once the conversation moved past that turn 1ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > ignores a checkpoint whose pre-turn prefix no longer matches 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > ignores a checkpoint whose restored prefix digest no longer matches 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > honours a checkpoint carried on a restored prefix-digest binding 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > does not let a checkpoint outrank a model drift 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > lets the checkpoint fork a same-turn account failover on a shared-root lane 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > does not let a checkpoint outrank the config-dir lane limit 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > does not let a checkpoint outrank a missing transcript 0ms
 ✓ test/claude-sdk-oauth-continuity-retry-checkpoint.test.ts > claude-sdk-oauth retry checkpoint continuity > never overrides a live resident entry, which owns its own decision 0ms
 ✓ test/claude-sdk-oauth-flatten-demotion.test.ts > claude-sdk-oauth flatten demotion > never flattens a live session across every divergence class 1ms
 ✓ test/claude-sdk-oauth-flatten-demotion.test.ts > claude-sdk-oauth flatten demotion > flattens only when no transcript remains to resume 0ms
 ✓ test/claude-sdk-oauth-flatten-demotion.test.ts > claude-sdk-oauth flatten demotion > keeps a delta for the ordinary next turn 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > cold-seeds a persisted binding when the model drifts 1ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > reattaches a persisted binding when the account drifts on a shared-root lane 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > cold-seeds a persisted binding when the account drifts on the config-dir lane 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > keeps live-entry reattach behavior when account drifts 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > keeps live-entry reattach behavior when model drifts 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > keeps live-entry reattach behavior when system-prompt drifts 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > keeps live-entry reattach behavior when toolset drifts 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > reattaches a persisted binding when system-prompt drifts (#7884) 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > decideNativeContinuity > reattaches a persisted binding when toolset drifts (#7884) 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns false for an empty lookup 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns false when messages belong to a different session_id 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns false when the stored assistant UUID is missing from the transcript 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns false when the only assistant is nested under a tool-use 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns false when lookup throws 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns false for the config-dir auth lane 0ms
 ✓ test/claude-sdk-oauth-restored-security.test.ts > claude-sdk-oauth restored security > verifyRestoredTranscript > returns true for a matching top-level assistant 0ms
 ✓ test/claude-sdk-oauth-binding-persistence.test.ts > Claude SDK OAuth persisted binding lifecycle > restores a trusted sidecar on startup 7ms
 ✓ test/claude-sdk-oauth-binding-persistence.test.ts > Claude SDK OAuth persisted binding lifecycle > restores a trusted sidecar on resume 2ms
 ✓ test/claude-sdk-oauth-binding-persistence.test.ts > Claude SDK OAuth persisted binding lifecycle > does not restore a sidecar whose marker is absent 2ms
 ✓ test/claude-sdk-oauth-binding-persistence.test.ts > Claude SDK OAuth persisted binding lifecycle > keeps the fresher process binding on reload 1ms
 ✓ test/claude-sdk-oauth-binding-persistence.test.ts > Claude SDK OAuth persisted binding lifecycle > clears stale process state when startup has no sidecar 1ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > never resumes a cold-seed id that failed before the SDK acknowledged it (#7562) 5ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > confirms the id from the SDK's replay echo even without an init message 1ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > forgets a resumed id that Claude Code reports as missing 0ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > keeps a dead resumed id forgotten through the retained-attempt discard path 1ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > reports session_unconfirmed through the continuity observation instead of other 0ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > retains a retry checkpoint after init confirms the SDK session id 0ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > records a successful confirmed turn exactly as before 0ms
 ✓ test/claude-sdk-oauth-unconfirmed-binding.test.ts > claude-sdk-oauth unconfirmed continuity bindings > treats a resume-created entry as already confirmed 0ms
 ✓ test/claude-sdk-oauth-model-switch.test.ts > claude-sdk-oauth model and thinking switches > switches models on the live query without starting a new session 3ms
 ✓ test/claude-sdk-oauth-model-switch.test.ts > claude-sdk-oauth model and thinking switches > tears the session down when the model leaves this provider 0ms
 ✓ test/claude-sdk-oauth-model-switch.test.ts > claude-sdk-oauth model and thinking switches > keeps the binding for reattach when the thinking level changes 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > creates queries lazily and reuses the resident entry 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > closes and removes a session for the shutdown reason 2ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > closes and removes a session for the new_session reason 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > closes and removes a session for the switch_session reason 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > closes and removes a session for the process_exit reason 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > makes close idempotent 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > retires and cold-seeds an existing entry idle past the TTL 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > never reuses a generation number across repeated close/reopen cycles 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > cold-seeds a resident entry idle at the TTL on the admission decision path 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > keeps a recently used resident entry incremental on the admission decision path 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > does not retire an entry with a turn in flight after the idle TTL 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > records turn admission and completion and reaps from the completion timestamp 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > fences a canceled reap callback from an active or replacement generation 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > keeps recently completed entries out of the LRU victim slot 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > ignores late synchronized-state writes to a closed and replaced entry 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > evicts expired idle entries using the injected clock 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > enforces the 32-entry cap by evicting the oldest idle or tainted entry 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > never evicts an active turn when an older evictable entry exists 1ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > rejects admission when all 32 resident entries have active turns 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > fences messages from a superseded generation 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > throws for illegal state transitions 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > round-trips assistant UUIDs and branch information 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > reports whether the bound non-environment account token is expiring 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > preserves a matching early terminal result 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > restores sdkResultFailure classification before replay claim 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > claims a turn from the replayed submitted uuid 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > persists the forked session id from the init message 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > buffers pre-replay stream events and flushes them in order 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > closes the query when the pre-replay buffer overflows 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > ends a claimed turn only at its result and returns to idle 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > closes the query for a mismatched result user_message_uuid 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > interrupts once, finishes the aborted turn with partial content, and keeps the lineage 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > throws on a second concurrent turn admission 0ms
 ✓ test/claude-sdk-oauth-session-registry.test.ts > Claude SDK OAuth session registry > discards messages from a superseded generation 0ms
 ✓ test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts > issue #6981 compaction restart continuity > persists the admission projection and reattaches after compaction 11ms
 ✓ test/suite/regressions/1747-model-select-keeps-binding.test.ts > issue #1747 model selector keeps a resumable Claude binding > keeps the binding and the sidecar when the selected model leaves this provider 9ms
 ✓ test/suite/regressions/1747-model-select-keeps-binding.test.ts > issue #1747 model selector keeps a resumable Claude binding > reattaches at the recorded prefix when the same Claude model is selected again 2ms
 ✓ test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts > issue #6981 headless restart continuity > invalidates persisted continuity when the committed assistant is rewritten 6ms
 ✓ test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts > issue #6981 headless restart continuity > restores a sidecar-bound SDK lineage after a separate process starts 6ms
 ✓ test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts > issue #6981 headless restart continuity > leaves no marker when a closed-entry fallback binding does not match the branch 2ms
 ✓ test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts > issue #6981 headless restart continuity > anchors and reattaches a contentless first turn at count zero 4ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > does not continue incrementally after switching away from and back to the provider 5ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > forks instead of continuing after an assistant-only context transformation 1ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > does not continue incrementally after the reasoning configuration changes 1ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > records a compaction fork boundary only when compaction was accepted 1ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > records tree branch boundaries without tainting 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > closes a session on quit shutdown idempotently 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > closes a session on new shutdown idempotently 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > closes a session on resume shutdown idempotently 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > closes a session on fork shutdown idempotently 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > closes a session on reload shutdown idempotently 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > closes a session when the extension is removed idempotently 0ms
 ✓ test/claude-sdk-oauth-session-registry-wiring.test.ts > Claude SDK OAuth session registry lifecycle wiring > registers lifecycle wiring from the production extension factory 0ms
 ✓ test/claude-sdk-oauth-bootstrap-classification.test.ts > Claude SDK OAuth bootstrap classification > bootstraps a fresh multi-message first turn with no prior assistant 66ms
 ✓ test/claude-sdk-oauth-bootstrap-classification.test.ts > Claude SDK OAuth bootstrap classification > flattens a fresh context that already has an assistant message 1ms
 ✓ test/suite/regressions/1432-claude-sdk-oauth-failover-reattach.test.ts > issue #1432 claude-sdk-oauth two-account failover reattach > fork-resumes the bound lineage on the failover account instead of flattening 72ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > emits bootstrap then delta with the delta message count for a healthy conversation 65ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > emits a flatten observation with a sanitized reason when the session is tainted 2ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > attributes the retained close cause to the next turn after a model switch closes the session 2ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > emits exactly one observation per turn on the resume-fallback path 5ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > emits a disabled observation for the non-resident path when resume mode is off 1ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > emits one terminal error observation when every attempt fails 1ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > logs structured continuity events through the session logger 1ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > sanitizes arbitrary close and pump errors into the fixed cause vocabulary 0ms
 ✓ test/claude-sdk-oauth-observability.test.ts > Claude SDK OAuth continuity observations > consumes a pending close cause exactly once 1ms
 ✓ test/suite/regressions/1747-invalidation-reason-continuity.test.ts > issue #1747 recorded invalidation cause reaches the next turn > names the recorded reason instead of registry_miss 67ms
 ✓ test/suite/regressions/1747-invalidation-reason-continuity.test.ts > issue #1747 recorded invalidation cause reaches the next turn > still reports registry_miss when no invalidation was ever recorded 2ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Claude SDK OAuth continuity diagnostics > attaches a continuity diagnostic to the assistant message on a degraded turn 58ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Claude SDK OAuth continuity diagnostics > attaches a continuity diagnostic on a healthy delta turn 3ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Continuity notice rendering > renders a muted notice for a flatten diagnostic 0ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Continuity notice rendering > stays silent for a healthy delta diagnostic 0ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Continuity notice rendering > renders the disabled notice only once per session 0ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Continuity notice rendering > renders the previously transported resume-fallback diagnostic 0ms
 ✓ test/claude-sdk-oauth-diagnostic-render.test.ts > Continuity notice rendering > stays silent for messages without continuity diagnostics 0ms
 ✓ test/suite/regressions/723-claude-sdk-oauth-timeout-abort-retry-continuity.test.ts > issue #723 claude-sdk-oauth stream-start-timeout retry continuity > resumes the aborted turn's lineage instead of re-sending it 1071ms
 ✓ test/suite/regressions/723-claude-sdk-oauth-timeout-abort-retry-continuity.test.ts > issue #723 claude-sdk-oauth stream-start-timeout retry continuity > re-seeds a stalled first turn byte-identically instead of storming 1003ms
 ✓ test/suite/regressions/7925-claude-sdk-oauth-resident-commit-boundary.test.ts > oh-my-openagent#7925 resident commit boundary > keeps a plain thinking+text turn clean so the next turn is an incremental delta 63ms
 ✓ test/suite/regressions/7925-claude-sdk-oauth-resident-commit-boundary.test.ts > oh-my-openagent#7925 resident commit boundary > ignores transport metadata stamped after the last message_update but still catches rewrites 0ms

 Test Files  21 passed (21)
      Tests  155 passed (155)
   Start at  13:21:22
   Duration  3.41s (transform 11.66s, setup 244ms, import 19.97s, tests 2.62s, environment 1ms)

TESTS-EXIT=0

Static gates on the same machine at the same commit: biome check --error-on-warnings over the touched provider directory and both new test files - clean; tsc --noEmit over 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 as assistant_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

  • Leaving the claude-sdk-oauth provider 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.
  • The recorded invalidation reason now replaces registry_miss on the next turn; registry_miss now strictly means no binding record was ever found.
  • Non-observation ledger reasons map to existing vocabulary (compactiontainted_compaction, tree_changedbranch_diverged, forktainted_fork).
  • No safety net is weakened: identity drift, unconfirmed SDK session ids, missing transcripts, and diverged sent streams still flatten.
  • Two regression suites cover the kept binding/reattach path and the invalidation-reason reporting.

Written for commit 02d34a5. Summary will update on new commits.

Review in cubic

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cycling the model selector discards a resumable Claude binding, and the recorded invalidation cause is replaced by registry_miss

1 participant