fix(claude-sdk-oauth): preserve restart continuity after compaction - #1267
Conversation
There was a problem hiding this comment.
3 issues found across 15 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/ai/src/auth/pool/slots.ts">
<violation number="1" location="packages/ai/src/auth/pool/slots.ts:159">
P1: When two provider-owned logins start with no stored accounts, both results use `default`, so this filter drops the first login's distinct account and the later `return flat` overwrites it. Reconcile same-name slots by material or assign a unique name after reading the serialized latest state.</violation>
<violation number="2" location="packages/ai/src/auth/pool/slots.ts:161">
P2: When an account is pinned or unpinned during the OAuth flow, this merge copies the login's stale `pinned` value over the latest stored value. Preserve current pool metadata while merging the returned accounts, and apply the same rule when no concurrent account needs appending.</violation>
</file>
<file name="packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts">
<violation number="1" location="packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts:118">
P1: When `credential` is a flat projected OAuth account, this makes `check()` report configured while the runtime still finds zero managed slots and uses ambient auth. The provider can therefore enter fallback but fail without the selected token; make runtime lane selection consume the same projected account or align availability with it.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| 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)); |
There was a problem hiding this comment.
P1: When two provider-owned logins start with no stored accounts, both results use default, so this filter drops the first login's distinct account and the later return flat overwrites it. Reconcile same-name slots by material or assign a unique name after reading the serialized latest state.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/ai/src/auth/pool/slots.ts, line 159:
<comment>When two provider-owned logins start with no stored accounts, both results use `default`, so this filter drops the first login's distinct account and the later `return flat` overwrites it. Reconcile same-name slots by material or assign a unique name after reading the serialized latest state.</comment>
<file context>
@@ -140,12 +140,27 @@ function nextLoginSlotName(credential: PooledCredential): string {
+ 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] };
</file context>
| 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; |
There was a problem hiding this comment.
P1: When credential is a flat projected OAuth account, this makes check() report configured while the runtime still finds zero managed slots and uses ambient auth. The provider can therefore enter fallback but fail without the selected token; make runtime lane selection consume the same projected account or align availability with it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts, line 118:
<comment>When `credential` is a flat projected OAuth account, this makes `check()` report configured while the runtime still finds zero managed slots and uses ambient auth. The provider can therefore enter fallback but fail without the selected token; make runtime lane selection consume the same projected account or align availability with it.</comment>
<file context>
@@ -109,9 +109,13 @@ export function createOAuthConfig(deps: {
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");
</file context>
| 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] }; |
There was a problem hiding this comment.
P2: When an account is pinned or unpinned during the OAuth flow, this merge copies the login's stale pinned value over the latest stored value. Preserve current pool metadata while merging the returned accounts, and apply the same rule when no concurrent account needs appending.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/ai/src/auth/pool/slots.ts, line 161:
<comment>When an account is pinned or unpinned during the OAuth flow, this merge copies the login's stale `pinned` value over the latest stored value. Preserve current pool metadata while merging the returned accounts, and apply the same rule when no concurrent account needs appending.</comment>
<file context>
@@ -140,12 +140,27 @@ function nextLoginSlotName(credential: PooledCredential): string {
+ 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;
+ }
</file context>
d8eb707 to
6d1df6d
Compare
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
…essage[] 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.
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
…ifecycle 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.
…ts 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.
6d1df6d to
6d77078
Compare
…he 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.
Summary
Fix Claude SDK OAuth restart continuity after compaction and preserve provider-owned OAuth account pools.
This replaces the persistence/root-cause portion of #1262 with a clean, reviewable history. User recovery commands are intentionally split into a separate PR.
Root cause
After compaction, the raw branch can carry empty materialized message bodies while
SessionManager.buildSessionContext()owns the real active context. Restart-sidecar persistence hashed the raw branch, so it did not refresh the SDK lineage. A later process flattened and re-sent the full conversation, which could exceed the SDK request limit; retry fallback then repeated the same oversized payload.Two adjacent continuity cases also invalidated a usable binding:
message_end, while the verified current binding still exists.The OAuth account pool had a separate persistence defect: provider-owned pool results were flattened into generated slots and overlapping login flows could lose provider-assigned accounts.
Changes
buildSessionContext();Failing-first proof
Mutation checks against the new tests:
Verification
packages/ai: 2 focused files, 7 tests passed;packages/coding-agent: 5 focused files, 40 tests passed;bun run check: passed;QA evidence is stored locally under
local-ignore/qa-evidence/20260902-claude-session-persistence/.Summary by cubic
Fixes Claude SDK OAuth restart continuity after compaction. It now persists the provider-visible, compaction-aware context instead of hashing the raw branch, so restarts reattach to the SDK session instead of flattening and resending an oversized transcript.
message_end, requiring matching message counts and prefix digests.Written for commit dba53d1. Summary will update on new commits.