From 094e0b8110eb67ae07d312bbc9ee45adcba7ca1d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 13:12:37 +0900 Subject: [PATCH 1/4] test(claude-sdk-oauth): pin the model-select binding loss and the dropped 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. --- ...747-invalidation-reason-continuity.test.ts | 141 ++++++++++++++++++ .../1747-model-select-keeps-binding.test.ts | 105 +++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 packages/coding-agent/test/suite/regressions/1747-invalidation-reason-continuity.test.ts create mode 100644 packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts diff --git a/packages/coding-agent/test/suite/regressions/1747-invalidation-reason-continuity.test.ts b/packages/coding-agent/test/suite/regressions/1747-invalidation-reason-continuity.test.ts new file mode 100644 index 000000000..11930f1fe --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1747-invalidation-reason-continuity.test.ts @@ -0,0 +1,141 @@ +import type { Api, Context, Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import { BINDING_ENTRY_TYPE } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts"; +import type { ContinuityObservation } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts"; +import { + overrideContinuityObservabilityBoundary, + resetContinuityObservabilityBoundary, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts"; +import { forgetBinding } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; +import { closeSession } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; +import { registerSessionRegistry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts"; +import { streamClaudeSdkOauth } from "../../../src/core/extensions/builtin/claude-sdk-oauth/stream.ts"; +import type { ExtensionContext } from "../../../src/core/extensions/types.ts"; +import { + assistant, + type BranchEntry, + cleanupRestartFixture, + emit, + fakeExtension, + sessionFixture, +} from "../../helpers/claude-sdk-oauth-restart-fixture.ts"; +import { + installScriptedSdk, + installSingleAccountLane, + resetScriptedSdk, + sdkMessage, + type TurnScript, +} from "../../helpers/claude-sdk-oauth-scripted-sdk.ts"; + +/** + * senpi#1747: the ledger records WHY a binding was invalidated, and nothing ever + * read that reason back. The next turn therefore reported the no-record default + * `registry_miss` instead of the recorded cause. + */ + +const model: Model = { + id: "claude-test", + name: "Claude test", + api: "claude-sdk-oauth", + provider: "claude-sdk-oauth", + baseUrl: "claude-sdk-oauth", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 }, + contextWindow: 200_000, + maxTokens: 8_192, +}; + +const answerTurn: TurnScript = (sessionId, userUuid, submission) => [ + sdkMessage({ + type: "assistant", + message: { id: `message-${userUuid}`, type: "message", role: "assistant", content: [] }, + parent_tool_use_id: null, + uuid: `assistant-${userUuid}`, + session_id: sessionId, + }), + sdkMessage({ + type: "result", + subtype: "success", + result: `answer-${submission}`, + user_message_uuid: userUuid, + uuid: `result-${userUuid}`, + session_id: sessionId, + }), +]; + +const conversation: Context = { + messages: [ + { role: "user", content: "turn one", timestamp: 1 }, + assistant("turn one answer"), + { role: "user", content: "turn two", timestamp: 3 }, + ], +}; + +const sessionIds = new Set(); + +function eventContext(sessionId: string, sessionFile: string, branch: BranchEntry[]): ExtensionContext { + return { + sessionManager: { + getSessionId: () => sessionId, + getSessionFile: () => sessionFile, + getBranch: () => branch, + getLeafId: () => branch[branch.length - 1]?.id ?? null, + }, + } as unknown as ExtensionContext; +} + +/** Restarts a session whose ledger may carry an invalidation record, then runs one turn. */ +async function restartAndPrompt(sessionId: string, invalidation?: string): Promise { + sessionIds.add(sessionId); + const observed: ContinuityObservation[] = []; + overrideContinuityObservabilityBoundary({ emit: (observation) => observed.push(observation) }); + await installSingleAccountLane(); + installScriptedSdk(answerTurn); + const { sessionFile, branch } = sessionFixture(); + branch.push({ type: "message", id: "assistant-entry", message: assistant("turn one answer") }); + if (invalidation !== undefined) { + branch.push({ + type: "custom", + id: "binding-ledger", + customType: BINDING_ENTRY_TYPE, + data: { schemaVersion: 1, invalidated: true, reason: invalidation }, + }); + } + const extension = fakeExtension(branch); + registerSessionRegistry(extension.api); + await emit( + extension.handlers, + "session_start", + { type: "session_start", reason: "resume" }, + eventContext(sessionId, sessionFile, branch), + ); + await streamClaudeSdkOauth(model, conversation, { sessionId, streamKind: "main" }).result(); + return observed; +} + +afterEach(() => { + for (const sessionId of sessionIds) { + closeSession(sessionId, "test_cleanup"); + forgetBinding(sessionId); + } + sessionIds.clear(); + resetScriptedSdk(); + resetContinuityObservabilityBoundary(); + cleanupRestartFixture(); +}); + +describe("issue #1747 recorded invalidation cause reaches the next turn", () => { + it("names the recorded reason instead of registry_miss", async () => { + const observed = await restartAndPrompt("issue-1747-recorded", "model_selected"); + + expect(observed).toContainEqual(expect.objectContaining({ kind: "flatten", reason: "model_selected" })); + expect(observed.map((observation) => observation.reason)).not.toContain("registry_miss"); + }); + + it("still reports registry_miss when no invalidation was ever recorded", async () => { + const observed = await restartAndPrompt("issue-1747-unrecorded"); + + expect(observed).toContainEqual(expect.objectContaining({ kind: "flatten", reason: "registry_miss" })); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts b/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts new file mode 100644 index 000000000..611160896 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + BINDING_ENTRY_TYPE, + BINDING_MARKER, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts"; +import { readStoredBinding } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-binding-store.ts"; +import { decideNativeContinuity } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts"; +import { getBinding } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; +import { getSession } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; +import { registerSessionRegistry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts"; +import { + recordSyncedStream, + sentMessageHashes, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; +import { + assistant, + cleanupRestartFixture, + context, + emit, + fakeExtension, + PROMPT_HASH, + residentEntry, + SESSION_ID, + sessionFixture, + TOOLSET_HASH, +} from "../../helpers/claude-sdk-oauth-restart-fixture.ts"; + +/** + * senpi#1747: leaving this provider through the model selector must close the live + * SDK session and KEEP the binding, exactly like a thinking-level change. Destroying + * it made the return trip re-send the whole conversation. + */ + +const FINGERPRINT = { systemPromptHash: PROMPT_HASH, toolsetHash: TOOLSET_HASH }; + +function selectModel(provider: string, id: string) { + return { + type: "model_select", + model: { id, provider }, + previousModel: { id: "claude-test", provider: "claude-sdk-oauth" }, + }; +} + +/** A committed turn: the marker plus the sidecar a later process would restore from. */ +async function committedTurn() { + const fixture = sessionFixture(); + const extension = fakeExtension(fixture.branch); + registerSessionRegistry(extension.api); + const entry = residentEntry(); + recordSyncedStream(entry, fixture.turnHashes); + const eventContext = context(fixture.sessionFile, fixture.branch); + await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + fixture.branch.push({ type: "message", id: "assistant-entry", message: assistant() }); + return { ...fixture, extension, eventContext, sdkSessionId: entry.sdkSessionId }; +} + +afterEach(() => { + cleanupRestartFixture(); +}); + +describe("issue #1747 model selector keeps a resumable Claude binding", () => { + it("keeps the binding and the sidecar when the selected model leaves this provider", async () => { + const turn = await committedTurn(); + expect(await readStoredBinding(turn.sessionFile)).toMatchObject({ sdkSessionId: turn.sdkSessionId }); + + await emit(turn.extension.handlers, "model_select", selectModel("openai", "gpt-5.6"), turn.eventContext); + + expect(getSession(SESSION_ID)).toBeUndefined(); + expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: turn.sdkSessionId, sentCount: 1 }); + expect(await readStoredBinding(turn.sessionFile)).toMatchObject({ sdkSessionId: turn.sdkSessionId }); + expect(turn.extension.persisted).toEqual([{ customType: BINDING_ENTRY_TYPE, data: BINDING_MARKER }]); + }); + + it("reattaches at the recorded prefix when the same Claude model is selected again", async () => { + const turn = await committedTurn(); + + await emit(turn.extension.handlers, "model_select", selectModel("openai", "gpt-5.6"), turn.eventContext); + await emit( + turn.extension.handlers, + "model_select", + selectModel("claude-sdk-oauth", "claude-test"), + turn.eventContext, + ); + + // The excursion answered one turn on the other provider before coming back. + const awayHashes = sentMessageHashes([ + ...turn.contextMessages, + assistant("foreign answer"), + { role: "user", content: [{ type: "text", text: "back on Claude" }], timestamp: 3 }, + ]); + const decision = decideNativeContinuity({ + entry: undefined, + binding: getBinding(SESSION_ID), + currentHashes: awayHashes, + accountName: "default", + modelId: "claude-test", + fingerprint: FINGERPRINT, + transcriptAvailable: true, + crossAccountResumeSupported: true, + }); + + expect(decision).toMatchObject({ kind: "reattach", sdkSessionId: turn.sdkSessionId, from: 1 }); + expect(awayHashes).toHaveLength(3); + }); +}); From 896fb1ab8bc39e2d44c0e78e460b24cfea724d64 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 13:17:00 +0900 Subject: [PATCH 2/4] fix(claude-sdk-oauth): keep the binding across a provider excursion and 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 --- packages/coding-agent/CHANGELOG.md | 2 ++ .../builtin/claude-sdk-oauth/changes.md | 24 +++++++++++++ .../claude-sdk-oauth/session-binding.ts | 19 ++++++++++ .../claude-sdk-oauth/session-continuity.ts | 36 +++++++++++++++++-- .../claude-sdk-oauth/session-reattach.ts | 17 +++++++++ .../session-registry-wiring.ts | 36 +++++++++++++++---- .../claude-sdk-oauth/session-stream.ts | 3 +- 7 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f1a649fba..259027235 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Moving the model selector off a Claude model and back no longer re-sends the whole conversation. Leaving the `claude-sdk-oauth` provider now closes the live SDK session but keeps its continuity binding - the same thing a thinking-level change already did - so returning to the same Claude model re-attaches to the existing session and sends only the messages added while you were away, instead of paying for the full history again. Switching to a different Claude model, a restart with no usable transcript, a diverged conversation and an unacknowledged session id all still start fresh exactly as before. When a binding really was discarded, the transcript notice now names the recorded cause (`model_selected`, `tainted_compaction`, `branch_diverged`, `extensions_removed`, ...) instead of the generic `registry_miss`, which from now on means only "no record was ever found" ([#1747](https://github.com/code-yeongyu/senpi/issues/1747)). + - A provider that accepts a request and never starts streaming no longer ends the turn with the watchdog's own message (`Provider stream start timed out after 180000ms ...`). The stall is still retried on the same model with the configured stream-start bound, and still hands the turn to the next model in a configured `retry.fallbackChains` entry whose answer becomes the turn result. What changed is what you read: the transcript (and `senpi -p`) describes the stall in plain language, and when nothing can take the turn over the final line names the stalled model, the attempts spent and the next step - `/fallback`, resending, or raising `retry.provider.streamStartTimeoutMs` (`0` disables). The wording on the assistant message is unchanged, so retry classification and fallback routing behave exactly as before ([#1740](https://github.com/code-yeongyu/senpi/issues/1740)). - A provider that keeps streaming at a uselessly low rate is now detected instead of looking healthy forever. Every previous guard on a live stream watched for silence (the stream-start bound stops applying at the first event; the idle bound is re-armed by every event), so a turn crawling at ~2 tok/s never failed, never retried and never walked a fallback chain. After the first stream event senpi now ignores `retry.provider.throughputGraceMs` (default 5000) of streaming and then measures streamed text and thinking units over a trailing `retry.provider.throughputWindowMs` (default 20000); a full window carrying at least 16 units whose sustained rate is below `retry.provider.minThroughputTokensPerSecond` (default 8, `0` disables) aborts the request with `Provider stream throughput degraded: tok/s over s (floor tok/s)`. That failure is retryable but spends no same-model attempts - replaying the payload cannot make the upstream faster - so it goes straight to the configured fallback chain, and with no candidate the turn ends on that error with the usual "No fallback chain configured - set one with /fallback." guidance instead of continuing to crawl. Time the provider spends running local tools is excluded from the measurement, and the interactive working line now shows the live rate (`Working (1m 12s - 2.1 tok/s - esc to interrupt)`) so a degraded turn is visible while it runs ([#1739](https://github.com/code-yeongyu/senpi/issues/1739)). diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index 855a17f3a..5a01a59f8 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -1,5 +1,29 @@ # claude-sdk-oauth +## 2026-09-16 - Keep the binding across a provider excursion and report the recorded invalidation cause (senpi#1747) + +### What changed + +- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts`: the `model_select` handler no longer invalidates when the newly selected model belongs to another provider. It now uses the existing `keepBindingThenClose`, exactly like `thinking_level_select` and the in-provider `switchSessionModel` failure branch, so the live SDK session closes while the binding and its sidecar survive. The same file now also carries the ledger invalidation cause into process memory: `persistBindingInvalidation` records it as it appends the record, `session_start` re-reads it from the branch on a restart (clearing it for `new`), and the `message_end` marker retires it. +- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts`: adds `invalidationReasonFromBranch`, which returns the reason of the newest binding ledger record when that record is an invalidation (a later marker retires it). +- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts`: holds the pending invalidation reason per senpi session id (`rememberBindingInvalidation`, `bindingInvalidationReason`) next to the binding map it replaces. +- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts`: `ContinuityDecisionInput` accepts `invalidationReason`, and a `bootstrap`/`flatten` that would report the no-record default `registry_miss` reports the recorded cause instead (`model_selected`, `extensions_removed`, `assistant_rewritten` pass through the observation vocabulary; `compaction`, `tree_changed` and `fork` map to `tainted_compaction`, `branch_diverged` and `tainted_fork`). Classification is unchanged: no decision kind moves, and every other reason is left as decided. +- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts`: passes the pending reason into the decision input. + +### Why + +- Cycling the model selector away from Claude and back re-sent the whole conversation (field reports of hundreds of KB per turn, one measured at 226 messages / ~881 KB) because the excursion destroyed a perfectly resumable binding. The module already had the non-destructive path for exactly this situation; the reattach path handles the return trip through `sentPrefixHash` / `commonPrefixLength` with `from: binding.sentCount`, so only the messages added while away are sent. Every safety net still decides the return trip: `identityDrift` flattens on `model_changed`, an unconfirmed SDK session id is refused, and a missing transcript or a diverged sent stream still flattens. +- The invalidation reason was written to the ledger and never read back, so a genuine invalidation (`model_selected`, `compaction`, `tree_changed`, `extensions_removed`) surfaced to the user as `registry_miss` - the reason that is supposed to mean "no record was ever found". + +### Why an extension could not handle it + +- The model-selector lifecycle wiring, the binding ledger and the continuity decision table are this provider extension's own internals; no extension surface can observe or replace them. + +### Expected merge conflict zones + +- MEDIUM: the `session_start` and `model_select` handlers in `session-registry-wiring.ts`. +- LOW: the binding map in `session-reattach.ts`, the branch readers in `session-binding.ts`, the decision-input type and the `decideNativeContinuity` entry point in `session-continuity.ts`, the decision-input construction in `session-stream.ts`. + ## 2026-09-11 - Detect same-tick settings rewrites ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts index c43a208fe..7abe2c894 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts @@ -96,6 +96,25 @@ export function storedBindingFromBinding( }; } +/** + * Reason carried by the newest binding ledger record, when that record is an + * invalidation. A marker appended later retires it: the marker means a turn + * re-established the binding, so no cause is pending any more. + */ +export function invalidationReasonFromBranch(branch: readonly BranchEntry[]): string | undefined { + const index = newestBindingEntryIndex(branch); + if (index < 0) return undefined; + const data = branch[index]?.data; + return isBindingInvalidation(data) ? data.reason : undefined; +} + +function isBindingInvalidation(value: unknown): value is BindingInvalidation { + if (typeof value !== "object" || value === null) return false; + return ( + "invalidated" in value && value.invalidated === true && "reason" in value && typeof value.reason === "string" + ); +} + export function bindingFromStoredBranch( branch: readonly BranchEntry[], stored: StoredBinding, diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts index b6fb70797..3758bee0c 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts @@ -1,4 +1,4 @@ -import type { ContinuityReason } from "./session-observability.ts"; +import { type ContinuityReason, sanitizeReason } from "./session-observability.ts"; import { sentHashPrefixDigest } from "./session-sync.ts"; export type ContinuityEntrySnapshot = { @@ -42,10 +42,12 @@ export type ContinuityDecisionInput = { /** false only on the config-dir lane, whose per-account credential roots cannot share a transcript root, so cross-account resume is impossible there. */ crossAccountResumeSupported: boolean; idleExpired?: boolean; + /** Reason the newest ledger record invalidated this session's binding, when one is pending. */ + invalidationReason?: string; }; export type ContinuityDecision = - | { kind: "bootstrap" } + | { kind: "bootstrap"; reason?: ContinuityReason } | { kind: "delta"; from: number } | { kind: "reattach"; sdkSessionId: string; from: number; reason: ContinuityReason } | { kind: "fork"; sdkSessionId: string; atUuid: string; from: number; reason: ContinuityReason } @@ -56,6 +58,32 @@ const PENDING_FORK_REASONS: Readonly> = { compaction: "tainted_compaction", }; +/** + * Ledger invalidation reasons that are not themselves observation vocabulary, + * mapped onto the closest member. Reasons that ARE members (model_selected, + * extensions_removed, assistant_rewritten) pass through `sanitizeReason`, which + * also keeps an unknown ledger string from ever reaching an observation. + */ +const INVALIDATION_CAUSES: Readonly> = { + compaction: "tainted_compaction", + tree_changed: "branch_diverged", + fork: "tainted_fork", +}; + +/** + * `registry_miss` means what it says: no record was ever found. When the ledger + * recorded WHY the binding went away, the cold-seed names that cause instead. + * Classification is untouched - only the reason a `bootstrap`/`flatten` reports + * changes, and only when it would otherwise be the no-record default. + */ +function withRecordedInvalidation(decision: ContinuityDecision, input: ContinuityDecisionInput): ContinuityDecision { + if (input.invalidationReason === undefined) return decision; + const cause = INVALIDATION_CAUSES[input.invalidationReason] ?? sanitizeReason(input.invalidationReason); + if (decision.kind === "bootstrap") return { kind: "bootstrap", reason: cause }; + if (decision.kind === "flatten" && decision.reason === "registry_miss") return { kind: "flatten", reason: cause }; + return decision; +} + function commonPrefixLength(left: readonly string[], right: readonly string[]): number { const limit = Math.min(left.length, right.length); let index = 0; @@ -214,6 +242,10 @@ function decideFromBinding(input: ContinuityDecisionInput, binding: ContinuityBi * session, new query). */ export function decideNativeContinuity(input: ContinuityDecisionInput): ContinuityDecision { + return withRecordedInvalidation(decideFromState(input), input); +} + +function decideFromState(input: ContinuityDecisionInput): ContinuityDecision { const { entry, binding } = input; if (!entry) { if (!binding) return { kind: "bootstrap" }; diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts index 4b81a84da..c14a40190 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts @@ -70,6 +70,23 @@ export function forgetBinding(senpiSessionId: string): void { bindings.delete(senpiSessionId); } +/** + * Reason the newest ledger record invalidated this session's binding. Held next + * to the binding it replaced so the next continuity decision can name the real + * cause instead of the no-record default: a restart re-reads it from the branch + * (session-registry-wiring), and a committed turn retires it with the marker. + */ +const bindingInvalidations = new Map(); + +export function rememberBindingInvalidation(senpiSessionId: string, reason: string | undefined): void { + if (reason === undefined) bindingInvalidations.delete(senpiSessionId); + else bindingInvalidations.set(senpiSessionId, reason); +} + +export function bindingInvalidationReason(senpiSessionId: string): string | undefined { + return bindingInvalidations.get(senpiSessionId); +} + export function bindingFromEntry( entry: Pick< ClaudeSdkOauthSessionEntry, diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts index 266eb6043..6ef2eb609 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts @@ -7,6 +7,7 @@ import { BINDING_MARKER, type BindingInvalidation, bindingFromStoredBranch, + invalidationReasonFromBranch, storedBindingFromBinding, storedBindingFromEntry, } from "./session-binding.ts"; @@ -17,7 +18,13 @@ import { isResidentAssistant, isTerminalFailure, } from "./session-commit-boundary.ts"; -import { bindingFromEntry, forgetBinding, getBinding, rememberBinding } from "./session-reattach.ts"; +import { + bindingFromEntry, + forgetBinding, + getBinding, + rememberBinding, + rememberBindingInvalidation, +} from "./session-reattach.ts"; import { closeSession, getSession, @@ -29,8 +36,13 @@ import { sentHashesForEntry, sentMessageHashes, sentMessages } from "./session-s const commitBoundary = new AssistantCommitBoundary(); -function persistBindingInvalidation(pi: Partial>, reason: string): void { +function persistBindingInvalidation( + pi: Partial>, + sessionId: string, + reason: string, +): void { pi.appendEntry?.(BINDING_ENTRY_TYPE, { schemaVersion: 1, invalidated: true, reason } satisfies BindingInvalidation); + rememberBindingInvalidation(sessionId, reason); } async function invalidateBinding( @@ -42,7 +54,7 @@ async function invalidateBinding( forgetBinding(sessionId); const sessionFile = ctx.sessionManager.getSessionFile?.(); if (sessionFile) await deleteStoredBinding(sessionFile); - persistBindingInvalidation(pi, reason); + persistBindingInvalidation(pi, sessionId, reason); } function keepBindingThenClose(sessionId: string, reason: string): void { @@ -64,21 +76,26 @@ export function registerSessionRegistry( if (event.reason === "reload") return; const sessionId = ctx.sessionManager.getSessionId(); forgetBinding(sessionId); + rememberBindingInvalidation(sessionId, undefined); const sessionFile = ctx.sessionManager.getSessionFile?.(); if (event.reason === "new") return; if (event.reason === "fork") { if (sessionFile) await deleteStoredBinding(sessionFile); - persistBindingInvalidation(pi, "fork"); + persistBindingInvalidation(pi, sessionId, "fork"); return; } if (!sessionFile) return; + // A restart carries the ledger, not the process maps: the newest binding record + // says whether a cause is still pending (invalidation) or was retired (marker). + const branch = ctx.sessionManager.getBranch(); + rememberBindingInvalidation(sessionId, invalidationReasonFromBranch(branch)); const stored = await readStoredBinding(sessionFile); if (!stored) return; if (stored.sessionId !== sessionId) { await deleteStoredBinding(sessionFile); return; } - const binding = bindingFromStoredBranch(ctx.sessionManager.getBranch(), stored); + const binding = bindingFromStoredBranch(branch, stored); if (!binding) { await deleteStoredBinding(sessionFile); return; @@ -100,9 +117,12 @@ export function registerSessionRegistry( }); pi.on("model_select", async (event, ctx) => { const sessionId = ctx.sessionManager.getSessionId(); + // Leaving this provider is an excursion, not an invalidation: the live SDK + // session closes but the binding stays, so coming back to the same model + // reattaches at the recorded prefix instead of re-sending the whole + // conversation (senpi#1747). Identity drift still flattens on the way back. if (event.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) { - closeSession(sessionId, "model_selected"); - await invalidateBinding(pi, ctx, "model_selected"); + keepBindingThenClose(sessionId, "model_selected"); return; } if (!(await switchSessionModel(sessionId, event.model.id))) { @@ -160,6 +180,8 @@ export function registerSessionRegistry( // not leave a marker-only entry that retires the still-valid older sidecar. if (!recordFor("pending")) return; pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER); + // The marker is now the newest ledger record, so any earlier cause is retired. + rememberBindingInvalidation(sessionId, undefined); const markerEntryId = ctx.sessionManager.getLeafId(); if (!markerEntryId) return; const stored = recordFor(markerEntryId); diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts index b053c05de..b03e3148b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts @@ -13,7 +13,7 @@ import { sanitizeTerminalFailure, stageContinuityDecision, } from "./session-observability.ts"; -import { bindingFromEntry, getBinding, reattachSession } from "./session-reattach.ts"; +import { bindingFromEntry, bindingInvalidationReason, getBinding, reattachSession } from "./session-reattach.ts"; import { type ClaudeSdkOauthSessionEntry, closeSession, @@ -98,6 +98,7 @@ async function createResidentAttempt( transcriptAvailable, crossAccountResumeSupported: auth.authLane !== "config-dir", idleExpired: existing ? isIdleExpired(existing) : false, + invalidationReason: bindingInvalidationReason(sessionId), }); const firstTurn = existing === undefined && getBinding(sessionId) === undefined && !contextHasPriorAssistantMessage(input.context); From 7bcbe30722be254760f9e18ce109b9c8311d3abc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 13:17:58 +0900 Subject: [PATCH 3/4] test(claude-sdk-oauth): assert the reattach delta against the real sent-stream rule --- .../regressions/1747-model-select-keeps-binding.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts b/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts index 611160896..6c4b469b0 100644 --- a/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts +++ b/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts @@ -99,7 +99,10 @@ describe("issue #1747 model selector keeps a resumable Claude binding", () => { crossAccountResumeSupported: true, }); + // The sent stream carries user turns only: the recorded prefix still matches, so + // the reattach re-sends just the turn taken while away, not the conversation. expect(decision).toMatchObject({ kind: "reattach", sdkSessionId: turn.sdkSessionId, from: 1 }); - expect(awayHashes).toHaveLength(3); + expect(awayHashes.slice(0, 1)).toEqual(turn.turnHashes); + expect(awayHashes).toHaveLength(2); }); }); From 02d34a5ae4adf0e96c5efa8c1d291139fd85ff55 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 16 Sep 2026 13:19:23 +0900 Subject: [PATCH 4/4] style(claude-sdk-oauth): satisfy biome and the sent-stream type in the #1747 regressions --- .../builtin/claude-sdk-oauth/session-binding.ts | 4 +--- .../regressions/1747-model-select-keeps-binding.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts index 7abe2c894..e3d282af2 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts @@ -110,9 +110,7 @@ export function invalidationReasonFromBranch(branch: readonly BranchEntry[]): st function isBindingInvalidation(value: unknown): value is BindingInvalidation { if (typeof value !== "object" || value === null) return false; - return ( - "invalidated" in value && value.invalidated === true && "reason" in value && typeof value.reason === "string" - ); + return "invalidated" in value && value.invalidated === true && "reason" in value && typeof value.reason === "string"; } export function bindingFromStoredBranch( diff --git a/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts b/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts index 6c4b469b0..77e8600b1 100644 --- a/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts +++ b/packages/coding-agent/test/suite/regressions/1747-model-select-keeps-binding.test.ts @@ -82,10 +82,10 @@ describe("issue #1747 model selector keeps a resumable Claude binding", () => { turn.eventContext, ); - // The excursion answered one turn on the other provider before coming back. + // The excursion answered one turn on the other provider before coming back; the + // sent stream carries user turns only, so that answer adds nothing to it. const awayHashes = sentMessageHashes([ ...turn.contextMessages, - assistant("foreign answer"), { role: "user", content: [{ type: "text", text: "back on Claude" }], timestamp: 3 }, ]); const decision = decideNativeContinuity({ @@ -99,8 +99,8 @@ describe("issue #1747 model selector keeps a resumable Claude binding", () => { crossAccountResumeSupported: true, }); - // The sent stream carries user turns only: the recorded prefix still matches, so - // the reattach re-sends just the turn taken while away, not the conversation. + // The recorded prefix still matches, so the reattach re-sends just the turn + // taken while away, not the conversation. expect(decision).toMatchObject({ kind: "reattach", sdkSessionId: turn.sdkSessionId, from: 1 }); expect(awayHashes.slice(0, 1)).toEqual(turn.turnHashes); expect(awayHashes).toHaveLength(2);