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 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); + }); +});