From d77a8d2ac8232afbf63049599c1ac1fb6b885fe7 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 12:16:21 +0900 Subject: [PATCH 01/12] fix(claude-sdk-oauth): persist compacted restart bindings Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../claude-sdk-oauth/session-binding.ts | 56 ++++++---- .../session-registry-wiring.ts | 36 ++++--- .../claude-sdk-oauth-binding-anchor.test.ts | 62 ++++------- ...-oauth-headless-restart-continuity.test.ts | 101 ++++++++++++++++-- .../suite/retry-fallback-hard-error.test.ts | 6 +- 5 files changed, 172 insertions(+), 89 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 db29cb3ee4..5b46c491ee 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 @@ -2,7 +2,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; 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 +66,28 @@ 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.sentCount !== hashes.length) 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,13 +121,24 @@ const SAFE_BINDING_SUFFIX_TYPES: ReadonlySet = new Set([ "senpi.hooks.stop-output", "pi-rules.scan", "rule-activation", + "goal-cache-warmup", + "senpi-memory.session-binding", + "omo-memory:accepted-turns", ]); function isSafeBindingSuffix(entry: BranchEntry): boolean { - if (entry.type === "label") return true; + if (entry.type === "label" || entry.type === "custom_message") return true; + if (entry.type === "message") { + return isSentMessage(entry.message); + } return entry.type === "custom" && entry.customType !== undefined && SAFE_BINDING_SUFFIX_TYPES.has(entry.customType); } +function isSentMessage(value: unknown): boolean { + if (typeof value !== "object" || value === null || !("role" in value)) return false; + return value.role === "user" || value.role === "toolResult"; +} + function newestBindingEntryIndex(branch: readonly BranchEntry[]): number { for (let index = branch.length - 1; index >= 0; index -= 1) { const entry = branch[index]; 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..e237eb1a6c 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 @@ -6,7 +6,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 +16,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 +24,7 @@ import { recordPendingFork, switchSessionModel, } from "./session-registry.ts"; -import { sentHashesForEntry } from "./session-sync.ts"; +import { isTransmittedMessage, sentHashesForEntry, sentMessageHashes } from "./session-sync.ts"; const commitBoundary = new AssistantCommitBoundary(); @@ -122,12 +122,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 +139,24 @@ export function registerSessionRegistry( if (outcome !== "clean") return; const sessionFile = ctx.sessionManager.getSessionFile?.(); if (!sessionFile || !pi.appendEntry) return; - const hashes = sentHashesFromBranch(ctx.sessionManager.getBranch()); + const hashes = sentMessageHashes(ctx.sessionManager.buildSessionContext().messages.filter(isTransmittedMessage)); 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, { - sessionPath: sessionFile, - sessionId, - markerEntryId, - assistantContentHash: assistantContentHash(event.message), - }), - ); + const anchor = { + sessionPath: sessionFile, + sessionId, + markerEntryId, + assistantContentHash: assistantContentHash(event.message), + }; + const stored = entry + ? storedBindingFromEntry(entry, hashes, anchor) + : binding + ? storedBindingFromBinding(binding, hashes, anchor) + : undefined; + 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..f92b034af5 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,10 @@ import { BINDING_ENTRY_TYPE, BINDING_MARKER, bindingFromStoredBranch, - sentHashesFromBranch, 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,14 +89,32 @@ 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 unsent 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" }, + }, + { type: "message" as const, id: "later-user", message: { role: "user" as const, content: "resume" } }, ]; - expect(bindingFromStoredBranch(branch, stored())).toBeUndefined(); + expect(bindingFromStoredBranch(branch, stored())).toMatchObject({ sdkSessionId: "sdk-1", sentCount: 2 }); + }); + + 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", () => { @@ -137,42 +153,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/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..b1b36aeaab 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 @@ -2,7 +2,7 @@ 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 type { AssistantMessage, Context } 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 { @@ -24,11 +24,27 @@ import { resetSessionRegistryBoundary, } 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 { + recordSyncedStream, + 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 }; +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; +}; const SESSION_ID = "issue-6981"; const PROMPT_HASH = "1".repeat(64); @@ -76,7 +92,7 @@ function sessionFixture() { timestamp: 1, }; const branch: BranchEntry[] = [{ type: "message", id: "user-entry", message: userMessage }]; - return { sessionFile, branch, turnHashes: sentMessageHashes([userMessage]) }; + return { sessionFile, branch, contextMessages: [userMessage], turnHashes: sentMessageHashes([userMessage]) }; } function fakeExtension(branch: BranchEntry[]) { @@ -95,13 +111,14 @@ function fakeExtension(branch: BranchEntry[]) { return { api, handlers, persisted }; } -function context(sessionFile: string, branch: BranchEntry[]): ExtensionContext { +function context(sessionFile: string, branch: BranchEntry[], messages: Context["messages"]): ExtensionContext { return { sessionManager: { getSessionId: () => SESSION_ID, getSessionFile: () => sessionFile, getBranch: () => branch, - getLeafId: () => branch.at(-1)?.id ?? null, + getLeafId: () => branch[branch.length - 1]?.id ?? null, + buildSessionContext: () => ({ messages }), }, } as unknown as ExtensionContext; } @@ -127,12 +144,12 @@ afterEach(() => { describe("issue #6981 headless restart continuity", () => { it("invalidates persisted continuity when the committed assistant is rewritten", async () => { - const { sessionFile, branch, turnHashes } = sessionFixture(); + const { sessionFile, branch, contextMessages, turnHashes } = sessionFixture(); const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); rememberBinding(bindingFromEntry(entry, turnHashes)); - const eventContext = context(sessionFile, branch); + const eventContext = context(sessionFile, branch, contextMessages); await emit( extension.handlers, @@ -157,15 +174,32 @@ describe("issue #6981 headless restart continuity", () => { }); it("restores a sidecar-bound SDK lineage after a separate process starts", async () => { - const { sessionFile, branch, turnHashes } = sessionFixture(); + const { sessionFile, branch, contextMessages, turnHashes } = sessionFixture(); const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); rememberBinding(bindingFromEntry(entry, turnHashes)); - const eventContext = context(sessionFile, branch); + const eventContext = context(sessionFile, branch, contextMessages); + 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,6 +233,53 @@ describe("issue #6981 headless restart continuity", () => { }), ).toMatchObject({ kind: "reattach", reason: "registry_miss" }); }); + + it("recreates restart continuity after compaction", async () => { + const { sessionFile, branch } = sessionFixture(); + // A long-running resident mirror may no longer carry the materialized + // message bodies that SessionManager.buildSessionContext() can restore. + // Persistence must consume the manager's active context, not rebuild it + // from this lossy branch projection. + branch[0] = { + type: "message", + id: "user-entry", + message: { role: "user" as const, content: [], timestamp: 1 }, + }; + const currentUser = { + role: "user" as const, + content: [{ type: "text" as const, text: "after compaction" }], + timestamp: 3, + }; + branch.push( + { + type: "compaction", + id: "compaction-entry", + parentId: "user-entry", + summary: "Earlier work summarized.", + firstKeptEntryId: "user-entry", + tokensBefore: 200_000, + timestamp: 2, + }, + { + type: "message", + id: "current-user", + parentId: "compaction-entry", + message: { role: "user" as const, content: [], timestamp: 3 }, + }, + ); + const extension = fakeExtension(branch); + registerSessionRegistry(extension.api); + const entry = residentEntry(); + const eventContext = context(sessionFile, branch, [currentUser]); + + await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + + expect(await readStoredBinding(sessionFile)).toMatchObject({ + sessionId: SESSION_ID, + sdkSessionId: entry.sdkSessionId, + sentCount: 1, + }); + }); }); function residentEntry() { diff --git a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts index b386aea7c9..aabd283702 100644 --- a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts +++ b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts @@ -119,7 +119,8 @@ describe("retry fallback hard errors", () => { expect(harness.faux.state.callCount).toBe(1); expect(harness.eventsOfType("auto_retry_start")).toEqual([]); expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); - expect(harness.session.state.messages.at(-1)).toMatchObject({ errorMessage: insufficientQuota }); + const messages = harness.session.state.messages; + expect(messages[messages.length - 1]).toMatchObject({ errorMessage: insufficientQuota }); }); it("does not replay a hard error that contains a tool call", async () => { @@ -158,7 +159,8 @@ describe("retry fallback hard errors", () => { expect(harness.faux.state.callCount).toBe(1); expect(harness.eventsOfType("auto_retry_start")).toEqual([]); - expect(harness.session.state.messages.at(-1)).toMatchObject({ errorMessage: toolSchemaRejection }); + const messages = harness.session.state.messages; + expect(messages[messages.length - 1]).toMatchObject({ errorMessage: toolSchemaRejection }); }); it("switches models immediately on a tool-schema rejection instead of retrying in place", async () => { From 0625ffe693e34bc3dc723b8724f9f2de3dab6218 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 12:16:21 +0900 Subject: [PATCH 02/12] docs(changes): record Claude restart persistence fixes Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/ai/CHANGELOG.md | 2 +- packages/coding-agent/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6c29f6657a..a1b4a39cfb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,7 +12,7 @@ - Adding a second account to a provider whose stored credential predates credential pools (a flat entry with no `accounts` array, which is what `openai-codex` OAuth login writes) no longer overwrites the first one. `appendLoginSlot` now promotes that legacy credential into the pool as the `default` slot and stores the new login beside it as `login-2`, so both accounts remain usable and the flat top-level fields still authenticate a build predating pools. First login (no stored credential) still writes the flat credential as-is, and a provider that returns its own populated `accounts` array is still written through untouched. - Removing the account whose material the flat top-level credential fields projected no longer leaves the pool authenticating as the deleted account. `removeSlot` now re-projects those fields from the first surviving slot, so a pool left with a single account (which does not enter credential rotation and therefore resolves through the flat projection) immediately uses the account that remains. `accounts` is kept, removing a non-projected slot still leaves the flat fields untouched, and removing the last slot still drops the credential entirely. - +- Provider-owned OAuth account pools now survive login persistence and overlapping login flows without losing provider-assigned account names or converting sentinel projections into generated slots. ### Removed ## [2026.9.3-2] - 2026-09-03 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)). From 98c1565089a564c5e32f8c81d81bb0d93cf7823d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 15:08:46 +0900 Subject: [PATCH 03/12] fix(claude-sdk-oauth): align restart binding digests Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../builtin/claude-sdk-oauth/changes.md | 20 +++++++++++++++++++ .../claude-sdk-oauth/session-binding.ts | 7 +++++-- .../session-registry-wiring.ts | 6 ++++-- 3 files changed, 29 insertions(+), 4 deletions(-) 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 5b46c491ee..855b3834db 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 @@ -72,7 +72,12 @@ export function storedBindingFromBinding( hashes: readonly string[], anchor: StoredBindingAnchor, ): StoredBinding | undefined { + if (binding.sdkSessionIdConfirmed === false) return undefined; if (binding.sentCount !== hashes.length) return undefined; + if (binding.sentPrefixHash !== undefined && binding.sentPrefixHash !== sentHashPrefixDigest(hashes)) return undefined; + if (binding.sentHashes.length > 0 && sentHashPrefixDigest(binding.sentHashes) !== sentHashPrefixDigest(hashes)) { + return undefined; + } return { schemaVersion: 1, sessionPath: anchor.sessionPath, @@ -122,8 +127,6 @@ const SAFE_BINDING_SUFFIX_TYPES: ReadonlySet = new Set([ "pi-rules.scan", "rule-activation", "goal-cache-warmup", - "senpi-memory.session-binding", - "omo-memory:accepted-turns", ]); function isSafeBindingSuffix(entry: BranchEntry): boolean { 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 e237eb1a6c..a65fd74f0b 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 { @@ -24,7 +25,7 @@ import { recordPendingFork, switchSessionModel, } from "./session-registry.ts"; -import { isTransmittedMessage, sentHashesForEntry, sentMessageHashes } from "./session-sync.ts"; +import { sentHashesForEntry, sentMessageHashes, sentMessages } from "./session-sync.ts"; const commitBoundary = new AssistantCommitBoundary(); @@ -139,7 +140,8 @@ export function registerSessionRegistry( if (outcome !== "clean") return; const sessionFile = ctx.sessionManager.getSessionFile?.(); if (!sessionFile || !pi.appendEntry) return; - const hashes = sentMessageHashes(ctx.sessionManager.buildSessionContext().messages.filter(isTransmittedMessage)); + const context = ctx.sessionManager.buildSessionContext(); + const hashes = sentMessageHashes(sentMessages({ ...context, messages: convertToLlm(context.messages) })); if (hashes.length === 0) return; pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER); const markerEntryId = ctx.sessionManager.getLeafId(); From 8f92ff4b2864737add3ca04d04aa87f4955cf94f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 15:11:56 +0900 Subject: [PATCH 04/12] test(claude-sdk-oauth): confirm restart binding fixtures Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../6981-claude-sdk-oauth-headless-restart-continuity.test.ts | 1 + 1 file changed, 1 insertion(+) 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 b1b36aeaab..220105cba2 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 @@ -293,6 +293,7 @@ function residentEntry() { options: {}, }); entry.sentCount = 1; + entry.sdkSessionIdConfirmed = true; entry.assistantUuidByIndex.set(1, "assistant-uuid-1"); return entry; } From e56538cdde6757180bf13c083a3190b2761a533d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 15:17:50 +0900 Subject: [PATCH 05/12] test(claude-sdk-oauth): pin compaction digest projection Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- ...laude-sdk-oauth-headless-restart-continuity.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 220105cba2..98fdaf523c 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 @@ -245,6 +245,12 @@ describe("issue #6981 headless restart continuity", () => { id: "user-entry", message: { role: "user" as const, content: [], timestamp: 1 }, }; + const compactionSummary = { + role: "compactionSummary" as const, + summary: "Earlier work summarized.", + tokensBefore: 200_000, + timestamp: 2, + }; const currentUser = { role: "user" as const, content: [{ type: "text" as const, text: "after compaction" }], @@ -270,14 +276,14 @@ describe("issue #6981 headless restart continuity", () => { const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); - const eventContext = context(sessionFile, branch, [currentUser]); + const eventContext = context(sessionFile, branch, [compactionSummary, currentUser]); await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); expect(await readStoredBinding(sessionFile)).toMatchObject({ sessionId: SESSION_ID, sdkSessionId: entry.sdkSessionId, - sentCount: 1, + sentCount: 2, }); }); }); From 042d15d49b1a67f6e30f69f97b7df9b49c11f0b5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 15:19:38 +0900 Subject: [PATCH 06/12] style(claude-sdk-oauth): format binding guard Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../extensions/builtin/claude-sdk-oauth/session-binding.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 855b3834db..4020713f9d 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 @@ -74,7 +74,8 @@ export function storedBindingFromBinding( ): StoredBinding | undefined { if (binding.sdkSessionIdConfirmed === false) return undefined; if (binding.sentCount !== hashes.length) return undefined; - if (binding.sentPrefixHash !== undefined && binding.sentPrefixHash !== sentHashPrefixDigest(hashes)) return undefined; + if (binding.sentPrefixHash !== undefined && binding.sentPrefixHash !== sentHashPrefixDigest(hashes)) + return undefined; if (binding.sentHashes.length > 0 && sentHashPrefixDigest(binding.sentHashes) !== sentHashPrefixDigest(hashes)) { return undefined; } From 19df80439b96f2ec773637a65a1e2fdfc15f2771 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 15:39:21 +0900 Subject: [PATCH 07/12] test(claude-sdk-oauth): type the restart-continuity fixture as AgentMessage[] The compaction-summary fixture is a session AgentMessage, not a pi-ai Message; the root tsc run (which includes tests) rejected the narrower parameter type. --- ...6981-claude-sdk-oauth-headless-restart-continuity.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 98fdaf523c..a1c6ea6631 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 @@ -2,7 +2,8 @@ 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, Context } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +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 { @@ -111,7 +112,7 @@ function fakeExtension(branch: BranchEntry[]) { return { api, handlers, persisted }; } -function context(sessionFile: string, branch: BranchEntry[], messages: Context["messages"]): ExtensionContext { +function context(sessionFile: string, branch: BranchEntry[], messages: AgentMessage[]): ExtensionContext { return { sessionManager: { getSessionId: () => SESSION_ID, From 4e9d255870d8ce8bc946c0a34227b91fb6454141 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 16:12:27 +0900 Subject: [PATCH 08/12] fix(claude-sdk-oauth): close restart continuity review blockers Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/ai/CHANGELOG.md | 1 + .../claude-sdk-oauth/session-binding.ts | 21 +- .../session-registry-wiring.ts | 1 - .../claude-sdk-oauth-binding-anchor.test.ts | 37 ++- ...aude-sdk-oauth-compaction-reanchor.test.ts | 222 ++++++++++++++++++ ...-oauth-headless-restart-continuity.test.ts | 75 ++---- .../suite/retry-fallback-hard-error.test.ts | 6 +- 7 files changed, 293 insertions(+), 70 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a1b4a39cfb..4b4151dfe4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,6 +13,7 @@ - Adding a second account to a provider whose stored credential predates credential pools (a flat entry with no `accounts` array, which is what `openai-codex` OAuth login writes) no longer overwrites the first one. `appendLoginSlot` now promotes that legacy credential into the pool as the `default` slot and stores the new login beside it as `login-2`, so both accounts remain usable and the flat top-level fields still authenticate a build predating pools. First login (no stored credential) still writes the flat credential as-is, and a provider that returns its own populated `accounts` array is still written through untouched. - Removing the account whose material the flat top-level credential fields projected no longer leaves the pool authenticating as the deleted account. `removeSlot` now re-projects those fields from the first surviving slot, so a pool left with a single account (which does not enter credential rotation and therefore resolves through the flat projection) immediately uses the account that remains. `accounts` is kept, removing a non-projected slot still leaves the flat fields untouched, and removing the last slot still drops the credential entirely. - Provider-owned OAuth account pools now survive login persistence and overlapping login flows without losing provider-assigned account names or converting sentinel projections into generated slots. + ### Removed ## [2026.9.3-2] - 2026-09-03 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 4020713f9d..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,4 +1,5 @@ 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"; @@ -74,11 +75,10 @@ export function storedBindingFromBinding( ): StoredBinding | undefined { if (binding.sdkSessionIdConfirmed === false) return undefined; if (binding.sentCount !== hashes.length) return undefined; - if (binding.sentPrefixHash !== undefined && binding.sentPrefixHash !== sentHashPrefixDigest(hashes)) - return undefined; - if (binding.sentHashes.length > 0 && sentHashPrefixDigest(binding.sentHashes) !== sentHashPrefixDigest(hashes)) { - 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, @@ -131,18 +131,11 @@ const SAFE_BINDING_SUFFIX_TYPES: ReadonlySet = new Set([ ]); function isSafeBindingSuffix(entry: BranchEntry): boolean { - if (entry.type === "label" || entry.type === "custom_message") return true; - if (entry.type === "message") { - return isSentMessage(entry.message); - } + 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); } -function isSentMessage(value: unknown): boolean { - if (typeof value !== "object" || value === null || !("role" in value)) return false; - return value.role === "user" || value.role === "toolResult"; -} - function newestBindingEntryIndex(branch: readonly BranchEntry[]): number { for (let index = branch.length - 1; index >= 0; index -= 1) { const entry = branch[index]; 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 a65fd74f0b..e1b91d33ef 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 @@ -142,7 +142,6 @@ export function registerSessionRegistry( if (!sessionFile || !pi.appendEntry) return; const context = ctx.sessionManager.buildSessionContext(); const hashes = sentMessageHashes(sentMessages({ ...context, messages: convertToLlm(context.messages) })); - if (hashes.length === 0) return; pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER); const markerEntryId = ctx.sessionManager.getLeafId(); if (!markerEntryId) return; 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 f92b034af5..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,6 +4,7 @@ import { BINDING_ENTRY_TYPE, BINDING_MARKER, bindingFromStoredBranch, + 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"; @@ -89,7 +90,7 @@ describe("claude-sdk-oauth stored binding anchor", () => { expect(bindingFromStoredBranch([marker(), assistantEntry(assistant("rewritten"))], stored())).toBeUndefined(); }); - it("allows unsent goal continuation context after the committed assistant", () => { + it("allows goal continuation context after the committed assistant", () => { const branch = [ marker(), assistantEntry(), @@ -105,12 +106,44 @@ describe("claude-sdk-oauth stored binding anchor", () => { customType: "goal-cache-warmup", data: { phase: "scheduled" }, }, - { type: "message" as const, id: "later-user", message: { role: "user" as const, content: "resume" } }, ]; 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()), 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..ae0c7802e4 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-compaction-reanchor.test.ts @@ -0,0 +1,222 @@ +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 { AgentMessage } from "@earendil-works/pi-agent-core"; +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 { 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, + getOrCreateSession, + overrideSessionRegistryBoundary, + resetSessionRegistryBoundary, +} 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, + 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"; + +type EventHandler = (event: unknown, ctx: ExtensionContext) => unknown; +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; +}; + +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, contextMessages: [userMessage], 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[], messages: AgentMessage[]): ExtensionContext { + return { + sessionManager: { + getSessionId: () => SESSION_ID, + getSessionFile: () => sessionFile, + getBranch: () => branch, + getLeafId: () => branch[branch.length - 1]?.id ?? null, + buildSessionContext: () => ({ messages }), + }, + } 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); +} + +afterEach(() => { + closeSession(SESSION_ID, "test_cleanup"); + forgetBinding(SESSION_ID); + resetSessionRegistryBoundary(); + for (const directory of temporaryDirectories) rmSync(directory, { recursive: true, force: true }); + temporaryDirectories.length = 0; +}); + +describe("issue #6981 compaction restart continuity", () => { + it("persists the admission projection and reattaches after compaction", async () => { + const { sessionFile, branch } = sessionFixture(); + branch[0] = { type: "message", id: "user-entry", message: { role: "user" as const, content: [], timestamp: 1 } }; + const compactionSummary = { + role: "compactionSummary" as const, + summary: "Earlier work summarized.", + tokensBefore: 200_000, + timestamp: 2, + }; + const currentUser = { + role: "user" as const, + content: [{ type: "text" as const, text: "after compaction" }], + timestamp: 3, + }; + branch.push( + { + type: "compaction", + id: "compaction-entry", + parentId: "user-entry", + summary: "Earlier work summarized.", + firstKeptEntryId: "user-entry", + tokensBefore: 200_000, + timestamp: 2, + }, + { + type: "message", + id: "current-user", + parentId: "compaction-entry", + message: { role: "user" as const, content: [], timestamp: 3 }, + }, + ); + const extension = fakeExtension(branch); + registerSessionRegistry(extension.api); + const entry = residentEntry(); + const contextMessages = [compactionSummary, currentUser]; + const eventContext = context(sessionFile, branch, contextMessages); + const expectedHashes = sentMessageHashes(sentMessages({ messages: convertToLlm(contextMessages) })); + + 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), + }); + + 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: expectedHashes, + accountName: "default", + modelId: "claude-test", + fingerprint: { systemPromptHash: PROMPT_HASH, toolsetHash: TOOLSET_HASH }, + transcriptAvailable: true, + }), + ).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: {}, + }); + 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-headless-restart-continuity.test.ts b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts index a1c6ea6631..5ad9b65f0f 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 @@ -27,6 +27,7 @@ import { import { registerSessionRegistry } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts"; import { recordSyncedStream, + sentHashPrefixDigest, sentMessageHashes, } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; import type { ExtensionAPI, ExtensionContext } from "../../../src/core/extensions/types.ts"; @@ -53,11 +54,7 @@ const TOOLSET_HASH = "2".repeat(64); const temporaryDirectories: string[] = []; function fakeQuery(): SdkQueryHandle { - return { - async *[Symbol.asyncIterator](): AsyncGenerator {}, - async interrupt() {}, - close() {}, - }; + return { async *[Symbol.asyncIterator](): AsyncGenerator {}, async interrupt() {}, close() {} }; } function assistant(text = "turn one"): AssistantMessage { @@ -235,57 +232,37 @@ describe("issue #6981 headless restart continuity", () => { ).toMatchObject({ kind: "reattach", reason: "registry_miss" }); }); - it("recreates restart continuity after compaction", async () => { + it("anchors and reattaches a contentless first turn at count zero", async () => { const { sessionFile, branch } = sessionFixture(); - // A long-running resident mirror may no longer carry the materialized - // message bodies that SessionManager.buildSessionContext() can restore. - // Persistence must consume the manager's active context, not rebuild it - // from this lossy branch projection. - branch[0] = { - type: "message", - id: "user-entry", - message: { role: "user" as const, content: [], timestamp: 1 }, - }; - const compactionSummary = { - role: "compactionSummary" as const, - summary: "Earlier work summarized.", - tokensBefore: 200_000, - timestamp: 2, - }; - const currentUser = { - role: "user" as const, - content: [{ type: "text" as const, text: "after compaction" }], - timestamp: 3, - }; - branch.push( - { - type: "compaction", - id: "compaction-entry", - parentId: "user-entry", - summary: "Earlier work summarized.", - firstKeptEntryId: "user-entry", - tokensBefore: 200_000, - timestamp: 2, - }, - { - type: "message", - id: "current-user", - parentId: "compaction-entry", - message: { role: "user" as const, content: [], timestamp: 3 }, - }, - ); + branch[0].message = { role: "user", content: [], timestamp: 1 }; const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); - const eventContext = context(sessionFile, branch, [compactionSummary, currentUser]); + entry.sentCount = 0; + entry.assistantUuidByIndex.clear(); + const eventContext = context(sessionFile, branch, [{ role: "user", content: [], timestamp: 1 }]); await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + const stored = await readStoredBinding(sessionFile); + expect(stored).toMatchObject({ sentCount: 0, sentPrefixHash: sentHashPrefixDigest([]) }); - expect(await readStoredBinding(sessionFile)).toMatchObject({ - sessionId: SESSION_ID, - sdkSessionId: entry.sdkSessionId, - sentCount: 2, - }); + 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" }); }); }); diff --git a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts index aabd283702..b386aea7c9 100644 --- a/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts +++ b/packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts @@ -119,8 +119,7 @@ describe("retry fallback hard errors", () => { expect(harness.faux.state.callCount).toBe(1); expect(harness.eventsOfType("auto_retry_start")).toEqual([]); expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); - const messages = harness.session.state.messages; - expect(messages[messages.length - 1]).toMatchObject({ errorMessage: insufficientQuota }); + expect(harness.session.state.messages.at(-1)).toMatchObject({ errorMessage: insufficientQuota }); }); it("does not replay a hard error that contains a tool call", async () => { @@ -159,8 +158,7 @@ describe("retry fallback hard errors", () => { expect(harness.faux.state.callCount).toBe(1); expect(harness.eventsOfType("auto_retry_start")).toEqual([]); - const messages = harness.session.state.messages; - expect(messages[messages.length - 1]).toMatchObject({ errorMessage: toolSchemaRejection }); + expect(harness.session.state.messages.at(-1)).toMatchObject({ errorMessage: toolSchemaRejection }); }); it("switches models immediately on a tool-schema rejection instead of retrying in place", async () => { From dce3ae0e90bb631e5a459fb32f351cffc5ef1a32 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 16:31:45 +0900 Subject: [PATCH 09/12] test(claude-sdk-oauth): share the #6981 restart fixture between the lifecycle and compaction regressions Both regression files carried the same session/extension fixture; hoisting it into test/helpers/claude-sdk-oauth-restart-fixture.ts keeps each file under the 250 pure-LOC ceiling and lets the two suites drift together. --- .../claude-sdk-oauth-restart-fixture.ts | 143 ++++++++++++++++ ...aude-sdk-oauth-compaction-reanchor.test.ts | 153 ++---------------- ...-oauth-headless-restart-continuity.test.ts | 150 ++--------------- 3 files changed, 171 insertions(+), 275 deletions(-) create mode 100644 packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts 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..3beeaf2a78 --- /dev/null +++ b/packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts @@ -0,0 +1,143 @@ +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 { AgentMessage } from "@earendil-works/pi-agent-core"; +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 } from "../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; +import type { ExtensionAPI, ExtensionContext } from "../../src/core/extensions/types.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 }; +} + +export function context(sessionFile: string, branch: BranchEntry[], messages: AgentMessage[]): ExtensionContext { + return { + sessionManager: { + getSessionId: () => SESSION_ID, + getSessionFile: () => sessionFile, + getBranch: () => branch, + getLeafId: () => branch[branch.length - 1]?.id ?? null, + buildSessionContext: () => ({ messages }), + }, + } as unknown as ExtensionContext; +} + +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 index ae0c7802e4..32a88c39e0 100644 --- 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 @@ -1,139 +1,30 @@ -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 { AgentMessage } from "@earendil-works/pi-agent-core"; -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 { 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, - 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 { sentHashPrefixDigest, 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"; - -type EventHandler = (event: unknown, ctx: ExtensionContext) => unknown; -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; -}; - -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, contextMessages: [userMessage], 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[], messages: AgentMessage[]): ExtensionContext { - return { - sessionManager: { - getSessionId: () => SESSION_ID, - getSessionFile: () => sessionFile, - getBranch: () => branch, - getLeafId: () => branch[branch.length - 1]?.id ?? null, - buildSessionContext: () => ({ messages }), - }, - } 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 { + 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 compaction restart continuity", () => { @@ -204,19 +95,3 @@ describe("issue #6981 compaction 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: {}, - }); - 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-headless-restart-continuity.test.ts b/packages/coding-agent/test/suite/regressions/6981-claude-sdk-oauth-headless-restart-continuity.test.ts index 5ad9b65f0f..ded755dc85 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,11 +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 { AgentMessage } from "@earendil-works/pi-agent-core"; -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, @@ -18,126 +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 { recordSyncedStream, sentHashPrefixDigest, - 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; - parentId?: string; - customType?: string; - content?: unknown; - display?: boolean; - data?: unknown; - message?: unknown; - summary?: string; - firstKeptEntryId?: string; - tokensBefore?: number; - timestamp?: number; -}; - -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, contextMessages: [userMessage], 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[], messages: AgentMessage[]): ExtensionContext { - return { - sessionManager: { - getSessionId: () => SESSION_ID, - getSessionFile: () => sessionFile, - getBranch: () => branch, - getLeafId: () => branch[branch.length - 1]?.id ?? null, - buildSessionContext: () => ({ messages }), - }, - } 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 { + 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", () => { @@ -265,19 +159,3 @@ describe("issue #6981 headless restart continuity", () => { ).toMatchObject({ kind: "reattach" }); }); }); - -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; -} From 6d770784f4e28bc480e6e81c4146843f64a38c83 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 17:12:21 +0900 Subject: [PATCH 10/12] fix(claude-sdk-oauth): validate the restart record before appending its marker A closed-entry fallback whose remembered binding does not match the current branch used to append a binding marker and then skip the sidecar write; that marker-only entry retired the still-valid older sidecar on the next restart. Build the candidate record first and touch the branch only when it validates. The #6981 fixtures now project the branch through the real buildSessionContext(), so the compaction regression proves the persisted digest equals the one admission computes instead of asserting a hand-supplied context. --- .../session-registry-wiring.ts | 29 +++++++----- .../claude-sdk-oauth-restart-fixture.ts | 28 ++++++++++-- ...aude-sdk-oauth-compaction-reanchor.test.ts | 45 ++++++++----------- ...-oauth-headless-restart-continuity.test.ts | 32 ++++++++++--- 4 files changed, 88 insertions(+), 46 deletions(-) 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 e1b91d33ef..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 @@ -142,20 +142,27 @@ export function registerSessionRegistry( if (!sessionFile || !pi.appendEntry) return; 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: 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 anchor = { - sessionPath: sessionFile, - sessionId, - markerEntryId, - assistantContentHash: assistantContentHash(event.message), - }; - const stored = entry - ? storedBindingFromEntry(entry, hashes, anchor) - : binding - ? storedBindingFromBinding(binding, hashes, anchor) - : undefined; + const stored = recordFor(markerEntryId); if (!stored) return; await writeStoredBinding(sessionFile, stored); }); 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 index 3beeaf2a78..c3ef7361d6 100644 --- a/packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts +++ b/packages/coding-agent/test/helpers/claude-sdk-oauth-restart-fixture.ts @@ -2,7 +2,6 @@ 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 { AgentMessage } from "@earendil-works/pi-agent-core"; 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"; @@ -13,8 +12,10 @@ import { overrideSessionRegistryBoundary, resetSessionRegistryBoundary, } from "../../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; -import { sentMessageHashes } from "../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.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; @@ -94,18 +95,37 @@ export function fakeExtension(branch: BranchEntry[]) { return { api, handlers, persisted }; } -export function context(sessionFile: string, branch: BranchEntry[], messages: AgentMessage[]): ExtensionContext { +/** + * 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: () => ({ messages }), + 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, 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 index 32a88c39e0..44cb72c1f2 100644 --- 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 @@ -4,12 +4,7 @@ import { decideNativeContinuity } from "../../../src/core/extensions/builtin/cla 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, - sentMessages, -} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; -import { convertToLlm } from "../../../src/core/messages.ts"; +import { sentHashPrefixDigest } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; import { assistant, cleanupRestartFixture, @@ -17,6 +12,7 @@ import { emit, fakeExtension, PROMPT_HASH, + projectedHashes, residentEntry, SESSION_ID, sessionFixture, @@ -30,43 +26,38 @@ afterEach(() => { describe("issue #6981 compaction restart continuity", () => { it("persists the admission projection and reattaches after compaction", async () => { const { sessionFile, branch } = sessionFixture(); - branch[0] = { type: "message", id: "user-entry", message: { role: "user" as const, content: [], timestamp: 1 } }; - const compactionSummary = { - role: "compactionSummary" as const, - summary: "Earlier work summarized.", - tokensBefore: 200_000, - timestamp: 2, - }; - const currentUser = { - role: "user" as const, - content: [{ type: "text" as const, text: "after compaction" }], - timestamp: 3, - }; + // A compacted branch: the original user turn is summarised away and one + // user message survives after the compaction boundary. branch.push( { type: "compaction", id: "compaction-entry", - parentId: "user-entry", summary: "Earlier work summarized.", - firstKeptEntryId: "user-entry", + firstKeptEntryId: "current-user", tokensBefore: 200_000, timestamp: 2, }, { type: "message", id: "current-user", - parentId: "compaction-entry", - message: { role: "user" as const, content: [], timestamp: 3 }, + message: { + role: "user" as const, + content: [{ type: "text" as const, text: "after compaction" }], + timestamp: 3, + }, }, ); const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); - const contextMessages = [compactionSummary, currentUser]; - const eventContext = context(sessionFile, branch, contextMessages); - const expectedHashes = sentMessageHashes(sentMessages({ messages: convertToLlm(contextMessages) })); + 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 + kept user). + const expectedHashes = projectedHashes(branch); + expect(expectedHashes).toHaveLength(2); await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); + const stored = await readStoredBinding(sessionFile); expect(stored).toMatchObject({ sessionId: SESSION_ID, @@ -75,6 +66,8 @@ describe("issue #6981 compaction restart continuity", () => { 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); @@ -86,7 +79,7 @@ describe("issue #6981 compaction restart continuity", () => { decideNativeContinuity({ entry: undefined, binding: restored, - currentHashes: expectedHashes, + currentHashes: projectedHashes(branch), accountName: "default", modelId: "claude-test", fingerprint: { systemPromptHash: PROMPT_HASH, toolsetHash: TOOLSET_HASH }, 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 ded755dc85..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 @@ -36,12 +36,12 @@ afterEach(() => { describe("issue #6981 headless restart continuity", () => { it("invalidates persisted continuity when the committed assistant is rewritten", async () => { - const { sessionFile, branch, contextMessages, turnHashes } = sessionFixture(); + const { sessionFile, branch, turnHashes } = sessionFixture(); const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); rememberBinding(bindingFromEntry(entry, turnHashes)); - const eventContext = context(sessionFile, branch, contextMessages); + const eventContext = context(sessionFile, branch); await emit( extension.handlers, @@ -66,12 +66,12 @@ describe("issue #6981 headless restart continuity", () => { }); it("restores a sidecar-bound SDK lineage after a separate process starts", async () => { - const { sessionFile, branch, contextMessages, turnHashes } = sessionFixture(); + const { sessionFile, branch, turnHashes } = sessionFixture(); const extension = fakeExtension(branch); registerSessionRegistry(extension.api); const entry = residentEntry(); rememberBinding(bindingFromEntry(entry, turnHashes)); - const eventContext = context(sessionFile, branch, contextMessages); + const eventContext = context(sessionFile, branch); recordSyncedStream(entry, turnHashes); closeSession(SESSION_ID, "other"); @@ -126,6 +126,28 @@ describe("issue #6981 headless restart continuity", () => { ).toMatchObject({ kind: "reattach", reason: "registry_miss" }); }); + 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 }; @@ -134,7 +156,7 @@ describe("issue #6981 headless restart continuity", () => { const entry = residentEntry(); entry.sentCount = 0; entry.assistantUuidByIndex.clear(); - const eventContext = context(sessionFile, branch, [{ role: "user", content: [], timestamp: 1 }]); + const eventContext = context(sessionFile, branch); await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); const stored = await readStoredBinding(sessionFile); From a975b2ccc74e82e8b6360f8b1a0e1fecddd6bfb7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 17:15:16 +0900 Subject: [PATCH 11/12] docs(ai): drop the pool bullet that no longer belongs to the restart-continuity PR --- packages/ai/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4b4151dfe4..6c29f6657a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,7 +12,6 @@ - Adding a second account to a provider whose stored credential predates credential pools (a flat entry with no `accounts` array, which is what `openai-codex` OAuth login writes) no longer overwrites the first one. `appendLoginSlot` now promotes that legacy credential into the pool as the `default` slot and stores the new login beside it as `login-2`, so both accounts remain usable and the flat top-level fields still authenticate a build predating pools. First login (no stored credential) still writes the flat credential as-is, and a provider that returns its own populated `accounts` array is still written through untouched. - Removing the account whose material the flat top-level credential fields projected no longer leaves the pool authenticating as the deleted account. `removeSlot` now re-projects those fields from the first surviving slot, so a pool left with a single account (which does not enter credential rotation and therefore resolves through the flat projection) immediately uses the account that remains. `accounts` is kept, removing a non-projected slot still leaves the flat fields untouched, and removing the last slot still drops the credential entirely. -- Provider-owned OAuth account pools now survive login persistence and overlapping login flows without losing provider-assigned account names or converting sentinel projections into generated slots. ### Removed From dba53d1a339e97642a33715793d806e8669b5630 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 17:50:06 +0900 Subject: [PATCH 12/12] test(claude-sdk-oauth): exercise real firstKeptEntryId semantics in the compaction re-anchor regression The compaction fixture named a post-boundary entry as firstKeptEntryId, so the expected projection came from the unconditional post-compaction slice rather than from selecting a kept pre-boundary entry. The branch now carries a summarised turn, a kept pre-boundary user, the compaction naming it, and a post-boundary user; the test asserts the projected digest covers exactly the summary + kept + later turns and excludes the summarised one. --- ...aude-sdk-oauth-compaction-reanchor.test.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) 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 index 44cb72c1f2..018e552f94 100644 --- 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 @@ -4,7 +4,10 @@ import { decideNativeContinuity } from "../../../src/core/extensions/builtin/cla 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 } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; +import { + sentHashPrefixDigest, + sentMessageHashes, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; import { assistant, cleanupRestartFixture, @@ -26,16 +29,26 @@ afterEach(() => { describe("issue #6981 compaction restart continuity", () => { it("persists the admission projection and reattaches after compaction", async () => { const { sessionFile, branch } = sessionFixture(); - // A compacted branch: the original user turn is summarised away and one - // user message survives after the compaction boundary. + // 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: "current-user", + firstKeptEntryId: "kept-user", tokensBefore: 200_000, - timestamp: 2, + timestamp: 3, }, { type: "message", @@ -43,7 +56,7 @@ describe("issue #6981 compaction restart continuity", () => { message: { role: "user" as const, content: [{ type: "text" as const, text: "after compaction" }], - timestamp: 3, + timestamp: 4, }, }, ); @@ -52,9 +65,14 @@ describe("issue #6981 compaction restart continuity", () => { 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 + kept user). + // 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(2); + 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);