From 1ba8323f5d66328093a0dac70e2b2e297c3d9c72 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 00:53:17 +0900 Subject: [PATCH 01/10] fix(auth): preserve selected Claude OAuth slot (OLI-281) --- .../builtin/claude-sdk-oauth/changes.md | 10 +++++++++ .../builtin/claude-sdk-oauth/oauth-login.ts | 6 ++++- .../suite/claude-sdk-oauth-extension.test.ts | 22 +++++++++---------- 3 files changed, 25 insertions(+), 13 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 f3ec8e3443..a557c7c2f9 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,15 @@ # claude-sdk-oauth +## 2026-09-02 - Preserve selected OAuth slot during resume + +### What changed + +- Treat a non-sentinel top-level OAuth credential as one configured account even when the provider-owned `accounts` pool is absent. + +### Why + +- Session resume can project the selected account into the top-level credential while omitting the sibling pool. Counting only `accounts` incorrectly routes the session to the ambient lane and reports the provider as unconfigured. + ## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts index 1766ee58fc..93628ee0f9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: { environment?: Record, ): Promise => { const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : []; + const selectedStoredAccount = + stored?.type === "oauth" && + stored.access !== SENTINEL_OAUTH_FIELDS.access && + stored.refresh !== SENTINEL_OAUTH_FIELDS.refresh; const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx)); const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length; - const accountCount = storedAccounts.length + environmentTokenCount; + const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount; const settings = deps.readSettings?.(); const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient"); if (lane === "ambient") { diff --git a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts index 7e2da39d41..d27b678a8f 100644 --- a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts +++ b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts @@ -55,19 +55,17 @@ async function createRuntimeWithProvider(config: ProviderConfigInput, storage = return runtime; } -function authenticatedStorage(): AuthStorage { +function authenticatedStorage(accountCount = 1): AuthStorage { return AuthStorage.inMemory({ [CLAUDE_SDK_OAUTH_PROVIDER_ID]: { ...emptyCredential(), - accounts: [ - { - name: "test", - refresh: "test-refresh", - access: "test-access", - expires: Date.now() + 60_000, - source: "login", - }, - ], + accounts: Array.from({ length: accountCount }, (_, index) => ({ + name: `test-${index + 1}`, + refresh: `test-refresh-${index + 1}`, + access: `test-access-${index + 1}`, + expires: Date.now() + 60_000, + source: "login" as const, + })), }, }); } @@ -146,7 +144,7 @@ describe("claude-sdk-oauth builtin provider", () => { }); }); - it("preflight reaches streamSimple with a stored login", async () => { + it("preflight reaches streamSimple with multiple stored logins", async () => { const { registration } = captureRegistration(); let called = false; const config: ProviderConfigInput = { @@ -156,7 +154,7 @@ describe("claude-sdk-oauth builtin provider", () => { return fakeStreamSimple()(model, context); }, }; - const runtime = await createRuntimeWithProvider(config, authenticatedStorage()); + const runtime = await createRuntimeWithProvider(config, authenticatedStorage(2)); const model = (await runtime.getAvailable(CLAUDE_SDK_OAUTH_PROVIDER_ID))[0]; expect(model).toBeDefined(); const stream = runtime.streamSimple(model as Model, { messages: [], tools: [] } as unknown as Context); From 82efb6edb02b4d8ed280c4933822202fefbe5e22 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 02:38:00 +0900 Subject: [PATCH 02/10] fix(auth): preserve provider-owned credential pools (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/ai/src/auth/pool/slots.ts | 4 ++ packages/ai/src/changes.md | 26 ++++++++ .../test/credential-pool-write-paths.test.ts | 59 +++++++++++++++++++ .../test/claude-sdk-oauth-auth-status.test.ts | 17 ++++++ 4 files changed, 106 insertions(+) diff --git a/packages/ai/src/auth/pool/slots.ts b/packages/ai/src/auth/pool/slots.ts index a9eda87ff8..b7f1454af4 100644 --- a/packages/ai/src/auth/pool/slots.ts +++ b/packages/ai/src/auth/pool/slots.ts @@ -146,6 +146,10 @@ function nextLoginSlotName(credential: PooledCredential): string { * user's stored bytes change until a second credential actually exists. */ export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential { + // Provider-owned account flows already return the complete next pool. Treating + // that sentinel-backed result as one flat login discards its new real account + // and appends an unusable sentinel slot instead. + if ("accounts" in flat && Array.isArray(flat.accounts) && flat.accounts.length > 0) return flat; if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) { return flat; } diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index b108b431b8..612e4cb75f 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,29 @@ +## Provider-owned OAuth pools survive generic login persistence (2026-09-02) + +### What changed + +- `auth/pool/slots.ts`: `appendLoginSlot` now accepts a provider-owned credential that already contains a non-empty + `accounts` pool as the complete next entry instead of projecting its sentinel top-level fields into a generated + `login-N` slot. +- `packages/ai/test/credential-pool-write-paths.test.ts`: covers a provider-owned OAuth login returning the current + account plus a newly named sibling, proving both accounts survive without a synthetic sentinel slot. + +### Why + +- Claude SDK OAuth and Cursor CLI OAuth own account naming and return the full updated pool from their login flows. + The generic persistence layer treated that pooled result as a new flat credential, discarded the provider's real + new account, and appended the pool's compatibility sentinel as `login-2`. Credential rotation then selected the + unusable sentinel and request-time auth failed with `Provider is not configured`. + +### Why an extension could not handle it + +- The destructive second merge happens after the provider login returns, inside the shared credential-store mutation + in `Models.login`; provider extensions cannot alter that generic persistence boundary. + +### Expected merge conflict zones + +- LOW: the early provider-owned-pool branch in `appendLoginSlot` in `auth/pool/slots.ts`. + ## Cursor conversation cache eviction cannot break a live request (2026-08-31) ### What changed diff --git a/packages/ai/test/credential-pool-write-paths.test.ts b/packages/ai/test/credential-pool-write-paths.test.ts index 7a0f80fa26..9d93d2bf72 100644 --- a/packages/ai/test/credential-pool-write-paths.test.ts +++ b/packages/ai/test/credential-pool-write-paths.test.ts @@ -53,6 +53,65 @@ describe("Models slot-preserving login/logout/refresh", () => { expect(listSlots(stored).find((slot) => slot.name === "login-2")).toMatchObject({ key: "rotated-key" }); }); + test("provider-owned pooled login result replaces the current pool without a sentinel slot", async () => { + const current = { + type: "oauth", + access: "managed", + refresh: "managed", + expires: 4_102_444_800_000, + accounts: [ + { + name: "default", + access: "default-access", + refresh: "default-refresh", + expires: 4_102_444_800_000, + source: "login" as const, + }, + ], + } satisfies PooledCredential; + const providerOwnedResult = { + ...current, + accounts: [ + ...current.accounts, + { + name: "second", + access: "second-access", + refresh: "second-refresh", + expires: 4_102_444_800_000, + source: "login" as const, + }, + ], + } satisfies PooledCredential; + const provider = createProvider({ + id: "provider-owned-pool", + name: "Provider-owned Pool", + baseUrl: "https://provider-owned-pool.example", + auth: { + oauth: { + name: "Provider-owned Pool", + login: async () => providerOwnedResult, + refresh: async (credential: OAuthCredential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + }, + }, + models: [], + api: "openai-responses" as never, + }); + const store = new InMemoryCredentialStore(); + await store.modify(provider.id, async () => current); + const models = createModels({ credentials: store }); + models.setProvider(provider); + + await models.login(provider.id, "oauth", promptInteraction("unused")); + + const stored = (await store.read(provider.id)) as PooledCredential; + expect(listSlots(stored).map((slot) => slot.name)).toEqual(["default", "second"]); + expect(listSlots(stored).find((slot) => slot.name === "second")).toMatchObject({ + access: "second-access", + refresh: "second-refresh", + }); + }); + test("logout with a slotId removes only that slot", async () => { const store = new InMemoryCredentialStore(); await store.modify("pooltest", async () => pooledApiKeyEntry()); diff --git a/packages/coding-agent/test/claude-sdk-oauth-auth-status.test.ts b/packages/coding-agent/test/claude-sdk-oauth-auth-status.test.ts index adbb8d487c..cc940708f0 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-auth-status.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-auth-status.test.ts @@ -62,6 +62,23 @@ describe("claude-sdk-oauth availability", () => { expect(readAmbientAuthStatus).not.toHaveBeenCalled(); }); + it("accepts a resume-projected selected account without a sibling pool", async () => { + const readAmbientAuthStatus = vi.fn(async () => false); + const check = availabilityCheck(readAmbientAuthStatus); + const credential = { + type: "oauth" as const, + access: "selected-access", + refresh: "selected-refresh", + expires: Date.now() + 60_000, + }; + + expect(await check({ ctx: authContext(), credential })).toEqual({ + type: "oauth", + source: "Claude SDK OAuth", + }); + expect(readAmbientAuthStatus).not.toHaveBeenCalled(); + }); + it("rejects a persisted empty managed credential when ambient auth is logged out", async () => { const check = availabilityCheck( async () => false, From e759820cbac10173442508493523d1e2c6e6d8cd Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 02:40:35 +0900 Subject: [PATCH 03/10] docs(changes): record OAuth pool persistence fix (LAB-100) 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 | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f38f428acc..73ad5d468c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,8 @@ ### Fixed +- Provider-owned OAuth account flows now persist their complete updated credential pool instead of having the generic login layer discard the real new account and append the pool's compatibility sentinel as a synthetic `login-N` slot ([#1262](https://github.com/code-yeongyu/senpi/pull/1262) by [@eddieparc](https://github.com/eddieparc)). + ### Removed ## [2026.8.31] - 2026-08-31 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 555b91dc6d..6f7e10aa76 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,8 @@ ### Fixed +- Claude SDK OAuth and Cursor CLI OAuth account additions now retain the provider-added account instead of creating an unusable sentinel `login-N` entry that could be selected on the next turn and fail with `Provider is not configured` ([#1262](https://github.com/code-yeongyu/senpi/pull/1262) by [@eddieparc](https://github.com/eddieparc)). + - `/quit` and `/exit` submitted while startup is still finishing (managed-tool downloads) now quit instead of being parked back in the editor behind a "Startup is still in progress" notice. Parking the text also disabled the Ctrl+D quit escape, which only fires on an empty editor, so the usual way out was a dead end until the line was cleared by hand. - An extension calling `ctx.shutdown()` while the session is idle now shuts down immediately instead of waiting for an `agent_settled` event that an idle session never emits, which previously stranded the request until the user happened to run another turn. From 6d4b21227ce86e03efbdd4ed8252c8b3a3b17974 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 02:55:23 +0900 Subject: [PATCH 04/10] fix(auth): merge overlapping provider-owned logins (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/ai/src/auth/pool/slots.ts | 13 ++- packages/ai/src/changes.md | 10 +- .../ai/test/provider-owned-pool-login.test.ts | 106 ++++++++++++++++++ .../suite/claude-sdk-oauth-extension.test.ts | 22 ++-- 4 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 packages/ai/test/provider-owned-pool-login.test.ts diff --git a/packages/ai/src/auth/pool/slots.ts b/packages/ai/src/auth/pool/slots.ts index b7f1454af4..bd8fe3cb86 100644 --- a/packages/ai/src/auth/pool/slots.ts +++ b/packages/ai/src/auth/pool/slots.ts @@ -140,6 +140,10 @@ function nextLoginSlotName(credential: PooledCredential): string { throw new Error("Credential pool is full"); } +function hasProviderOwnedPool(credential: Credential): credential is PooledCredential & { accounts: CredentialSlot[] } { + return "accounts" in credential && Array.isArray(credential.accounts); +} + /** * Appends an unnamed flat credential to a pool as a generated `login-N` slot. A * flat or absent current entry keeps today's whole-write shape so no existing @@ -149,7 +153,14 @@ export function appendLoginSlot(current: PooledCredential | undefined, flat: Cre // Provider-owned account flows already return the complete next pool. Treating // that sentinel-backed result as one flat login discards its new real account // and appends an unusable sentinel slot instead. - if ("accounts" in flat && Array.isArray(flat.accounts) && flat.accounts.length > 0) return flat; + if (hasProviderOwnedPool(flat)) { + if (flat.accounts.length === 0 || !current || !Array.isArray(current.accounts)) return flat; + const returnedNames = new Set(flat.accounts.map((slot) => slot.name)); + const concurrentAccounts = current.accounts.filter((slot) => !returnedNames.has(slot.name)); + if (concurrentAccounts.length === 0) return flat; + const merged: PooledCredential = { ...flat, accounts: [...flat.accounts, ...concurrentAccounts] }; + return merged; + } if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) { return flat; } diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index 612e4cb75f..63b5a9f4d3 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -2,11 +2,13 @@ ### What changed -- `auth/pool/slots.ts`: `appendLoginSlot` now accepts a provider-owned credential that already contains a non-empty - `accounts` pool as the complete next entry instead of projecting its sentinel top-level fields into a generated - `login-N` slot. +- `auth/pool/slots.ts`: `appendLoginSlot` now recognizes a provider-owned credential by its `accounts` array instead + of projecting sentinel top-level fields into a generated `login-N` slot. An explicitly empty provider pool remains + empty; a non-empty result merges accounts added by another login while both flows overlapped. - `packages/ai/test/credential-pool-write-paths.test.ts`: covers a provider-owned OAuth login returning the current account plus a newly named sibling, proving both accounts survive without a synthetic sentinel slot. +- `packages/ai/test/provider-owned-pool-login.test.ts`: deterministically overlaps two provider-owned login flows and + covers an explicitly empty returned pool. ### Why @@ -22,7 +24,7 @@ ### Expected merge conflict zones -- LOW: the early provider-owned-pool branch in `appendLoginSlot` in `auth/pool/slots.ts`. +- LOW: provider-owned-pool detection and serialized account merge in `appendLoginSlot` in `auth/pool/slots.ts`. ## Cursor conversation cache eviction cannot break a live request (2026-08-31) diff --git a/packages/ai/test/provider-owned-pool-login.test.ts b/packages/ai/test/provider-owned-pool-login.test.ts new file mode 100644 index 0000000000..bfc5ccf947 --- /dev/null +++ b/packages/ai/test/provider-owned-pool-login.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts"; +import type { AuthInteraction, OAuthCredential } from "../src/auth/types.ts"; +import { createModels, createProvider } from "../src/models.ts"; + +const PROVIDER_ID = "provider-owned-pool"; +const EXPIRES = 4_102_444_800_000; +type ProviderOwnedOAuthCredential = OAuthCredential & PooledCredential; + +function pool(...names: string[]): ProviderOwnedOAuthCredential { + return { + type: "oauth", + access: "managed", + refresh: "managed", + expires: EXPIRES, + accounts: names.map((name) => ({ + name, + access: `${name}-access`, + refresh: `${name}-refresh`, + expires: EXPIRES, + source: "login", + })), + }; +} + +function interaction(): AuthInteraction { + return { + signal: AbortSignal.timeout(5_000), + prompt: async () => "unused", + notify: () => {}, + }; +} + +function provider(login: () => Promise) { + return createProvider({ + id: PROVIDER_ID, + name: "Provider-owned Pool", + baseUrl: "https://provider-owned-pool.example", + auth: { + oauth: { + name: "Provider-owned Pool", + login, + refresh: async (credential: OAuthCredential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + }, + }, + models: [], + api: "openai-responses" as never, + }); +} + +function deferred() { + let settle: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + resolve(value: T) { + if (!settle) throw new Error("deferred resolver was not initialized"); + settle(value); + }, + }; +} + +describe("provider-owned pool login persistence", () => { + test("merges accounts added by overlapping login flows", async () => { + const entered = deferred(); + const first = deferred(); + const second = deferred(); + let callCount = 0; + const store = new InMemoryCredentialStore(); + const models = createModels({ credentials: store }); + models.setProvider( + provider(async () => { + const call = ++callCount; + if (call === 2) entered.resolve(); + return call === 1 ? first.promise : second.promise; + }), + ); + await store.modify(PROVIDER_ID, async () => pool("default")); + + const firstLogin = models.login(PROVIDER_ID, "oauth", interaction()); + const secondLogin = models.login(PROVIDER_ID, "oauth", interaction()); + await entered.promise; + first.resolve(pool("default", "first")); + second.resolve(pool("default", "second")); + await Promise.all([firstLogin, secondLogin]); + + const stored = (await store.read(PROVIDER_ID)) as PooledCredential; + expect(new Set(listSlots(stored).map((slot) => slot.name))).toEqual(new Set(["default", "first", "second"])); + }); + + test("preserves an explicitly empty provider-owned pool", async () => { + const store = new InMemoryCredentialStore(); + await store.modify(PROVIDER_ID, async () => pool("default")); + const models = createModels({ credentials: store }); + models.setProvider(provider(async () => pool())); + + await models.login(PROVIDER_ID, "oauth", interaction()); + + const stored = (await store.read(PROVIDER_ID)) as PooledCredential; + expect(stored.accounts).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts index d27b678a8f..7e2da39d41 100644 --- a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts +++ b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts @@ -55,17 +55,19 @@ async function createRuntimeWithProvider(config: ProviderConfigInput, storage = return runtime; } -function authenticatedStorage(accountCount = 1): AuthStorage { +function authenticatedStorage(): AuthStorage { return AuthStorage.inMemory({ [CLAUDE_SDK_OAUTH_PROVIDER_ID]: { ...emptyCredential(), - accounts: Array.from({ length: accountCount }, (_, index) => ({ - name: `test-${index + 1}`, - refresh: `test-refresh-${index + 1}`, - access: `test-access-${index + 1}`, - expires: Date.now() + 60_000, - source: "login" as const, - })), + accounts: [ + { + name: "test", + refresh: "test-refresh", + access: "test-access", + expires: Date.now() + 60_000, + source: "login", + }, + ], }, }); } @@ -144,7 +146,7 @@ describe("claude-sdk-oauth builtin provider", () => { }); }); - it("preflight reaches streamSimple with multiple stored logins", async () => { + it("preflight reaches streamSimple with a stored login", async () => { const { registration } = captureRegistration(); let called = false; const config: ProviderConfigInput = { @@ -154,7 +156,7 @@ describe("claude-sdk-oauth builtin provider", () => { return fakeStreamSimple()(model, context); }, }; - const runtime = await createRuntimeWithProvider(config, authenticatedStorage(2)); + const runtime = await createRuntimeWithProvider(config, authenticatedStorage()); const model = (await runtime.getAvailable(CLAUDE_SDK_OAUTH_PROVIDER_ID))[0]; expect(model).toBeDefined(); const stream = runtime.streamSimple(model as Model, { messages: [], tools: [] } as unknown as Context); From 968d379cf7194944fd8a35eefa0a98613048b45b Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 03:09:03 +0900 Subject: [PATCH 05/10] test(auth): assert pinned Claude account projection (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../suite/claude-sdk-oauth-extension.test.ts | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts index 7e2da39d41..48e509978b 100644 --- a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts +++ b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts @@ -55,19 +55,18 @@ async function createRuntimeWithProvider(config: ProviderConfigInput, storage = return runtime; } -function authenticatedStorage(): AuthStorage { +function authenticatedStorage(accountCount = 1, pinned?: string): AuthStorage { return AuthStorage.inMemory({ [CLAUDE_SDK_OAUTH_PROVIDER_ID]: { ...emptyCredential(), - accounts: [ - { - name: "test", - refresh: "test-refresh", - access: "test-access", - expires: Date.now() + 60_000, - source: "login", - }, - ], + accounts: Array.from({ length: accountCount }, (_, index) => ({ + name: `test-${index + 1}`, + refresh: `test-refresh-${index + 1}`, + access: `test-access-${index + 1}`, + expires: Date.now() + 60_000, + source: "login" as const, + })), + ...(pinned === undefined ? {} : { pinned }), }, }); } @@ -146,21 +145,33 @@ describe("claude-sdk-oauth builtin provider", () => { }); }); - it("preflight reaches streamSimple with a stored login", async () => { + it("preflight projects the pinned stored login before reaching streamSimple", async () => { const { registration } = captureRegistration(); + const oauth = registration.config.oauth; + const check = oauth?.check; + if (!oauth || !check) throw new Error("extension did not register an OAuth check"); + const checkedAccess: Array = []; let called = false; const config: ProviderConfigInput = { ...registration.config, + oauth: { + ...oauth, + check: async (input) => { + checkedAccess.push(input.credential?.access); + return check(input); + }, + }, streamSimple: (model: Model, context: Context) => { called = true; return fakeStreamSimple()(model, context); }, }; - const runtime = await createRuntimeWithProvider(config, authenticatedStorage()); + const runtime = await createRuntimeWithProvider(config, authenticatedStorage(2, "test-2")); const model = (await runtime.getAvailable(CLAUDE_SDK_OAUTH_PROVIDER_ID))[0]; expect(model).toBeDefined(); const stream = runtime.streamSimple(model as Model, { messages: [], tools: [] } as unknown as Context); for await (const event of stream) void event; expect(called).toBe(true); + expect(checkedAccess).toContain("test-access-2"); }); }); From 79114336c9d76e0b7d2f2ac69d6d83540d058658 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 10:12:20 +0900 Subject: [PATCH 06/10] fix(retry): recover compacted Claude sessions (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/CHANGELOG.md | 6 ++ packages/coding-agent/src/changes.md | 22 +++++++ .../coding-agent/src/core/agent-session.ts | 21 ++++++- .../builtin/claude-sdk-oauth/changes.md | 15 +++++ .../claude-sdk-oauth/session-binding.ts | 24 ++----- .../builtin/model-fallback/index.ts | 12 +++- .../src/core/extensions/runner.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 2 + .../claude-sdk-oauth-binding-anchor.test.ts | 49 ++++++++++----- .../helpers/extension-session-settings.ts | 1 + .../test/suite/model-fallback-command.test.ts | 3 +- .../suite/model-fallback-host-wiring.test.ts | 23 +++++++ ...-oauth-headless-restart-continuity.test.ts | 63 ++++++++++++++++--- .../suite/retry-fallback-hard-error.test.ts | 33 +++++++++- 14 files changed, 223 insertions(+), 52 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f7e10aa76..567f5bbc14 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,8 +4,14 @@ ### Added +- `/fallback restore` returns the current session to the model and thinking level active before fallback without changing global defaults. + ### Fixed +- Claude SDK OAuth sessions recreate restart continuity after compaction instead of permanently losing their sidecar and replaying the full compacted context on the next process start. + +- Provider-owned operation aborts now use the configured turn retry budget (three retries by default) before model fallback; explicit user aborts remain terminal. + - Claude SDK OAuth and Cursor CLI OAuth account additions now retain the provider-added account instead of creating an unusable sentinel `login-N` entry that could be selected on the next turn and fail with `Provider is not configured` ([#1262](https://github.com/code-yeongyu/senpi/pull/1262) by [@eddieparc](https://github.com/eddieparc)). - `/quit` and `/exit` submitted while startup is still finishing (managed-tool downloads) now quit instead of being parked back in the editor behind a "Startup is still in progress" notice. Parking the text also disabled the Ctrl+D quit escape, which only fires on an empty editor, so the usual way out was a dead end until the line was cleared by hand. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 70551aa9d0..03738ea313 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,5 +1,27 @@ # changes +## 2026-09-02 - Retry provider aborts and restore the pre-fallback model + +### What changed + +- Provider-owned `This operation was aborted` results enter the ordinary turn retry budget, which defaults to three retries, before model fallback. Explicit user aborts remain terminal. +- `/fallback restore` switches only the current session back to the model and thinking level active before fallback, then clears the live fallback state. +- `ExtensionSessionSettings` exposes the narrow `restoreFallbackPrimary()` action used by the builtin command. + +### Why + +- A fallback provider could return an operation abort once and strand the session after a single attempt even though the existing transient retry budget was three. +- Operators had no direct session command to undo an unwanted fallback without manually reconstructing the original model selector and thinking level. + +### Why an extension could not handle it + +- Retry admission and the pre-fallback model/thinking state are owned by `AgentSession` and its retry controller. The builtin command uses only the narrow session action exposed by core. + +### Expected merge conflict zones + +- MEDIUM: retry admission in `_processAgentEvent`, `sessionSettings` binding in `_bindExtensionCore`, and the `ExtensionSessionSettings` public contract. +- LOW: the builtin `/fallback` argument router. + ## 2026-09-01 - Negotiate RPC session auto-titling ### What changed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index df0249c23c..b301dc55f9 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -173,7 +173,7 @@ import { expandPromptTemplateWithMetadata, type PromptTemplate } from "./prompt- import { createProviderTimeoutRetryPlan, runBoundedRetryContinuation } from "./provider-timeout-retry.ts"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; import { isBillingErrorMessage } from "./retry-fallback/billing.ts"; -import { formatSelector } from "./retry-fallback/chains.ts"; +import { formatSelector, parseFallbackSelector } from "./retry-fallback/chains.ts"; import { RetryFallbackController } from "./retry-fallback/controller.ts"; import { SelectorCooldowns } from "./retry-fallback/cooldown.ts"; import { @@ -2349,8 +2349,12 @@ export class AgentSession { const retryAfterRequiredCompaction = requiredAutoCompaction !== undefined && this._isRequiredCompactionError(msg); - // Retry transient failures normally and eligible hard errors only through a fallback. - const retryableError = this._isRetryableError(msg); + // A provider-owned AbortError is not a user cancellation. Admit it to + // the ordinary three-retry turn budget, then fall back if it persists. + // User/system abort provenance still wins and never replays a cancelled turn. + const providerOperationAbort = + msg.stopReason === "aborted" && msg.errorMessage?.trim().toLowerCase() === "this operation was aborted"; + const retryableError = this._isRetryableError(msg) || providerOperationAbort; const hardErrorFallbackEligible = this._isHardErrorFallbackEligible(msg); const cursorZeroTokenRe = isCursorZeroTokenResourceExhausted(msg); const cursorQuotaRe = isCursorQuotaResourceExhausted(msg, this.model?.contextWindow ?? 0); @@ -6880,6 +6884,17 @@ export class AgentSession { pinned: active.pinned, }; }, + restoreFallbackPrimary: async () => { + const active = this._retryFallback.activeState; + if (!active) return false; + const selector = parseFallbackSelector(active.originalSelector, this._modelRegistry); + const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined; + if (!model) return false; + const originalThinkingLevel = active.originalThinkingLevel; + await this.setSessionModel(model); + if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel); + return true; + }, }, compact: (options) => { const admission = this._claimPendingCompactionAdmission(); 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 a557c7c2f9..751b812d4f 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,20 @@ # claude-sdk-oauth +## 2026-09-02 - Recreate restart bindings after compaction + +### What changed + +- Derive persisted sent-stream hashes from the same compaction-aware active context projection used by the session runtime instead of refusing every branch that contains a compaction entry. +- Recreate the fixed-size restart sidecar after the first successful post-compaction assistant turn. + +### Why + +- Accepted compaction deleted the previous sidecar, and the old branch walker returned no hashes forever after. Restarting a long compacted session therefore cold-seeded hundreds of messages; an aborted seed left a zero-count lineage that replayed the entire context again and failed with `invalid_request`. + +### Expected merge conflict zones + +- LOW: `session-binding.ts` at `sentHashesFromBranch`, plus the restart and binding-anchor regressions. + ## 2026-09-02 - Preserve selected OAuth slot during resume ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts index db29cb3ee4..ff003ca091 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts @@ -1,8 +1,9 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { buildSessionContext, type SessionEntry } from "../../../session-manager.ts"; import type { StoredBinding } from "./session-binding-store.ts"; import { assistantContentHash } from "./session-commit-boundary.ts"; import type { ContinuityBinding } from "./session-reattach.ts"; -import { isTransmittedMessage, type SentMessage, sentHashPrefixDigest, sentMessageHashes } from "./session-sync.ts"; +import { isTransmittedMessage, sentHashPrefixDigest, sentMessageHashes } from "./session-sync.ts"; export const BINDING_ENTRY_TYPE = "claude-sdk-oauth-binding"; export const BINDING_MARKER = { schemaVersion: 2, marker: true } as const; @@ -67,24 +68,9 @@ 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 }); +export function sentHashesFromBranch(branch: readonly SessionEntry[]): string[] { + const context = buildSessionContext([...branch], branch[branch.length - 1]?.id); + return sentMessageHashes(context.messages.filter(isTransmittedMessage)); } export function bindingFromStoredBranch( diff --git a/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts b/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts index 1aa917b9e9..b7bc2a2d08 100644 --- a/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/model-fallback/index.ts @@ -13,7 +13,7 @@ export default function modelFallbackExtension(pi: ExtensionAPI): void { }); pi.registerCommand("fallback", { description: "View and manage retry model fallback chains.", - argumentHint: "[target [fallback1 fallback2 ...]]", + argumentHint: "[restore | target fallback1 [fallback2 ...]]", handler: async (rawArgs, ctx) => handleFallbackCommand(rawArgs, ctx), }); } @@ -45,8 +45,16 @@ async function handleFallbackCommand(rawArgs: string, ctx: ExtensionCommandConte }); return; } + if (args.length === 1 && args[0] === "restore") { + const restored = await ctx.sessionSettings.restoreFallbackPrimary(); + ctx.ui.notify( + restored ? "Restored the pre-fallback model for this session." : "This session has no active fallback model.", + restored ? "info" : "warning", + ); + return; + } if (args.length < 2) { - ctx.ui.notify("Usage: /fallback [fallback2 ...]", "error"); + ctx.ui.notify("Usage: /fallback restore | /fallback [fallback2 ...]", "error"); return; } await saveChain(ctx, args[0], args.slice(1)); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 0888e91a5b..82a08af3ac 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -329,6 +329,7 @@ function createNoOpSessionSettings(): ExtensionContextActions["sessionSettings"] }, reload: () => settings.reload(), getFallbackStatus: () => undefined, + restoreFallbackPrimary: async () => false, }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index be2a23f4f6..31d6d2e515 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -324,6 +324,8 @@ export interface ExtensionSessionSettings { setFallbackRevertPolicy(policy: "cooldown-expiry" | "never"): Promise; reload(): Promise; getFallbackStatus(): RetryFallbackStatus | undefined; + /** Restore this session to the model and thinking level active before fallback. */ + restoreFallbackPrimary(): Promise; } export interface ContextUsage { 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..b0088e5c79 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 @@ -137,19 +137,24 @@ 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 hashes from the active context after compaction", () => { + const before = { role: "user" as const, content: [{ type: "text" as const, text: "before" }], timestamp: 1 }; + const after = { role: "user" as const, content: [{ type: "text" as const, text: "after" }], timestamp: 3 }; + const fromBranch = sentHashesFromBranch([ + { type: "message", id: "u1", parentId: null, timestamp: "2026-09-02T00:00:01.000Z", message: before }, + { + type: "compaction", + id: "c1", + parentId: "u1", + timestamp: "2026-09-02T00:00:02.000Z", + summary: "Earlier work summarized.", + firstKeptEntryId: "u1", + tokensBefore: 100, + }, + { type: "message", id: "u2", parentId: "c1", timestamp: "2026-09-02T00:00:03.000Z", message: after }, + ]); + + expect(fromBranch).toEqual(sentMessageHashes(sentMessages({ messages: [before, after] } as never))); }); it("derives branch hashes exactly as the context path does", () => { @@ -164,9 +169,21 @@ describe("claude-sdk-oauth stored binding anchor", () => { 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); + { + type: "message", + id: "u1", + parentId: null, + timestamp: "2026-09-02T00:00:01.000Z", + message: transmitted, + }, + { + type: "message", + id: "u2", + parentId: "u1", + timestamp: "2026-09-02T00:00:02.000Z", + message: contentless, + }, + ]); const fromContext = sentMessageHashes(sentMessages({ messages: [transmitted, contentless] } as never)); expect(fromBranch).toEqual(fromContext); diff --git a/packages/coding-agent/test/helpers/extension-session-settings.ts b/packages/coding-agent/test/helpers/extension-session-settings.ts index 0740ffa346..b57eb47fec 100644 --- a/packages/coding-agent/test/helpers/extension-session-settings.ts +++ b/packages/coding-agent/test/helpers/extension-session-settings.ts @@ -33,5 +33,6 @@ export function createInMemoryExtensionSessionSettings(): ExtensionSessionSettin }, reload: () => settings.reload(), getFallbackStatus: () => undefined, + restoreFallbackPrimary: async () => false, }; } diff --git a/packages/coding-agent/test/suite/model-fallback-command.test.ts b/packages/coding-agent/test/suite/model-fallback-command.test.ts index 7c30468648..c0fcffa2ae 100644 --- a/packages/coding-agent/test/suite/model-fallback-command.test.ts +++ b/packages/coding-agent/test/suite/model-fallback-command.test.ts @@ -155,6 +155,7 @@ async function context( }, reload: () => settings.reload(), getFallbackStatus: () => undefined, + restoreFallbackPrimary: async () => false, }, compact: () => {}, getMessageRevision: () => 0, @@ -185,7 +186,7 @@ describe("model fallback builtin command", () => { it("registers /fallback with its quick-set hint", async () => { const command = (await harness()).get("fallback"); - expect(command?.argumentHint).toBe("[target [fallback1 fallback2 ...]]"); + expect(command?.argumentHint).toBe("[restore | target fallback1 [fallback2 ...]]"); expect(command?.description).toContain("fallback"); }); diff --git a/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts b/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts index 9678245d3c..39e3707a5b 100644 --- a/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts +++ b/packages/coding-agent/test/suite/model-fallback-host-wiring.test.ts @@ -157,4 +157,27 @@ describe("model fallback host wiring", () => { pinned: false, }); }); + + it("restores the pre-fallback model for the current session on command", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, baseDelayMs: 1, maxRetries: 0, fallbackChains: { [primary]: [fallback] } }, + }, + extensionFactories: [{ factory: modelFallbackExtension }], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }), + fauxAssistantMessage("fallback response"), + ]); + await harness.session.prompt("enter fallback"); + expect(harness.session.model?.id).toBe("faux-2"); + + const context = harness.getExtensionRunner().createCommandContext(); + await getFallbackCommand(harness).handler("restore", context); + + expect(harness.session.model?.id).toBe("faux-1"); + expect(context.sessionSettings.getFallbackStatus()).toBeUndefined(); + }); }); 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..3972b748a2 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 { @@ -28,7 +28,18 @@ import { sentMessageHashes } from "../../../src/core/extensions/builtin/claude-s 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; + 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 +87,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 +106,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 +139,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,12 +169,12 @@ 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); await emit(extension.handlers, "message_end", { type: "message_end", message: assistant() }, eventContext); branch.push({ type: "message", id: "assistant-entry", message: assistant() }); @@ -199,6 +211,39 @@ describe("issue #6981 headless restart continuity", () => { }), ).toMatchObject({ kind: "reattach", reason: "registry_miss" }); }); + + it("recreates restart continuity after compaction", async () => { + const { sessionFile, branch } = sessionFixture(); + 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: currentUser }, + ); + 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: 2, + }); + }); }); 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..55313b2d26 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 () => { @@ -222,4 +224,31 @@ describe("retry fallback hard errors", () => { expect(harness.eventsOfType("auto_retry_start")).toEqual([]); expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); }); + + it("retries a provider operation abort three times before fallback", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1" }, { id: "faux-2" }], + settings: { + retry: { enabled: true, maxRetries: 3, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } }, + }, + }); + harnesses.push(harness); + harness.setResponses([ + ...Array.from({ length: 4 }, () => + fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "This operation was aborted" }), + ), + fauxAssistantMessage("fallback answer"), + ]); + + await harness.session.prompt("hello"); + + expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual([ + "faux-1", + "faux-1", + "faux-1", + "faux-1", + "faux-2", + ]); + expect(harness.eventsOfType("retry_fallback_applied")).toMatchObject([{ reason: "transient" }]); + }); }); From 7197e3c0d3764b2dcdf8c77172d55b75223a2a71 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 10:46:33 +0900 Subject: [PATCH 07/10] fix(auth): persist compacted session bindings (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/CHANGELOG.md | 2 - packages/coding-agent/src/changes.md | 8 +-- .../coding-agent/src/core/agent-session.ts | 8 +-- packages/coding-agent/src/core/changes.md | 18 ++++++ .../builtin/claude-sdk-oauth/changes.md | 6 +- .../claude-sdk-oauth/session-binding.ts | 9 +-- .../session-registry-wiring.ts | 5 +- .../src/core/extensions/changes.md | 18 ++++++ .../claude-sdk-oauth-binding-anchor.test.ts | 55 ------------------- ...-oauth-headless-restart-continuity.test.ts | 18 +++++- .../suite/retry-fallback-hard-error.test.ts | 27 --------- 11 files changed, 63 insertions(+), 111 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1259cdbd25..e65db147b6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,8 +12,6 @@ - Claude SDK OAuth sessions recreate restart continuity after compaction instead of permanently losing their sidecar and replaying the full compacted context on the next process start. -- Provider-owned operation aborts now use the configured turn retry budget (three retries by default) before model fallback; explicit user aborts remain terminal. - - Claude SDK OAuth and Cursor CLI OAuth account additions now retain the provider-added account instead of creating an unusable sentinel `login-N` entry that could be selected on the next turn and fail with `Provider is not configured` ([#1262](https://github.com/code-yeongyu/senpi/pull/1262) by [@eddieparc](https://github.com/eddieparc)). - The shared RPC host now starts on Windows: socket endpoints resolve to `\\.\pipe\` named-pipe addresses derived from a per-endpoint secret (stored `0600` beside the logical path) with a constant-time authenticated handshake gating both the public and internal listeners, pidfile ownership proof no longer depends on MSYS `ps`, logical-path filesystem cleanup is skipped for named pipes, and detached supervisor/daemon startup failures no longer leak live children or signal reused PIDs. POSIX transports keep the prior unix-socket + `0600` behavior. diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 03738ea313..0607671a39 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,25 +1,23 @@ # changes -## 2026-09-02 - Retry provider aborts and restore the pre-fallback model +## 2026-09-02 - Restore the pre-fallback model ### What changed -- Provider-owned `This operation was aborted` results enter the ordinary turn retry budget, which defaults to three retries, before model fallback. Explicit user aborts remain terminal. - `/fallback restore` switches only the current session back to the model and thinking level active before fallback, then clears the live fallback state. - `ExtensionSessionSettings` exposes the narrow `restoreFallbackPrimary()` action used by the builtin command. ### Why -- A fallback provider could return an operation abort once and strand the session after a single attempt even though the existing transient retry budget was three. - Operators had no direct session command to undo an unwanted fallback without manually reconstructing the original model selector and thinking level. ### Why an extension could not handle it -- Retry admission and the pre-fallback model/thinking state are owned by `AgentSession` and its retry controller. The builtin command uses only the narrow session action exposed by core. +- The pre-fallback model/thinking state is owned by `AgentSession` and its retry controller. The builtin command uses only the narrow session action exposed by core. ### Expected merge conflict zones -- MEDIUM: retry admission in `_processAgentEvent`, `sessionSettings` binding in `_bindExtensionCore`, and the `ExtensionSessionSettings` public contract. +- MEDIUM: `sessionSettings` binding in `_bindExtensionCore` and the `ExtensionSessionSettings` public contract. - LOW: the builtin `/fallback` argument router. ## 2026-09-01 - Negotiate RPC session auto-titling diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index b301dc55f9..4f19760139 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2349,12 +2349,8 @@ export class AgentSession { const retryAfterRequiredCompaction = requiredAutoCompaction !== undefined && this._isRequiredCompactionError(msg); - // A provider-owned AbortError is not a user cancellation. Admit it to - // the ordinary three-retry turn budget, then fall back if it persists. - // User/system abort provenance still wins and never replays a cancelled turn. - const providerOperationAbort = - msg.stopReason === "aborted" && msg.errorMessage?.trim().toLowerCase() === "this operation was aborted"; - const retryableError = this._isRetryableError(msg) || providerOperationAbort; + // Retry transient failures normally and eligible hard errors only through a fallback. + const retryableError = this._isRetryableError(msg); const hardErrorFallbackEligible = this._isHardErrorFallbackEligible(msg); const cursorZeroTokenRe = isCursorZeroTokenResourceExhausted(msg); const cursorQuotaRe = isCursorQuotaResourceExhausted(msg, this.model?.contextWindow ?? 0); diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index e9794623f6..275cc442f6 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-02 - Restore pre-fallback session state + +### What changed + +- The session settings binding exposes `restoreFallbackPrimary()`, which resolves the controller's recorded original model, switches only the current session, restores its prior thinking level, and clears live fallback state through the normal manual model-change path. + +### Why + +- Operators needed a direct way to undo an unwanted fallback without changing global model defaults or reconstructing the previous thinking level. + +### Why an extension could not handle it + +- The original model/thinking snapshot is private `AgentSession` and retry-controller state. The builtin command consumes the narrow session action rather than reaching into core. + +### Expected merge conflict zones + +- MEDIUM: the `sessionSettings` object in `_bindExtensionCore`. + ## 2026-08-31 - Session activity contract for host occupancy decisions ### What changed 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 751b812d4f..307b6dd58a 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 @@ -4,16 +4,16 @@ ### What changed -- Derive persisted sent-stream hashes from the same compaction-aware active context projection used by the session runtime instead of refusing every branch that contains a compaction entry. +- Derive persisted sent-stream hashes directly from `SessionManager.buildSessionContext()`, the same compaction-aware active context projection used by the runtime. Do not reconstruct it from `getBranch()`: a long-running resident mirror may have externalized message bodies that the manager can restore but a copied branch cannot. - Recreate the fixed-size restart sidecar after the first successful post-compaction assistant turn. ### Why -- Accepted compaction deleted the previous sidecar, and the old branch walker returned no hashes forever after. Restarting a long compacted session therefore cold-seeded hundreds of messages; an aborted seed left a zero-count lineage that replayed the entire context again and failed with `invalid_request`. +- Accepted compaction deleted the previous sidecar, and the old branch walker returned no hashes forever after. A first attempted fix rebuilt context from `getBranch()`, but live instrumentation on the affected 527-message session measured `hashes: 0` there while reopening the same file through `buildSessionContext()` produced 386 transmitted hashes. Restarting without a sidecar cold-seeded hundreds of messages; an aborted seed left a zero-count lineage that replayed the entire context again and failed with `invalid_request`. ### Expected merge conflict zones -- LOW: `session-binding.ts` at `sentHashesFromBranch`, plus the restart and binding-anchor regressions. +- LOW: `session-registry-wiring.ts` at the `message_end` persistence seam, plus the restart regression. ## 2026-09-02 - Preserve selected OAuth slot during resume 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 ff003ca091..31c3049b2b 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,9 +1,8 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { buildSessionContext, type SessionEntry } from "../../../session-manager.ts"; import type { StoredBinding } from "./session-binding-store.ts"; import { assistantContentHash } from "./session-commit-boundary.ts"; import type { ContinuityBinding } from "./session-reattach.ts"; -import { isTransmittedMessage, 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; @@ -67,12 +66,6 @@ export function storedBindingFromEntry( }; } -/** Hashes for the user/toolResult messages the persisted branch already carries. */ -export function sentHashesFromBranch(branch: readonly SessionEntry[]): string[] { - const context = buildSessionContext([...branch], branch[branch.length - 1]?.id); - return sentMessageHashes(context.messages.filter(isTransmittedMessage)); -} - export function bindingFromStoredBranch( branch: readonly BranchEntry[], stored: StoredBinding, 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..83026dc945 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,6 @@ import { BINDING_MARKER, type BindingInvalidation, bindingFromStoredBranch, - sentHashesFromBranch, storedBindingFromEntry, } from "./session-binding.ts"; import { deleteStoredBinding, readStoredBinding, writeStoredBinding } from "./session-binding-store.ts"; @@ -24,7 +23,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(); @@ -137,7 +136,7 @@ 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(); diff --git a/packages/coding-agent/src/core/extensions/changes.md b/packages/coding-agent/src/core/extensions/changes.md index a7c90e01f7..5d21cb787a 100644 --- a/packages/coding-agent/src/core/extensions/changes.md +++ b/packages/coding-agent/src/core/extensions/changes.md @@ -1,5 +1,23 @@ # Core Extensions Changes +## Restore pre-fallback session model through extension settings (2026-09-02) + +### What changed + +- `packages/coding-agent/src/core/extensions/types.ts`: `ExtensionSessionSettings` adds the narrow async `restoreFallbackPrimary()` action. +- `packages/coding-agent/src/core/extensions/runner.ts`: the no-op extension runtime returns `false` for that action when no live session owns fallback state. + +### Why + +- The builtin `/fallback restore` command needs a typed host capability that restores the retry controller's recorded original model and thinking level without exposing controller internals or changing global defaults. + +### Why an extension could not handle it + +- The extension can request restoration but cannot read or mutate `AgentSession`'s private fallback state directly. + +### Expected merge conflict zones + +- LOW: `ExtensionSessionSettings` and the no-op session-settings fixture in `runner.ts`. ## Expose the extension event bus for session activity signals (2026-08-31) 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 b0088e5c79..51b8aa5f10 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); @@ -137,59 +135,6 @@ describe("claude-sdk-oauth stored binding anchor", () => { expect(bindingFromStoredBranch(branch, stored())).toBeUndefined(); }); - it("derives hashes from the active context after compaction", () => { - const before = { role: "user" as const, content: [{ type: "text" as const, text: "before" }], timestamp: 1 }; - const after = { role: "user" as const, content: [{ type: "text" as const, text: "after" }], timestamp: 3 }; - const fromBranch = sentHashesFromBranch([ - { type: "message", id: "u1", parentId: null, timestamp: "2026-09-02T00:00:01.000Z", message: before }, - { - type: "compaction", - id: "c1", - parentId: "u1", - timestamp: "2026-09-02T00:00:02.000Z", - summary: "Earlier work summarized.", - firstKeptEntryId: "u1", - tokensBefore: 100, - }, - { type: "message", id: "u2", parentId: "c1", timestamp: "2026-09-02T00:00:03.000Z", message: after }, - ]); - - expect(fromBranch).toEqual(sentMessageHashes(sentMessages({ messages: [before, after] } as never))); - }); - - 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", - parentId: null, - timestamp: "2026-09-02T00:00:01.000Z", - message: transmitted, - }, - { - type: "message", - id: "u2", - parentId: "u1", - timestamp: "2026-09-02T00:00:02.000Z", - message: contentless, - }, - ]); - 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 3972b748a2..3a6e9ec060 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 @@ -214,6 +214,15 @@ describe("issue #6981 headless restart continuity", () => { 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" }], @@ -229,7 +238,12 @@ describe("issue #6981 headless restart continuity", () => { tokensBefore: 200_000, timestamp: 2, }, - { type: "message", id: "current-user", parentId: "compaction-entry", message: currentUser }, + { + type: "message", + id: "current-user", + parentId: "compaction-entry", + message: { role: "user" as const, content: [], timestamp: 3 }, + }, ); const extension = fakeExtension(branch); registerSessionRegistry(extension.api); @@ -241,7 +255,7 @@ describe("issue #6981 headless restart continuity", () => { expect(await readStoredBinding(sessionFile)).toMatchObject({ sessionId: SESSION_ID, sdkSessionId: entry.sdkSessionId, - sentCount: 2, + sentCount: 1, }); }); }); 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 55313b2d26..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 @@ -224,31 +224,4 @@ describe("retry fallback hard errors", () => { expect(harness.eventsOfType("auto_retry_start")).toEqual([]); expect(harness.eventsOfType("retry_fallback_applied")).toEqual([]); }); - - it("retries a provider operation abort three times before fallback", async () => { - const harness = await createHarness({ - models: [{ id: "faux-1" }, { id: "faux-2" }], - settings: { - retry: { enabled: true, maxRetries: 3, baseDelayMs: 1, fallbackChains: { [primary]: [fallback] } }, - }, - }); - harnesses.push(harness); - harness.setResponses([ - ...Array.from({ length: 4 }, () => - fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "This operation was aborted" }), - ), - fauxAssistantMessage("fallback answer"), - ]); - - await harness.session.prompt("hello"); - - expect(harness.faux.getCallLog().map((call) => call.modelId)).toEqual([ - "faux-1", - "faux-1", - "faux-1", - "faux-1", - "faux-2", - ]); - expect(harness.eventsOfType("retry_fallback_applied")).toMatchObject([{ reason: "transient" }]); - }); }); From 31d3a7785c93a35a368865394a1f852b1522e441 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 10:47:08 +0900 Subject: [PATCH 08/10] docs(changes): cover fallback restore core seam (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/src/core/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 275cc442f6..f8079e4cfc 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -4,7 +4,7 @@ ### What changed -- The session settings binding exposes `restoreFallbackPrimary()`, which resolves the controller's recorded original model, switches only the current session, restores its prior thinking level, and clears live fallback state through the normal manual model-change path. +- `packages/coding-agent/src/core/agent-session.ts` exposes `restoreFallbackPrimary()` through the session settings binding. It resolves the controller's recorded original model, switches only the current session, restores its prior thinking level, and clears live fallback state through the normal manual model-change path. ### Why From 97b3a846e70fb3078211b589e735761e1ff1cba9 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 11:00:43 +0900 Subject: [PATCH 09/10] fix(auth): resume through goal continuation suffix (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../builtin/claude-sdk-oauth/changes.md | 2 ++ .../claude-sdk-oauth/session-binding.ts | 13 +++++++++- .../claude-sdk-oauth-binding-anchor.test.ts | 24 ++++++++++++++++--- ...-oauth-headless-restart-continuity.test.ts | 17 +++++++++++++ 4 files changed, 52 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 307b6dd58a..ffed0019d6 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 @@ -6,10 +6,12 @@ - Derive persisted sent-stream hashes directly from `SessionManager.buildSessionContext()`, the same compaction-aware active context projection used by the runtime. Do not reconstruct it from `getBranch()`: a long-running resident mirror may have externalized message bodies that the manager can restore but a copied branch cannot. - Recreate the fixed-size restart sidecar after the first successful post-compaction assistant turn. +- Admit post-anchor user/tool-result and automatic goal-continuation metadata as unsent restart delta. A later assistant or unknown state entry still rejects the stale anchor, and a newer binding invalidation remains authoritative. ### Why - Accepted compaction deleted the previous sidecar, and the old branch walker returned no hashes forever after. A first attempted fix rebuilt context from `getBranch()`, but live instrumentation on the affected 527-message session measured `hashes: 0` there while reopening the same file through `buildSessionContext()` produced 386 transmitted hashes. Restarting without a sidecar cold-seeded hundreds of messages; an aborted seed left a zero-count lineage that replayed the entire context again and failed with `invalid_request`. +- The corrected sidecar was then created on the real session, but restart admission deleted it because `goal-continuation`, memory state, and the next user input followed the anchored assistant. Those entries do not rewrite the trusted assistant; they are the delta the resumed query must consume. ### Expected merge conflict zones 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 31c3049b2b..4f7d0cd602 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 @@ -97,13 +97,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/test/claude-sdk-oauth-binding-anchor.test.ts b/packages/coding-agent/test/claude-sdk-oauth-binding-anchor.test.ts index 51b8aa5f10..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 @@ -89,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", () => { 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 3a6e9ec060..b0c6808a5a 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 @@ -33,6 +33,8 @@ type BranchEntry = { type: string; parentId?: string; customType?: string; + content?: unknown; + display?: boolean; data?: unknown; message?: unknown; summary?: string; @@ -178,6 +180,21 @@ describe("issue #6981 headless restart continuity", () => { 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({ From 41aea64624ae09f52e5fef457cc83db82f34a593 Mon Sep 17 00:00:00 2001 From: JGP Date: Wed, 2 Sep 2026 11:17:46 +0900 Subject: [PATCH 10/10] fix(auth): persist closed resident bindings (LAB-100) Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../builtin/claude-sdk-oauth/changes.md | 2 ++ .../claude-sdk-oauth/session-binding.ts | 24 ++++++++++++++ .../session-registry-wiring.ts | 31 ++++++++++++------- ...-oauth-headless-restart-continuity.test.ts | 7 ++++- 4 files changed, 51 insertions(+), 13 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 ffed0019d6..44daa5e583 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 @@ -7,11 +7,13 @@ - Derive persisted sent-stream hashes directly from `SessionManager.buildSessionContext()`, the same compaction-aware active context projection used by the runtime. Do not reconstruct it from `getBranch()`: a long-running resident mirror may have externalized message bodies that the manager can restore but a copied branch cannot. - Recreate the fixed-size restart sidecar after the first successful post-compaction assistant turn. - Admit post-anchor user/tool-result and automatic goal-continuation metadata as unsent restart delta. A later assistant or unknown state entry still rejects the stale anchor, and a newer binding invalidation remains authoritative. +- Persist from the current in-memory continuity binding when a successful turn closes its resident registry entry before the host emits `message_end`; require its `sentCount` to equal the active context hashes before trusting it. ### Why - Accepted compaction deleted the previous sidecar, and the old branch walker returned no hashes forever after. A first attempted fix rebuilt context from `getBranch()`, but live instrumentation on the affected 527-message session measured `hashes: 0` there while reopening the same file through `buildSessionContext()` produced 386 transmitted hashes. Restarting without a sidecar cold-seeded hundreds of messages; an aborted seed left a zero-count lineage that replayed the entire context again and failed with `invalid_request`. - The corrected sidecar was then created on the real session, but restart admission deleted it because `goal-continuation`, memory state, and the next user input followed the anchored assistant. Those entries do not rewrite the trusted assistant; they are the delta the resumed query must consume. +- Live restarts also showed successful fallback turns after `resume_initialization_aborted` with no new marker: the registry entry had closed, but `message_end` returned before consulting the binding that `session-turn-attempt` had already committed. The next restart therefore found a stale anchor followed by later assistants and correctly rejected it. ### Expected merge conflict zones 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 4f7d0cd602..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 @@ -66,6 +66,30 @@ export function storedBindingFromEntry( }; } +/** 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( branch: readonly BranchEntry[], stored: StoredBinding, 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 83026dc945..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,6 +6,7 @@ import { BINDING_MARKER, type BindingInvalidation, bindingFromStoredBranch, + storedBindingFromBinding, storedBindingFromEntry, } from "./session-binding.ts"; import { deleteStoredBinding, readStoredBinding, writeStoredBinding } from "./session-binding-store.ts"; @@ -15,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, @@ -121,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"); @@ -141,15 +144,19 @@ export function registerSessionRegistry( 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/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 b0c6808a5a..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 @@ -24,7 +24,10 @@ 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; @@ -177,6 +180,8 @@ describe("issue #6981 headless restart continuity", () => { const entry = residentEntry(); rememberBinding(bindingFromEntry(entry, turnHashes)); 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() });