diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9b33ed5213..4e1e0ed2dc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed ### Fixed +- Claude SDK OAuth restart continuity now persists from the compaction-aware active context, survives goal-continuation metadata and a resident entry closing before `message_end`, and therefore avoids flattening and re-sending an oversized full transcript after restart. - `/gpt-account remove` (and every other account-removal path) now leaves the remaining account actually in use. Removing the account the stored credential's top-level fields projected used to keep those fields pointing at the deleted account's tokens, and a provider left with one account does not enter credential rotation, so requests kept authenticating as the removed account; the surviving account's material is now projected onto those fields. - Claude SDK OAuth classifies Fable-style "requires usage credits" failures as a non-retryable entitlement instead of a rate limit, so the account is not blocked for 60s and AgentSession can fall through to the next model ([#709](https://github.com/code-yeongyu/senpi/issues/709)). 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 2f971a94a5..f951674b69 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,25 @@ # claude-sdk-oauth +## 2026-09-03 - Persist compaction-aware restart bindings + +### What changed + +- `session-binding.ts`: requires a matching sent-prefix digest for closed-entry fallback bindings, and admits only labels, approved metadata, goal-cache warmups, and the goal-continuation custom message after a committed assistant. +- `session-registry-wiring.ts`: hashes the converted, compaction-aware session context at `message_end`, including an empty transmitted-message list, and falls back to a verified binding when the resident entry has already closed. + +### Why + +- Restart persistence must use the same provider-visible projection as admission, preserve valid count-zero anchors, and fail closed when lineage identity is unavailable or later conversation may already have reached the SDK. + +### Why an extension could not handle it + +- The SDK sent-stream digest, resident binding lifecycle, and restart sidecar are private to this builtin provider lane; no external extension hook can safely reconstruct them at `message_end`. + +### Expected merge conflict zones + +- MEDIUM: `session-binding.ts` around stored-binding identity checks and safe suffix validation. +- MEDIUM: `session-registry-wiring.ts` at the `message_end` persistence boundary and converted context hashing. + ## 2026-09-03 - Classify Fable usage-credit failures as entitlement ### 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 db29cb3ee4..d040c0cdea 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 @@ -1,8 +1,9 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { GOAL_CONTINUATION_MESSAGE_TYPE } from "../../../messages.ts"; import type { StoredBinding } from "./session-binding-store.ts"; import { assistantContentHash } from "./session-commit-boundary.ts"; import type { ContinuityBinding } from "./session-reattach.ts"; -import { isTransmittedMessage, type SentMessage, sentHashPrefixDigest, sentMessageHashes } from "./session-sync.ts"; +import { sentHashPrefixDigest } from "./session-sync.ts"; export const BINDING_ENTRY_TYPE = "claude-sdk-oauth-binding"; export const BINDING_MARKER = { schemaVersion: 2, marker: true } as const; @@ -66,25 +67,33 @@ export function storedBindingFromEntry( }; } -/** Hashes for the user/toolResult messages the persisted branch already carries. */ -export function sentHashesFromBranch(branch: readonly BranchEntry[]): string[] { - // This walk is not compaction-aware, but admission compares against the - // compaction-truncated context. Anchoring across a boundary would inflate - // sentCount and flatten every later restart, so decline to anchor instead. - if (branch.some((entry) => entry.type === "compaction")) return []; - const messages: SentMessage[] = []; - for (const entry of branch) { - if (entry.type !== "message") continue; - if (isSentMessage(entry.message)) messages.push(entry.message); - } - return sentMessageHashes(messages); -} - -function isSentMessage(value: unknown): value is SentMessage { - if (typeof value !== "object" || value === null) return false; - if (!("role" in value) || typeof value.role !== "string" || !("content" in value)) return false; - // Same selection rule the context path uses, so the digests cannot diverge. - return isTransmittedMessage(value as { role: string }); +/** Persist a completed turn whose resident registry entry closed before `message_end`. */ +export function storedBindingFromBinding( + binding: ContinuityBinding, + hashes: readonly string[], + anchor: StoredBindingAnchor, +): StoredBinding | undefined { + if (binding.sdkSessionIdConfirmed === false) return undefined; + if (binding.sentCount !== hashes.length) return undefined; + const expectedDigest = sentHashPrefixDigest(hashes); + const bindingDigest = + binding.sentPrefixHash ?? (binding.sentHashes.length > 0 ? sentHashPrefixDigest(binding.sentHashes) : undefined); + if (bindingDigest === undefined || bindingDigest !== expectedDigest) return undefined; + return { + schemaVersion: 1, + sessionPath: anchor.sessionPath, + sessionId: anchor.sessionId, + markerEntryId: anchor.markerEntryId, + sdkSessionId: binding.sdkSessionId, + sentCount: hashes.length, + sentPrefixHash: sentHashPrefixDigest(hashes), + assistantContentHash: anchor.assistantContentHash, + lastAssistantUuid: binding.lastAssistantUuid, + accountName: binding.accountName, + modelId: binding.modelId, + systemPromptHash: binding.systemPromptHash, + toolsetHash: binding.toolsetHash, + }; } export function bindingFromStoredBranch( @@ -118,10 +127,12 @@ const SAFE_BINDING_SUFFIX_TYPES: ReadonlySet = new Set([ "senpi.hooks.stop-output", "pi-rules.scan", "rule-activation", + "goal-cache-warmup", ]); function isSafeBindingSuffix(entry: BranchEntry): boolean { if (entry.type === "label") return true; + if (entry.type === "custom_message") return entry.customType === GOAL_CONTINUATION_MESSAGE_TYPE; return entry.type === "custom" && entry.customType !== undefined && SAFE_BINDING_SUFFIX_TYPES.has(entry.customType); } 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 33eb062079..266eb60438 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,4 +1,5 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { convertToLlm } from "../../../messages.ts"; import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "./account-management.ts"; import { @@ -6,7 +7,7 @@ import { BINDING_MARKER, type BindingInvalidation, bindingFromStoredBranch, - sentHashesFromBranch, + storedBindingFromBinding, storedBindingFromEntry, } from "./session-binding.ts"; import { deleteStoredBinding, readStoredBinding, writeStoredBinding } from "./session-binding-store.ts"; @@ -16,7 +17,7 @@ import { isResidentAssistant, isTerminalFailure, } from "./session-commit-boundary.ts"; -import { bindingFromEntry, forgetBinding, rememberBinding } from "./session-reattach.ts"; +import { bindingFromEntry, forgetBinding, getBinding, rememberBinding } from "./session-reattach.ts"; import { closeSession, getSession, @@ -24,7 +25,7 @@ import { recordPendingFork, switchSessionModel, } from "./session-registry.ts"; -import { sentHashesForEntry } from "./session-sync.ts"; +import { sentHashesForEntry, sentMessageHashes, sentMessages } from "./session-sync.ts"; const commitBoundary = new AssistantCommitBoundary(); @@ -122,12 +123,14 @@ export function registerSessionRegistry( if (event.message.role !== "assistant") return; const sessionId = ctx.sessionManager.getSessionId(); const entry = getSession(sessionId); - if (!entry) return; + const binding = getBinding(sessionId); + const modelId = entry?.modelId ?? binding?.modelId; + if (!modelId) return; if (isTerminalFailure(event.message)) { commitBoundary.forget(sessionId); return; } - const outcome = commitBoundary.commit(sessionId, event.message, entry.modelId); + const outcome = commitBoundary.commit(sessionId, event.message, modelId); if (outcome === "rewritten") { recordPendingFork(sessionId, "assistant_rewritten"); await invalidateBinding(pi, ctx, "assistant_rewritten"); @@ -137,20 +140,31 @@ export function registerSessionRegistry( if (outcome !== "clean") return; const sessionFile = ctx.sessionManager.getSessionFile?.(); if (!sessionFile || !pi.appendEntry) return; - const hashes = sentHashesFromBranch(ctx.sessionManager.getBranch()); - if (hashes.length === 0) return; - pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER); - const markerEntryId = ctx.sessionManager.getLeafId(); - if (!markerEntryId) return; - await writeStoredBinding( - sessionFile, - storedBindingFromEntry(entry, hashes, { + const context = ctx.sessionManager.buildSessionContext(); + const hashes = sentMessageHashes(sentMessages({ ...context, messages: convertToLlm(context.messages) })); + const committedAssistantHash = assistantContentHash(event.message); + const recordFor = (markerEntryId: string) => { + const anchor = { sessionPath: sessionFile, sessionId, markerEntryId, - assistantContentHash: assistantContentHash(event.message), - }), - ); + assistantContentHash: committedAssistantHash, + }; + return entry + ? storedBindingFromEntry(entry, hashes, anchor) + : binding + ? storedBindingFromBinding(binding, hashes, anchor) + : undefined; + }; + // Validate before touching the branch: a rejected closed-entry fallback must + // not leave a marker-only entry that retires the still-valid older sidecar. + if (!recordFor("pending")) return; + pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER); + const markerEntryId = ctx.sessionManager.getLeafId(); + if (!markerEntryId) return; + const stored = recordFor(markerEntryId); + if (!stored) return; + await writeStoredBinding(sessionFile, stored); }); pi.on("session_shutdown", (event, ctx) => { closeSession(ctx.sessionManager.getSessionId(), event.reason); diff --git a/packages/coding-agent/test/claude-sdk-oauth-binding-anchor.test.ts b/packages/coding-agent/test/claude-sdk-oauth-binding-anchor.test.ts index 998628c848..03ad294231 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-binding-anchor.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-binding-anchor.test.ts @@ -4,12 +4,11 @@ import { BINDING_ENTRY_TYPE, BINDING_MARKER, bindingFromStoredBranch, - sentHashesFromBranch, + storedBindingFromBinding, storedBindingFromEntry, } from "../src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts"; import type { StoredBinding } from "../src/core/extensions/builtin/claude-sdk-oauth/session-binding-store.ts"; import { assistantContentHash } from "../src/core/extensions/builtin/claude-sdk-oauth/session-commit-boundary.ts"; -import { sentMessageHashes, sentMessages } from "../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; const PROMPT_HASH = "1".repeat(64); const TOOLSET_HASH = "2".repeat(64); @@ -91,16 +90,66 @@ describe("claude-sdk-oauth stored binding anchor", () => { expect(bindingFromStoredBranch([marker(), assistantEntry(assistant("rewritten"))], stored())).toBeUndefined(); }); - it("rejects an anchor followed by later conversation context", () => { + it("allows goal continuation context after the committed assistant", () => { const branch = [ marker(), assistantEntry(), - { type: "message" as const, id: "later-user", message: { role: "user" as const } }, + { + type: "custom_message" as const, + id: "goal-continuation", + customType: "goal-continuation", + content: "Continue the active goal.", + }, + { + type: "custom" as const, + id: "goal-cache", + customType: "goal-cache-warmup", + data: { phase: "scheduled" }, + }, + ]; + + expect(bindingFromStoredBranch(branch, stored())).toMatchObject({ sdkSessionId: "sdk-1", sentCount: 2 }); + }); + + it("rejects a later user message after the committed assistant", () => { + const branch = [ + marker(), + assistantEntry(), + { type: "message" as const, id: "later-user", message: { role: "user" as const, content: "resume" } }, ]; expect(bindingFromStoredBranch(branch, stored())).toBeUndefined(); }); + it("rejects a count-only fallback binding", () => { + const binding = { + senpiSessionId: "senpi-1", + sdkSessionId: "sdk-1", + sentCount: 1, + sentHashes: [], + lastAssistantUuid: null, + accountName: "primary", + modelId: "claude-test", + systemPromptHash: PROMPT_HASH, + toolsetHash: TOOLSET_HASH, + sdkSessionIdConfirmed: true, + }; + expect( + storedBindingFromBinding(binding, ["different"], { + sessionPath: "/tmp/session.jsonl", + sessionId: "senpi-1", + markerEntryId: "marker-1", + assistantContentHash: assistantContentHash(assistant()), + }), + ).toBeUndefined(); + }); + + it("rejects a stale anchor followed by another assistant", () => { + expect( + bindingFromStoredBranch([marker(), assistantEntry(), assistantEntry(assistant("later"))], stored()), + ).toBeUndefined(); + }); + it("allows known non-context metadata after the committed assistant", () => { const branch = [ marker(), @@ -137,42 +186,6 @@ describe("claude-sdk-oauth stored binding anchor", () => { expect(bindingFromStoredBranch(branch, stored())).toBeUndefined(); }); - it("refuses to derive hashes across a compaction boundary", () => { - // The branch walk is not compaction-aware, while admission compares against - // the compaction-truncated context. Deriving here would inflate sentCount and - // flatten every later restart, so refuse to anchor at all. - const message = { role: "user" as const, content: [{ type: "text" as const, text: "before" }], timestamp: 1 }; - - expect( - sentHashesFromBranch([ - { type: "message", id: "u1", message }, - { type: "compaction", id: "c1" }, - { type: "message", id: "u2", message }, - ] as never), - ).toEqual([]); - }); - - it("derives branch hashes exactly as the context path does", () => { - // A content-less user message is skipped when the provider builds its sent - // stream; if only one side skips it, every later index shifts and a restart - // reports a false divergence. - const transmitted = { - role: "user" as const, - content: [{ type: "text" as const, text: "real turn" }], - timestamp: 1, - }; - const contentless = { role: "user" as const, content: [], timestamp: 2 }; - - const fromBranch = sentHashesFromBranch([ - { type: "message", id: "u1", message: transmitted }, - { type: "message", id: "u2", message: contentless }, - ] as never); - const fromContext = sentMessageHashes(sentMessages({ messages: [transmitted, contentless] } as never)); - - expect(fromBranch).toEqual(fromContext); - expect(fromBranch).toHaveLength(1); - }); - it("keeps the sidecar fixed-size when the conversation grows", () => { const sentCount = 10_000; const hashes = Array.from({ length: sentCount }, (_value, index) => `hash-${index}`); diff --git a/packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts b/packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts new file mode 100644 index 0000000000..c3ef7361d6 --- /dev/null +++ b/packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts @@ -0,0 +1,163 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { expect } from "vitest"; +import type { SdkQueryHandle } from "../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; +import { forgetBinding } from "../../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; +import { + closeSession, + getOrCreateSession, + overrideSessionRegistryBoundary, + resetSessionRegistryBoundary, +} from "../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; +import { sentMessageHashes, sentMessages } from "../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; +import type { ExtensionAPI, ExtensionContext } from "../../src/core/extensions/types.ts"; +import { convertToLlm } from "../../src/core/messages.ts"; +import { buildSessionContext, type SessionEntry } from "../../src/core/session-manager.ts"; + +/** Shared fixture for the issue #6981 restart-continuity regressions (lifecycle + compaction re-anchor). */ +export type EventHandler = (event: unknown, ctx: ExtensionContext) => unknown; +export type BranchEntry = { + id: string; + type: string; + parentId?: string; + customType?: string; + content?: unknown; + display?: boolean; + data?: unknown; + message?: unknown; + summary?: string; + firstKeptEntryId?: string; + tokensBefore?: number; + timestamp?: number; +}; + +export const SESSION_ID = "issue-6981"; +export const PROMPT_HASH = "1".repeat(64); +export const TOOLSET_HASH = "2".repeat(64); +const temporaryDirectories: string[] = []; + +export function fakeQuery(): SdkQueryHandle { + return { async *[Symbol.asyncIterator](): AsyncGenerator {}, async interrupt() {}, close() {} }; +} + +export function assistant(text = "turn one"): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "claude-sdk-oauth", + provider: "claude-sdk-oauth", + model: "claude-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 1, + }; +} + +export function sessionFixture() { + const directory = mkdtempSync(join(tmpdir(), "issue-6981-restart-")); + temporaryDirectories.push(directory); + const sessionFile = join(directory, "session.jsonl"); + writeFileSync(sessionFile, "", "utf8"); + // A real `-p -c` turn persists its user message before the assistant commits, + // and that message is what the restart record anchors its sent-prefix on. + const userMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: "turn one" }], + timestamp: 1, + }; + const branch: BranchEntry[] = [{ type: "message", id: "user-entry", message: userMessage }]; + return { sessionFile, branch, contextMessages: [userMessage], turnHashes: sentMessageHashes([userMessage]) }; +} + +export function fakeExtension(branch: BranchEntry[]) { + const handlers = new Map(); + const persisted: Array<{ customType: string; data: unknown }> = []; + const api = { + on(event: string, handler: EventHandler): void { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + appendEntry(customType: string, data: unknown): void { + const id = `custom-${branch.length + 1}`; + branch.push({ type: "custom", id, customType, data }); + persisted.push({ customType, data }); + }, + } as unknown as ExtensionAPI; + return { api, handlers, persisted }; +} + +/** + * Extension context whose `buildSessionContext()` is the REAL session-manager + * projection of `branch` (walks parent ids from the leaf, applies compaction + * entries), so tests prove the digest the wiring persists equals what admission + * computes from the same branch. Entries are chained through `parentId` in + * array order when a test did not set one explicitly. + */ +export function context(sessionFile: string, branch: BranchEntry[]): ExtensionContext { + const entries = (): SessionEntry[] => + branch.map((entry, index) => ({ + timestamp: new Date(entry.timestamp ?? index + 1).toISOString(), + parentId: index === 0 ? null : (branch[index - 1]?.id ?? null), + ...entry, + })) as unknown as SessionEntry[]; + return { + sessionManager: { + getSessionId: () => SESSION_ID, + getSessionFile: () => sessionFile, + getBranch: () => branch, + getLeafId: () => branch[branch.length - 1]?.id ?? null, + buildSessionContext: () => buildSessionContext(entries(), branch[branch.length - 1]?.id ?? null), + }, + } as unknown as ExtensionContext; +} + +/** The admission-side projection of a branch: the same hashes `message_end` persists. */ +export function projectedHashes(branch: BranchEntry[]): string[] { + const ctx = context("", branch).sessionManager.buildSessionContext(); + return sentMessageHashes(sentMessages({ ...ctx, messages: convertToLlm(ctx.messages) })); +} + +export async function emit( + handlers: Map, + eventName: string, + event: unknown, + eventContext: ExtensionContext, +): Promise { + const registered = handlers.get(eventName) ?? []; + expect(registered).toHaveLength(1); + for (const handler of registered) await handler(event, eventContext); +} + +/** Tear down the registry, process bindings, and every temp session directory this fixture created. */ +export function cleanupRestartFixture(): void { + closeSession(SESSION_ID, "test_cleanup"); + forgetBinding(SESSION_ID); + resetSessionRegistryBoundary(); + for (const directory of temporaryDirectories) rmSync(directory, { recursive: true, force: true }); + temporaryDirectories.length = 0; +} + +export function residentEntry() { + overrideSessionRegistryBoundary({ queryFactory: () => fakeQuery() }); + const entry = getOrCreateSession({ + senpiSessionId: SESSION_ID, + accountName: "default", + modelId: "claude-test", + systemPromptHash: PROMPT_HASH, + toolsetHash: TOOLSET_HASH, + options: {}, + }); + entry.sentCount = 1; + entry.sdkSessionIdConfirmed = true; + entry.assistantUuidByIndex.set(1, "assistant-uuid-1"); + return entry; +} diff --git a/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts new file mode 100644 index 0000000000..018e552f94 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it } from "vitest"; +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 { forgetBinding, getBinding } 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 { + sentHashPrefixDigest, + sentMessageHashes, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; +import { + assistant, + cleanupRestartFixture, + context, + emit, + fakeExtension, + PROMPT_HASH, + projectedHashes, + residentEntry, + SESSION_ID, + sessionFixture, + TOOLSET_HASH, +} from "../../helpers/claude-sdk-oauth-restart-fixture.ts"; + +afterEach(() => { + cleanupRestartFixture(); +}); + +describe("issue #6981 compaction restart continuity", () => { + it("persists the admission projection and reattaches after compaction", async () => { + const { sessionFile, branch } = sessionFixture(); + // A compacted branch with real firstKeptEntryId semantics: the first user turn + // is summarised away, "kept-user" (BEFORE the boundary) survives verbatim + // because the compaction names it, and one user turn follows the boundary. + branch.push( + { + type: "message", + id: "kept-user", + message: { + role: "user" as const, + content: [{ type: "text" as const, text: "kept verbatim" }], + timestamp: 2, + }, + }, + { + type: "compaction", + id: "compaction-entry", + summary: "Earlier work summarized.", + firstKeptEntryId: "kept-user", + tokensBefore: 200_000, + timestamp: 3, + }, + { + type: "message", + id: "current-user", + message: { + role: "user" as const, + content: [{ type: "text" as const, text: "after compaction" }], + timestamp: 4, + }, + }, + ); + const extension = fakeExtension(branch); + registerSessionRegistry(extension.api); + const entry = residentEntry(); + const eventContext = context(sessionFile, branch); + // The wiring must persist exactly what the next admission computes from this + // branch through the real session-manager projection: summary, the kept + // pre-boundary user, and the post-boundary user - and NOT the summarised turn. + const expectedHashes = projectedHashes(branch); + expect(expectedHashes).toHaveLength(3); + const summarisedTurn = sentMessageHashes([ + { role: "user", content: [{ type: "text", text: "turn one" }], timestamp: 1 }, + ]); + expect(expectedHashes).not.toContain(summarisedTurn[0]); + + await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + + const stored = await readStoredBinding(sessionFile); + expect(stored).toMatchObject({ + sessionId: SESSION_ID, + sdkSessionId: entry.sdkSessionId, + sentCount: expectedHashes.length, + sentPrefixHash: sentHashPrefixDigest(expectedHashes), + }); + + // The lifecycle appends the committed assistant after the marker; a fresh + // process then restores the sidecar and admission reattaches at that prefix. + branch.push({ type: "message", id: "assistant-entry", message: assistant() }); + closeSession(SESSION_ID, "process_exit"); + forgetBinding(SESSION_ID); + const restarted = fakeExtension(branch); + registerSessionRegistry(restarted.api); + await emit(restarted.handlers, "session_start", { type: "session_start", reason: "resume" }, eventContext); + const restored = getBinding(SESSION_ID); + expect( + decideNativeContinuity({ + entry: undefined, + binding: restored, + currentHashes: projectedHashes(branch), + accountName: "default", + modelId: "claude-test", + fingerprint: { systemPromptHash: PROMPT_HASH, toolsetHash: TOOLSET_HASH }, + transcriptAvailable: true, + }), + ).toMatchObject({ kind: "reattach", reason: "registry_miss" }); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts index 2493183ef8..03b7fd581c 100644 --- a/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts +++ b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts @@ -1,10 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import type { SdkQueryHandle } from "../../../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; import { BINDING_ENTRY_TYPE, BINDING_MARKER, @@ -17,112 +11,27 @@ import { getBinding, rememberBinding, } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; -import { - closeSession, - getOrCreateSession, - overrideSessionRegistryBoundary, - resetSessionRegistryBoundary, -} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.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 { sentMessageHashes } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; -import type { ExtensionAPI, ExtensionContext } from "../../../src/core/extensions/types.ts"; - -type EventHandler = (event: unknown, ctx: ExtensionContext) => unknown; -type BranchEntry = { id: string; type: string; customType?: string; data?: unknown; message?: unknown }; - -const SESSION_ID = "issue-6981"; -const PROMPT_HASH = "1".repeat(64); -const TOOLSET_HASH = "2".repeat(64); -const temporaryDirectories: string[] = []; - -function fakeQuery(): SdkQueryHandle { - return { - async *[Symbol.asyncIterator](): AsyncGenerator {}, - async interrupt() {}, - close() {}, - }; -} - -function assistant(text = "turn one"): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "claude-sdk-oauth", - provider: "claude-sdk-oauth", - model: "claude-test", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: 1, - }; -} - -function sessionFixture() { - const directory = mkdtempSync(join(tmpdir(), "issue-6981-restart-")); - temporaryDirectories.push(directory); - const sessionFile = join(directory, "session.jsonl"); - writeFileSync(sessionFile, "", "utf8"); - // A real `-p -c` turn persists its user message before the assistant commits, - // and that message is what the restart record anchors its sent-prefix on. - const userMessage = { - role: "user" as const, - content: [{ type: "text" as const, text: "turn one" }], - timestamp: 1, - }; - const branch: BranchEntry[] = [{ type: "message", id: "user-entry", message: userMessage }]; - return { sessionFile, branch, turnHashes: sentMessageHashes([userMessage]) }; -} - -function fakeExtension(branch: BranchEntry[]) { - const handlers = new Map(); - const persisted: Array<{ customType: string; data: unknown }> = []; - const api = { - on(event: string, handler: EventHandler): void { - handlers.set(event, [...(handlers.get(event) ?? []), handler]); - }, - appendEntry(customType: string, data: unknown): void { - const id = `custom-${branch.length + 1}`; - branch.push({ type: "custom", id, customType, data }); - persisted.push({ customType, data }); - }, - } as unknown as ExtensionAPI; - return { api, handlers, persisted }; -} - -function context(sessionFile: string, branch: BranchEntry[]): ExtensionContext { - return { - sessionManager: { - getSessionId: () => SESSION_ID, - getSessionFile: () => sessionFile, - getBranch: () => branch, - getLeafId: () => branch.at(-1)?.id ?? null, - }, - } as unknown as ExtensionContext; -} - -async function emit( - handlers: Map, - eventName: string, - event: unknown, - eventContext: ExtensionContext, -): Promise { - const registered = handlers.get(eventName) ?? []; - expect(registered).toHaveLength(1); - for (const handler of registered) await handler(event, eventContext); -} +import { + recordSyncedStream, + sentHashPrefixDigest, +} 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"; afterEach(() => { - closeSession(SESSION_ID, "test_cleanup"); - forgetBinding(SESSION_ID); - resetSessionRegistryBoundary(); - for (const directory of temporaryDirectories) rmSync(directory, { recursive: true, force: true }); - temporaryDirectories.length = 0; + cleanupRestartFixture(); }); describe("issue #6981 headless restart continuity", () => { @@ -163,9 +72,26 @@ describe("issue #6981 headless restart continuity", () => { const entry = residentEntry(); rememberBinding(bindingFromEntry(entry, turnHashes)); const eventContext = context(sessionFile, branch); + recordSyncedStream(entry, turnHashes); + closeSession(SESSION_ID, "other"); await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); branch.push({ type: "message", id: "assistant-entry", message: assistant() }); + branch.push( + { + type: "custom_message", + id: "goal-continuation", + customType: "goal-continuation", + content: "Continue working toward the active thread goal.", + display: false, + }, + { + type: "custom", + id: "goal-cache", + customType: "goal-cache-warmup", + data: { phase: "scheduled" }, + }, + ); expect(extension.persisted).toEqual([{ customType: BINDING_ENTRY_TYPE, data: BINDING_MARKER }]); expect(await readStoredBinding(sessionFile)).toMatchObject({ @@ -199,19 +125,59 @@ describe("issue #6981 headless restart continuity", () => { }), ).toMatchObject({ kind: "reattach", reason: "registry_miss" }); }); -}); -function residentEntry() { - overrideSessionRegistryBoundary({ queryFactory: () => fakeQuery() }); - const entry = getOrCreateSession({ - senpiSessionId: SESSION_ID, - accountName: "default", - modelId: "claude-test", - systemPromptHash: PROMPT_HASH, - toolsetHash: TOOLSET_HASH, - options: {}, + it("leaves no marker when a closed-entry fallback binding does not match the branch", async () => { + // The resident entry is gone (process kept running after closeSession) and the + // remembered binding was minted for a DIFFERENT sent stream: the wiring must + // neither write a sidecar nor append a marker that would retire the last good one. + const { sessionFile, branch } = sessionFixture(); + const extension = fakeExtension(branch); + registerSessionRegistry(extension.api); + const entry = residentEntry(); + rememberBinding({ + ...bindingFromEntry(entry, ["stale-stream-hash"]), + sentPrefixHash: sentHashPrefixDigest(["stale-stream-hash"]), + }); + closeSession(SESSION_ID, "attempt_discarded"); + const eventContext = context(sessionFile, branch); + + await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + + expect(await readStoredBinding(sessionFile)).toBeUndefined(); + expect(extension.persisted).toEqual([]); + expect(branch.some((item) => item.type === "custom" && item.customType === BINDING_ENTRY_TYPE)).toBe(false); + }); + + it("anchors and reattaches a contentless first turn at count zero", async () => { + const { sessionFile, branch } = sessionFixture(); + branch[0].message = { role: "user", content: [], timestamp: 1 }; + const extension = fakeExtension(branch); + registerSessionRegistry(extension.api); + const entry = residentEntry(); + entry.sentCount = 0; + entry.assistantUuidByIndex.clear(); + const eventContext = context(sessionFile, branch); + + await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + const stored = await readStoredBinding(sessionFile); + expect(stored).toMatchObject({ sentCount: 0, sentPrefixHash: sentHashPrefixDigest([]) }); + + branch.push({ type: "message", id: "assistant-entry", message: assistant() }); + closeSession(SESSION_ID, "process_exit"); + forgetBinding(SESSION_ID); + const restarted = fakeExtension(branch); + registerSessionRegistry(restarted.api); + await emit(restarted.handlers, "session_start", { type: "session_start", reason: "resume" }, eventContext); + expect( + decideNativeContinuity({ + entry: undefined, + binding: getBinding(SESSION_ID), + currentHashes: [], + accountName: "default", + modelId: entry.modelId, + fingerprint: { systemPromptHash: PROMPT_HASH, toolsetHash: TOOLSET_HASH }, + transcriptAvailable: true, + }), + ).toMatchObject({ kind: "reattach" }); }); - entry.sentCount = 1; - entry.assistantUuidByIndex.set(1, "assistant-uuid-1"); - return entry; -} +});