From 0bdff3263a71e61f0196c08902c8fbc236d60bad Mon Sep 17 00:00:00 2001 From: 1vivy <1vivy@tutanota.com> Date: Tue, 11 Aug 2026 00:13:27 -0600 Subject: [PATCH 1/2] fix(claude-sdk-oauth): persist the continuity binding across restarts session-binding.ts has existed since db8e8cfeb but was never wired: nothing called latestBindingOnBranch() and nothing wrote a claude-sdk-oauth-binding entry, so its unit test was the only consumer. The sole surviving binding store was the process-memory Map in session-reattach.ts. After a restart both getSession() and getBinding() miss, decideNativeContinuity returns bootstrap, and the lane flattens. session-stream.ts synthesizes the reason registry_miss because bootstrap carries none, and session-observability reports it as flatten once firstTurn is false. A 697-message session re-sent 68.0KB on the first turn after every restart. Write the checkpoint at the message_end commit boundary, lift it off the branch at session_start, and rebuild the binding in createResidentAttempt only once the current prefix digest matches the recorded one and account, model, systemPromptHash and toolsetHash all agree. Every unproven case falls through to the existing cold path. fixes #808 --- .../builtin/claude-sdk-oauth/changes.md | 35 ++++++ .../claude-sdk-oauth/session-binding.ts | 30 ++++- .../session-registry-wiring.ts | 37 +++++- .../claude-sdk-oauth/session-stream.ts | 43 +++++++ .../builtin/claude-sdk-oauth/session-sync.ts | 2 +- ...aude-sdk-oauth-binding-persistence.test.ts | 113 ++++++++++++++++++ 6 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts 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 20d61138d5..5ecd754025 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,40 @@ # claude-sdk-oauth extension changes +## 2026-08-11 - Persist the continuity binding so a restart resumes instead of re-sending + +- `session-binding.ts` has been present since `db8e8cfeb` ("persist continuity bindings for verified restart + resume") but was never wired: nothing called `latestBindingOnBranch()` and nothing ever wrote a + `claude-sdk-oauth-binding` entry. Its unit test was the only consumer, so the sole surviving binding store was the + process-memory `Map` in `session-reattach.ts`. +- Consequence: after a restart both `getSession()` and `getBinding()` miss, `decideNativeContinuity` returns + `bootstrap`, and the lane flattens. `session-stream.ts` synthesizes the reason `registry_miss` because `bootstrap` + carries none, and `session-observability.ts` reports it as `flatten` once `firstTurn` is false. Observed on a + 697-message session: 68.0KB and ~60K tokens re-sent on the first turn after every restart. +- Write path: `session-registry-wiring.ts` appends a `BindingCheckpoint` via `pi.appendEntry` at the `message_end` + commit boundary, only after the boundary reports a non-rewritten commit. `registerSessionRegistry` therefore now + takes `Pick`. +- Read path: a new `session_start` handler lifts the newest checkpoint off `ctx.sessionManager.getBranch()` into a + module map. The decision needs the current sent-hash prefix, which only exists once the provider context is built, + so the checkpoint is held until `createResidentAttempt` can verify it. +- Verification is fail-closed. `rehydrateBindingFromCheckpoint` rebuilds the binding from the CURRENT hashes and only + after `prefixDigest(hashes, sentCount)` equals the recorded `sentPrefixHash`, and only when account, model, + `systemPromptHash`, and `toolsetHash` all match. A live entry gets that drift check from `identityDrift`, which a + restarted process cannot run, so `BindingCheckpoint` gained optional `systemPromptHash`/`toolsetHash`; absent means + unknown and unknown never rehydrates. Every refusal falls through to today's cold path. +- The checkpoint stays compact deliberately: `sentPrefixHash` is one sha256 over the prefix, where persisting the + full hash array would add tens of KB to the transcript on every turn. `claudeConfigDir` became optional because + nothing consumes it — a real transcript-existence probe would have to hard-code Claude Code's private + `projects//.jsonl` layout, and this extension deliberately treats resume failure as that gate + (`session-stream.ts` catches it and falls back to `resume_initialization_failed`). +- `verifyBindingAgainstTranscript` is left untouched and still unused; wiring it needs the transcript probe above. +- This cannot be implemented by an external extension: the binding store, the continuity decision, and the commit + boundary are all private to the builtin provider, and no extension hook can reach them. +- Added `test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts` covering checkpoint derivation, the + branch round-trip, a successful rehydrate, six refusal cases, and single-use consumption. +- Expected merge conflict zones: LOW in `session-binding.ts` (appended store) and `session-sync.ts` (one `export`); + MEDIUM in `session-registry-wiring.ts` (imports, signature, `message_end` tail) and `session-stream.ts` (imports + and the pre-decision block in `createResidentAttempt`). + ## 2026-08-11 - Require a real OAuth login for runtime availability - Removed the literal `apiKey: "claude-sdk-oauth-managed"` registration placeholder. Provider composition treated 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 91ef086c69..7200f512fc 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 @@ -9,8 +9,16 @@ export type BindingCheckpoint = { sentPrefixHash: string; lastAssistantUuid: string | null; accountName: string; - claudeConfigDir: string; + claudeConfigDir?: string; modelId: string; + /** + * Config identity at the time the checkpoint was written. A live entry has its + * drift checked by `identityDrift`, which a restarted process cannot run because + * the entry is gone. Absent (pre-existing checkpoints) means unknown, and unknown + * never rehydrates. + */ + systemPromptHash?: string; + toolsetHash?: string; }; export type BindingInvalidation = { schemaVersion: 1; invalidated: true; reason: string }; @@ -64,3 +72,23 @@ export function verifyBindingAgainstTranscript(input: BindingVerificationInput): reason: "sent_stream_diverged", }; } + +/** + * Checkpoints read off the branch at `session_start`, held until the turn that can + * verify them. The decision needs the current sent-hash prefix, which only exists + * once the provider context is built, so the read and the check happen in different + * places and this is the hand-off between them. + */ +const checkpoints = new Map(); + +export function rememberCheckpoint(senpiSessionId: string, checkpoint: BindingCheckpoint): void { + checkpoints.set(senpiSessionId, checkpoint); +} + +export function getCheckpoint(senpiSessionId: string): BindingCheckpoint | undefined { + return checkpoints.get(senpiSessionId); +} + +export function forgetCheckpoint(senpiSessionId: string): void { + checkpoints.delete(senpiSessionId); +} 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 7af860f922..edd789f6c2 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 @@ -1,9 +1,16 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "../../types.ts"; import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "./account-management.ts"; +import { + BINDING_ENTRY_TYPE, + type BindingCheckpoint, + latestBindingOnBranch, + rememberCheckpoint, +} from "./session-binding.ts"; import { AssistantCommitBoundary, isResidentAssistant, isTerminalFailure } from "./session-commit-boundary.ts"; import { bindingFromEntry, rememberBinding } from "./session-reattach.ts"; import { + type ClaudeSdkOauthSessionEntry, closeSession, getSession, recordBranchInfo, @@ -25,7 +32,32 @@ function residentEntryFor(sessionId: string, message: AssistantMessage) { return entry; } -export function registerSessionRegistry(pi: Pick): void { +/** + * The compact form of the binding: one prefix digest instead of every sent hash. + * A full hash array would add tens of KB to the transcript on every turn, and the + * digest answers the only question a restart asks - is the prefix still the one + * the SDK session already received. + */ +export function checkpointFromEntry(entry: ClaudeSdkOauthSessionEntry): BindingCheckpoint | undefined { + if (!entry.syncedPrefixHash) return undefined; + return { + schemaVersion: 1, + sdkSessionId: entry.sdkSessionId, + sentCount: entry.sentCount, + sentPrefixHash: entry.syncedPrefixHash, + lastAssistantUuid: entry.assistantUuidByIndex.get(entry.sentCount) ?? null, + accountName: entry.accountName, + modelId: entry.modelId, + systemPromptHash: entry.systemPromptHash, + toolsetHash: entry.toolsetHash, + }; +} + +export function registerSessionRegistry(pi: Pick): void { + pi.on("session_start", (_event, ctx) => { + const checkpoint = latestBindingOnBranch(ctx.sessionManager.getBranch()); + if (checkpoint) rememberCheckpoint(ctx.sessionManager.getSessionId(), checkpoint); + }); pi.on("session_compact", (_event, ctx) => { recordPendingFork(ctx.sessionManager.getSessionId(), "compaction"); }); @@ -70,7 +102,10 @@ export function registerSessionRegistry(pi: Pick): void { } if (commitBoundary.commit(sessionId, event.message, entry.modelId) === "rewritten") { recordPendingFork(sessionId, "assistant_rewritten"); + return; } + const checkpoint = checkpointFromEntry(entry); + if (checkpoint) pi.appendEntry(BINDING_ENTRY_TYPE, checkpoint); }); pi.on("session_shutdown", (event, ctx) => { closeSession(ctx.sessionManager.getSessionId(), event.reason); 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 0871129301..46a4493875 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 @@ -5,6 +5,7 @@ import { buildPromptBlocks } from "./prompt-bridge.ts"; import { dedupeUltraworkBlocks, serializedPayloadBytes } from "./prompt-directive-dedupe.ts"; import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts"; import { getSdkBoundary } from "./sdk-boundary.ts"; +import { forgetCheckpoint, getCheckpoint } from "./session-binding.ts"; import { type ContinuityDecision, decideNativeContinuity } from "./session-continuity.ts"; import { type ContinuityObservation, @@ -28,6 +29,7 @@ import { submitSessionTurn } from "./session-registry-pump.ts"; import { buildDeltaPromptBlocks, configFingerprint, + prefixDigest, recordSyncedStream, sentHashesForEntry, sentMessageHashes, @@ -129,6 +131,44 @@ function entrySnapshot(entry: ClaudeSdkOauthSessionEntry, hashes: readonly strin }; } +/** + * A restart empties both the registry and the in-memory binding map, so an unchanged + * session would bootstrap and re-send its entire history. The branch checkpoint + * outlives the process, but it carries only a digest, so the binding is rebuilt from + * the CURRENT hashes and only once that digest proves the prefix is identical to what + * the SDK session already received. Anything unproven - a shorter history, a changed + * prefix, a different account/model, an unknown config identity - is left alone and + * takes the existing cold path. + */ +export function rehydrateBindingFromCheckpoint( + sessionId: string, + hashes: readonly string[], + fingerprint: { systemPromptHash: string; toolsetHash: string }, + accountName: string, + modelId: string, +): boolean { + const checkpoint = getCheckpoint(sessionId); + if (!checkpoint) return false; + forgetCheckpoint(sessionId); + if (checkpoint.accountName !== accountName || checkpoint.modelId !== modelId) return false; + if (checkpoint.systemPromptHash !== fingerprint.systemPromptHash) return false; + if (checkpoint.toolsetHash !== fingerprint.toolsetHash) return false; + if (checkpoint.sentCount < 1 || hashes.length < checkpoint.sentCount) return false; + if (prefixDigest(hashes, checkpoint.sentCount) !== checkpoint.sentPrefixHash) return false; + rememberBinding({ + senpiSessionId: sessionId, + sdkSessionId: checkpoint.sdkSessionId, + sentCount: checkpoint.sentCount, + sentHashes: hashes.slice(0, checkpoint.sentCount), + lastAssistantUuid: checkpoint.lastAssistantUuid, + accountName: checkpoint.accountName, + modelId: checkpoint.modelId, + systemPromptHash: fingerprint.systemPromptHash, + toolsetHash: fingerprint.toolsetHash, + }); + return true; +} + async function createResidentAttempt( input: ResidentSessionStreamInput, auth: AuthenticatedAttemptInput, @@ -139,6 +179,9 @@ async function createResidentAttempt( const existing = getSession(sessionId); const fingerprint = configFingerprint(auth.options, input.context, auth.authLane, auth.accountName); const residentHashes = existing ? (sentHashesForEntry(existing) ?? hashes) : hashes; + if (!existing && !getBinding(sessionId)) { + rehydrateBindingFromCheckpoint(sessionId, hashes, fingerprint, auth.accountName, input.model.id); + } const decision = decideNativeContinuity({ entry: existing ? entrySnapshot(existing, residentHashes) : undefined, binding: getBinding(sessionId), diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts index 6028c4a5c1..4d123c8a26 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts @@ -65,7 +65,7 @@ export function sentMessageHashes(messages: readonly SentMessage[]): string[] { return hashes; } -function prefixDigest(hashes: readonly string[], count = hashes.length): string { +export function prefixDigest(hashes: readonly string[], count = hashes.length): string { return digest(hashes.slice(0, count)); } diff --git a/packages/coding-agent/test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts b/packages/coding-agent/test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts new file mode 100644 index 0000000000..546189dfad --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + BINDING_ENTRY_TYPE, + forgetCheckpoint, + latestBindingOnBranch, + rememberCheckpoint, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts"; +import { forgetBinding, getBinding } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; +import type { ClaudeSdkOauthSessionEntry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; +import { checkpointFromEntry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts"; +import { rehydrateBindingFromCheckpoint } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts"; +import { prefixDigest } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; + +const SESSION = "senpi-session-808"; +const FINGERPRINT = { systemPromptHash: "sp-1", toolsetHash: "ts-1" }; +const HASHES = ["h0", "h1", "h2", "h3"]; + +function entryLike(overrides: Partial = {}): ClaudeSdkOauthSessionEntry { + return { + senpiSessionId: SESSION, + sdkSessionId: "sdk-808", + sentCount: 3, + syncedPrefixHash: prefixDigest(HASHES, 3), + assistantUuidByIndex: new Map([[3, "uuid-a3"]]), + accountName: "primary", + modelId: "claude-opus-5", + systemPromptHash: FINGERPRINT.systemPromptHash, + toolsetHash: FINGERPRINT.toolsetHash, + ...overrides, + } as ClaudeSdkOauthSessionEntry; +} + +function branchWith(data: unknown) { + return [{ type: "custom" as const, customType: BINDING_ENTRY_TYPE, data }]; +} + +/** Restores the pre-turn state: both stores are module-level singletons. */ +function reset(): void { + forgetCheckpoint(SESSION); + forgetBinding(SESSION); +} + +describe("issue #808 - continuity binding survives a restart", () => { + beforeEach(reset); + + it("derives a checkpoint from a synced entry and recovers it from the branch", () => { + const checkpoint = checkpointFromEntry(entryLike()); + expect(checkpoint).toMatchObject({ + sdkSessionId: "sdk-808", + sentCount: 3, + sentPrefixHash: prefixDigest(HASHES, 3), + lastAssistantUuid: "uuid-a3", + systemPromptHash: "sp-1", + toolsetHash: "ts-1", + }); + + expect(latestBindingOnBranch(branchWith(checkpoint))).toMatchObject({ sdkSessionId: "sdk-808" }); + }); + + it("writes no checkpoint before the first stream is synced", () => { + expect(checkpointFromEntry(entryLike({ syncedPrefixHash: null }))).toBeUndefined(); + }); + + it("rebuilds the in-memory binding when the sent prefix still matches", () => { + const checkpoint = checkpointFromEntry(entryLike()); + if (!checkpoint) throw new Error("checkpoint must exist"); + rememberCheckpoint(SESSION, checkpoint); + + expect(getBinding(SESSION)).toBeUndefined(); + expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(true); + expect(getBinding(SESSION)).toMatchObject({ + sdkSessionId: "sdk-808", + sentCount: 3, + sentHashes: ["h0", "h1", "h2"], + lastAssistantUuid: "uuid-a3", + }); + }); + + it.each([ + ["a rewritten prefix", ["h0", "CHANGED", "h2", "h3"], FINGERPRINT, "primary", "claude-opus-5"], + ["a truncated history", ["h0", "h1"], FINGERPRINT, "primary", "claude-opus-5"], + ["a changed system prompt", HASHES, { ...FINGERPRINT, systemPromptHash: "sp-2" }, "primary", "claude-opus-5"], + ["a changed toolset", HASHES, { ...FINGERPRINT, toolsetHash: "ts-2" }, "primary", "claude-opus-5"], + ["a different account", HASHES, FINGERPRINT, "secondary", "claude-opus-5"], + ["a different model", HASHES, FINGERPRINT, "primary", "claude-opus-4-5"], + ])("refuses to rehydrate on %s", (_label, hashes, fingerprint, accountName, modelId) => { + const checkpoint = checkpointFromEntry(entryLike()); + if (!checkpoint) throw new Error("checkpoint must exist"); + rememberCheckpoint(SESSION, checkpoint); + + expect(rehydrateBindingFromCheckpoint(SESSION, hashes, fingerprint, accountName, modelId)).toBe(false); + expect(getBinding(SESSION)).toBeUndefined(); + }); + + it("refuses a checkpoint written before config identity was recorded", () => { + const checkpoint = checkpointFromEntry(entryLike()); + if (!checkpoint) throw new Error("checkpoint must exist"); + rememberCheckpoint(SESSION, { ...checkpoint, systemPromptHash: undefined, toolsetHash: undefined }); + + expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(false); + expect(getBinding(SESSION)).toBeUndefined(); + }); + + it("consumes the checkpoint so a later turn cannot replay it", () => { + const checkpoint = checkpointFromEntry(entryLike()); + if (!checkpoint) throw new Error("checkpoint must exist"); + rememberCheckpoint(SESSION, checkpoint); + + expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(true); + forgetBinding(SESSION); + expect(rehydrateBindingFromCheckpoint(SESSION, HASHES, FINGERPRINT, "primary", "claude-opus-5")).toBe(false); + }); +}); From 5b68b913aa328f5e4808cc53cc6353ce2c944f71 Mon Sep 17 00:00:00 2001 From: 1vivy <1vivy@tutanota.com> Date: Tue, 11 Aug 2026 00:14:46 -0600 Subject: [PATCH 2/2] docs(changelog): note the claude-sdk-oauth binding persistence fix --- packages/coding-agent/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index aeb3915bf4..6a5185390c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,11 @@ authenticated ambient Claude CLI is usable. Empty sentinel credentials and logged-out local CLIs are skipped, and fallback responses carrying errors such as `Not logged in` cannot emit an immediate or delayed green `Fallback model responded` notice ([#803](https://github.com/code-yeongyu/senpi/pull/803)). +- The `claude-sdk-oauth` continuity binding is persisted to the session branch and restored on the next start, so a + restart resumes the existing Claude session instead of re-sending the whole conversation as a `registry_miss` + flatten. The binding is only rebuilt when the current sent-prefix digest, account, model, system prompt and + toolset all still match; anything unproven keeps today's behavior + ([#809](https://github.com/code-yeongyu/senpi/pull/809)). ### New Features