From 76361c42b7d24150c0694cb4a5f03aa4dc5d3efb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 13:27:25 +0900 Subject: [PATCH 1/4] fix(claude-sdk-oauth): publish continuity bindings only after init confirms the session id Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/coding-agent/CHANGELOG.md | 1 + .../builtin/claude-sdk-oauth/changes.md | 22 +++ .../claude-sdk-oauth/session-registry-pump.ts | 1 + .../claude-sdk-oauth/session-registry.ts | 2 + .../claude-sdk-oauth/session-turn-attempt.ts | 14 +- ...aude-sdk-oauth-unconfirmed-binding.test.ts | 173 ++++++++++++++++++ 6 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4da6c6895f..36b9395f22 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed - RPC logins now answer providers' mid-flow prompts over the extension UI dialog channel (`extension_ui_request` `input` for pasted codes, text, and secrets; `select` for account choices) and release an unanswered dialog when the login settles, so Anthropic Claude Pro/Max and other prompt-driven OAuth flows complete through the browser callback instead of failing with `Interactive login input is not supported over RPC` and a dead callback port ([#1316](https://github.com/code-yeongyu/senpi/issues/1316)). +- Claude SDK OAuth no longer publishes a continuity binding for a locally minted SDK session id until `system/init` confirms it, preventing repeated resume failures after a cold-seed error ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). - Bundled Claude Code is now 2.1.259 via `@anthropic-ai/claude-agent-sdk` 0.3.259, so `claude-sdk-oauth` sessions on `claude-fable-5-1` no longer fail with the API 400 that required version 2.1.251 or newer ([#1298](https://github.com/code-yeongyu/senpi/issues/1298)). - Claude SDK OAuth maps malformed or raw-string content entries to text (or an omission placeholder) instead of image blocks with undefined `media_type`/`data`, which made Claude Code abort the next query ([oh-my-openagent#7660](https://github.com/code-yeongyu/oh-my-openagent/issues/7660)). - A second `claude-sdk-oauth` login now stores the newly issued OAuth tokens instead of a broken slot holding the managed placeholder, and no longer fails with `Provider is not configured: claude-sdk-oauth` when account rotation has selected a single account ([#1279](https://github.com/code-yeongyu/senpi/issues/1279)). 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 023cdf93e3..71ee2198d0 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,27 @@ # claude-sdk-oauth +## 2026-09-03 - Never resume an SDK session id that init never confirmed + +### What changed + +- `session-registry.ts`: resident entries now track whether the SDK confirmed their session id; resumed entries start confirmed while cold-seeded ids start provisional. +- `session-registry-pump.ts`: `system/init` confirms the entry's SDK session id, including when the SDK changes it. +- `session-turn-attempt.ts`: continuity bindings are published only after confirmation; failed provisional attempts forget any existing binding. +- `claude-sdk-oauth-unconfirmed-binding.test.ts`: covers provisional failure cleanup, confirmed failures and successes, and resumed entries. + +### Why + +- A failed cold-seed attempt could publish its locally minted SDK id before Claude Code confirmed it, poisoning subsequent admissions with a permanently unresumable binding ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). + +### Why an extension could not handle it + +- Session ids are minted, confirmed, and published inside the builtin resident SDK pump before any extension-facing stream event can correct the continuity binding. + +### Expected merge conflict zones + +- LOW in `session-registry.ts` around entry construction, `session-registry-pump.ts` around init handling, and `session-turn-attempt.ts` around binding publication. +- NEW test in `claude-sdk-oauth-unconfirmed-binding.test.ts`. + ## 2026-09-03 - Map malformed content entries to text instead of broken image blocks ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts index a4ca5e3d8c..c9409dd981 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts @@ -118,6 +118,7 @@ function handleMessage( // the fork's content is lost. if (message.type === "system" && message.subtype === "init" && typeof message.session_id === "string") { if (message.session_id !== entry.sdkSessionId) entry.sdkSessionId = message.session_id; + entry.sdkSessionIdConfirmed = true; } const turn = currentTurn(entry); if (!turn || !registry.isCurrentGeneration(entry.senpiSessionId, turn.generation)) return false; diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts index 1999ee8b7b..523b753246 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts @@ -82,6 +82,7 @@ export type SessionBranchInfo = { oldLeafId: string; newLeafId: string }; export interface ClaudeSdkOauthSessionEntry { senpiSessionId: string; sdkSessionId: string; + sdkSessionIdConfirmed: boolean; generation: number; accountName: string; modelId: string; @@ -211,6 +212,7 @@ export class ClaudeSdkOauthSessionRegistry { const target: ClaudeSdkOauthSessionEntry = { ...entryInput, sdkSessionId, + sdkSessionIdConfirmed: input.resume !== undefined, generation, query, inputController, diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts index fb292a76a5..18ef4460e0 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts @@ -1,7 +1,7 @@ import { BoundedAsyncQueue, SESSION_STREAM_QUEUE_CAPACITY } from "./bounded-queue.ts"; import { sdkResultFailure } from "./errors.ts"; import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts"; -import { bindingFromEntry, rememberBinding } from "./session-reattach.ts"; +import { bindingFromEntry, forgetBinding, rememberBinding } from "./session-reattach.ts"; import { type ClaudeSdkOauthSessionEntry, closeSession, @@ -34,9 +34,17 @@ function recordAssistantUuid(entry: ClaudeSdkOauthSessionEntry, sentCount: numbe * SAME turn's retry fork past the orphaned message instead of appending it * twice (issue #723 retry storm). In-memory only — nothing here is persisted. */ +function publishBinding(entry: ClaudeSdkOauthSessionEntry, binding: Parameters[0]): void { + if (!entry.sdkSessionIdConfirmed) { + forgetBinding(entry.senpiSessionId); + return; + } + rememberBinding(binding); +} + function rememberRetryCheckpoint(entry: ClaudeSdkOauthSessionEntry, hashes: readonly string[]): void { if (entry.sentCount < 0 || entry.sentCount > hashes.length) return; - rememberBinding({ + publishBinding(entry, { ...bindingFromEntry(entry, hashes.slice(0, entry.sentCount)), unansweredTurnDigest: sentHashPrefixDigest(hashes, hashes.length), }); @@ -70,7 +78,7 @@ export function createSessionTurnAttempt( const turn = await completion; if (!turn.aborted && successfulTurn(turn.messages)) { recordSyncedStream(entry, hashes); - rememberBinding(bindingFromEntry(entry, hashes)); + publishBinding(entry, bindingFromEntry(entry, hashes)); } else { rememberRetryCheckpoint(entry, hashes); } diff --git a/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts new file mode 100644 index 0000000000..839ee65cd8 --- /dev/null +++ b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts @@ -0,0 +1,173 @@ +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SdkQueryHandle } from "../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; +import { decideNativeContinuity } from "../src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts"; +import { forgetBinding, getBinding } from "../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; +import { + closeSession, + getOrCreateSession, + getSession, + overrideSessionRegistryBoundary, + resetSessionRegistryBoundary, +} from "../src/core/extensions/builtin/claude-sdk-oauth/session-registry.ts"; +import { createSessionTurnAttempt } from "../src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts"; + +class ScriptedQuery implements SdkQueryHandle, AsyncIterator { + private done = false; + private readonly queued: SDKMessage[] = []; + private readonly readers: Array<(value: IteratorResult) => void> = []; + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + next(): Promise> { + const value = this.queued.shift(); + if (value) return Promise.resolve({ value, done: false }); + if (this.done) return Promise.resolve({ value: undefined, done: true }); + return new Promise((resolve) => this.readers.push(resolve)); + } + + emit(message: SDKMessage): void { + const reader = this.readers.shift(); + if (reader) reader({ value: message, done: false }); + else this.queued.push(message); + } + + async interrupt(): Promise {} + + close(): void { + this.done = true; + for (const reader of this.readers.splice(0)) reader({ value: undefined, done: true }); + } +} + +const SESSION_ID = "unconfirmed-binding"; +const HASHES = ["turn-hash"]; +const userContent = { role: "user", content: "hello" } as const; + +function replay(uuid: string, sessionId: string): SDKMessage { + return { + type: "user", + message: userContent, + parent_tool_use_id: null, + uuid, + session_id: sessionId, + isReplay: true, + } as SDKMessage; +} + +function result(uuid: string, sessionId: string, isError: boolean): SDKMessage { + return { + type: "result", + subtype: "success", + user_message_uuid: uuid, + uuid: "result", + session_id: sessionId, + is_error: isError, + result: isError ? "No conversation found with session ID" : "done", + } as unknown as SDKMessage; +} + +function init(sessionId: string): SDKMessage { + return { type: "system", subtype: "init", session_id: sessionId } as unknown as SDKMessage; +} + +function fixture(resume?: string) { + const query = new ScriptedQuery(); + overrideSessionRegistryBoundary({ queryFactory: () => query }); + const entry = getOrCreateSession({ + senpiSessionId: SESSION_ID, + accountName: "default", + modelId: "claude-test", + toolsetHash: "tools-v1", + systemPromptHash: "prompt-v1", + options: {}, + ...(resume ? { resume: { sdkSessionId: resume } } : {}), + }); + const attempt = createSessionTurnAttempt(entry, userContent, HASHES, undefined, { emit() {} }); + return { query, entry, attempt }; +} + +async function submittedMessage(entry: { inputController: AsyncIterable }): Promise { + const item = await entry.inputController[Symbol.asyncIterator]().next(); + if (item.done) throw new Error("Expected a submitted user message"); + return item.value; +} + +async function consume(messages: AsyncIterable): Promise { + for await (const _message of messages) { + // Exhaust the attempt so it publishes or forgets its retry checkpoint. + } +} + +afterEach(() => { + closeSession(SESSION_ID, "test_cleanup"); + forgetBinding(SESSION_ID); + resetSessionRegistryBoundary(); +}); + +describe("claude-sdk-oauth unconfirmed continuity bindings", () => { + it("forgets a cold-seed id when failure arrives before init confirmation", async () => { + const { query, entry, attempt } = fixture(); + const consuming = consume(attempt.messages); + const submitted = await submittedMessage(entry); + query.emit(result(submitted.uuid!, entry.sdkSessionId, true)); + + await expect(consuming).rejects.toThrow("No conversation found"); + expect(getSession(SESSION_ID)).toBeUndefined(); + expect(getBinding(SESSION_ID)).toBeUndefined(); + expect( + decideNativeContinuity({ + entry: undefined, + binding: undefined, + currentHashes: HASHES, + accountName: "default", + modelId: "claude-test", + fingerprint: { toolsetHash: "tools-v1", systemPromptHash: "prompt-v1" }, + transcriptAvailable: false, + }), + ).toEqual({ kind: "bootstrap" }); + }); + + it("retains a retry checkpoint after init confirms the SDK session id", async () => { + const { query, entry, attempt } = fixture(); + const consuming = consume(attempt.messages); + const submitted = await submittedMessage(entry); + const confirmedId = "sdk-confirmed"; + query.emit(init(confirmedId)); + query.emit(replay(submitted.uuid!, confirmedId)); + query.emit(result(submitted.uuid!, confirmedId, true)); + + await expect(consuming).rejects.toThrow("No conversation found"); + expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: confirmedId, sentCount: 0 }); + }); + + it("records a successful confirmed turn exactly as before", async () => { + const { query, entry, attempt } = fixture(); + const consuming = consume(attempt.messages); + const submitted = await submittedMessage(entry); + query.emit(init(entry.sdkSessionId)); + query.emit(replay(submitted.uuid!, entry.sdkSessionId)); + query.emit(result(submitted.uuid!, entry.sdkSessionId, false)); + + await consuming; + expect(getBinding(SESSION_ID)).toMatchObject({ + sdkSessionId: entry.sdkSessionId, + sentCount: 1, + sentHashes: HASHES, + }); + }); + + it("treats a resume-created entry as already confirmed", async () => { + const resumedId = "sdk-resumed"; + const { query, entry, attempt } = fixture(resumedId); + const consuming = consume(attempt.messages); + const submitted = await submittedMessage(entry); + query.emit(replay(submitted.uuid!, resumedId)); + query.emit(result(submitted.uuid!, resumedId, true)); + + await expect(consuming).rejects.toThrow("No conversation found"); + expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: resumedId, sentCount: 0 }); + }); +}); From 52269b3e6df52c6397807523f33554694a138e60 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 13:36:28 +0900 Subject: [PATCH 2/4] fix(claude-sdk-oauth): gate resume on an SDK-acknowledged session id and drop ids Claude Code reports missing The first cut forgot every binding an unconfirmed entry published, which also erased the same-turn retry checkpoints #723 relies on. Carry the confirmation on the binding instead: init or the replay echo confirms the id, an unconfirmed binding may still drive a byte-identical same-turn re-seed but never a resume or fork (new continuity reason session_unconfirmed), and a failure that names 'No conversation found with session ID' forgets the binding outright. --- packages/coding-agent/CHANGELOG.md | 1 + .../builtin/claude-sdk-oauth/AGENTS.md | 1 + .../builtin/claude-sdk-oauth/changes.md | 20 ++--- .../claude-sdk-oauth/session-continuity.ts | 21 ++++- .../claude-sdk-oauth/session-observability.ts | 1 + .../claude-sdk-oauth/session-reattach.ts | 7 ++ .../claude-sdk-oauth/session-registry-pump.ts | 7 +- .../claude-sdk-oauth/session-turn-attempt.ts | 15 ++-- ...aude-sdk-oauth-unconfirmed-binding.test.ts | 90 ++++++++++++++----- 9 files changed, 120 insertions(+), 43 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 36b9395f22..b1b3d3be3a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - RPC logins now answer providers' mid-flow prompts over the extension UI dialog channel (`extension_ui_request` `input` for pasted codes, text, and secrets; `select` for account choices) and release an unanswered dialog when the login settles, so Anthropic Claude Pro/Max and other prompt-driven OAuth flows complete through the browser callback instead of failing with `Interactive login input is not supported over RPC` and a dead callback port ([#1316](https://github.com/code-yeongyu/senpi/issues/1316)). - Claude SDK OAuth no longer publishes a continuity binding for a locally minted SDK session id until `system/init` confirms it, preventing repeated resume failures after a cold-seed error ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). +- Claude SDK OAuth no longer loops on `No conversation found with session ID` after a failed cold seed: a session id is resumed only once Claude Code acknowledged it (init or replay echo), and an id Claude Code reports missing is dropped instead of retried ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). - Bundled Claude Code is now 2.1.259 via `@anthropic-ai/claude-agent-sdk` 0.3.259, so `claude-sdk-oauth` sessions on `claude-fable-5-1` no longer fail with the API 400 that required version 2.1.251 or newer ([#1298](https://github.com/code-yeongyu/senpi/issues/1298)). - Claude SDK OAuth maps malformed or raw-string content entries to text (or an omission placeholder) instead of image blocks with undefined `media_type`/`data`, which made Claude Code abort the next query ([oh-my-openagent#7660](https://github.com/code-yeongyu/oh-my-openagent/issues/7660)). - A second `claude-sdk-oauth` login now stores the newly issued OAuth tokens instead of a broken slot holding the managed placeholder, and no longer fails with `Provider is not configured: claude-sdk-oauth` when account rotation has selected a single account ([#1279](https://github.com/code-yeongyu/senpi/issues/1279)). diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/AGENTS.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/AGENTS.md index 3e4affb1e0..f700c63c33 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/AGENTS.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/AGENTS.md @@ -42,6 +42,7 @@ Generated: 2026-08-07 | Commit: `4f26b8282` - `full`/`override` prompt modes default `settingSources` to `[]` (no CLAUDE.md double-injection). The CLI still prepends its own agent preamble; `full` means senpi's prompt arrives intact, not alone. - Env precedence: env > project settings > global settings > default. All `SENPI_*` vars are stripped from the subprocess env on every lane. - Subscription-limit responses classify as account-failover conditions, not terminal errors. +- A continuity binding is resumable only after the SDK acknowledged its session id (`system/init` or the replay echo); unconfirmed ids cold-seed (`session_unconfirmed`) and an id Claude Code reports missing is forgotten, never retried. - Idle resident sessions retire after 30 minutes; at most 32 stay resident; in-flight sessions are never evicted. ## TESTS 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 71ee2198d0..1eb05543da 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,27 +1,27 @@ # claude-sdk-oauth -## 2026-09-03 - Never resume an SDK session id that init never confirmed +## 2026-09-03 - Never resume an SDK session id the SDK never acknowledged ### What changed -- `session-registry.ts`: resident entries now track whether the SDK confirmed their session id; resumed entries start confirmed while cold-seeded ids start provisional. -- `session-registry-pump.ts`: `system/init` confirms the entry's SDK session id, including when the SDK changes it. -- `session-turn-attempt.ts`: continuity bindings are published only after confirmation; failed provisional attempts forget any existing binding. -- `claude-sdk-oauth-unconfirmed-binding.test.ts`: covers provisional failure cleanup, confirmed failures and successes, and resumed entries. +- `session-registry.ts`: `ClaudeSdkOauthSessionEntry.sdkSessionIdConfirmed` records whether the SDK acknowledged the entry's session id; entries created from a resume start confirmed, entries whose id `getOrCreate` minted locally start unconfirmed. +- `session-registry-pump.ts`: a `system`/`init` message and the replay echo that claims the turn both mark the entry confirmed (either proves Claude Code runs under that id). +- `session-reattach.ts`: `ContinuityBinding.sdkSessionIdConfirmed` carries the flag; `bindingFromEntry` copies it. +- `session-turn-attempt.ts`: bindings are published with the flag instead of silently; an attempt whose failure says `No conversation found with session ID` forgets the binding outright, because Claude Code has declared the bound id dead. +- `session-continuity.ts`: `withoutUnconfirmedResume` turns a `reattach`/`fork` decision on an unconfirmed binding into `flatten` with the new reason `session_unconfirmed`; same-turn retry checkpoints (`timeout_retry`) are unaffected because they never resume the id. +- `session-observability.ts`: `ContinuityReason` gains `session_unconfirmed`. ### Why -- A failed cold-seed attempt could publish its locally minted SDK id before Claude Code confirmed it, poisoning subsequent admissions with a permanently unresumable binding ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). +- oh-my-openagent#7562: switching a long session to `claude-sdk-oauth` cold-seeds with a locally minted id; when that first attempt fails before Claude Code echoes anything (result before replay claim, API error), the retry checkpoint still carried the unconfirmed id, the next turn chose `reattach`, Claude Code answered `No conversation found with session ID`, and every later turn repeated the cycle with zero usage. Resuming is now gated on acknowledgement, and an id Claude Code reports missing is dropped instead of retried. ### Why an extension could not handle it -- Session ids are minted, confirmed, and published inside the builtin resident SDK pump before any extension-facing stream event can correct the continuity binding. +- Continuity decisions and binding publication are private to this builtin's resident-session lane; no extension hook sees the `init`/replay frames or the binding map. ### Expected merge conflict zones -- LOW in `session-registry.ts` around entry construction, `session-registry-pump.ts` around init handling, and `session-turn-attempt.ts` around binding publication. -- NEW test in `claude-sdk-oauth-unconfirmed-binding.test.ts`. - +- LOW: `session-continuity.ts` `decideNativeContinuity` entry branch, `session-turn-attempt.ts` publish/catch paths, `session-registry-pump.ts` init/claim handling, the `ContinuityReason` union. ## 2026-09-03 - Map malformed content entries to text instead of broken image blocks ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts index 40b214b041..fb5190f82e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts @@ -27,6 +27,8 @@ export type ContinuityBindingSnapshot = { toolsetHash: string; /** Sent-stream digest of a turn that was pushed but never answered (retry checkpoint). */ unansweredTurnDigest?: string; + /** False until the SDK acknowledged the id; a resume/fork of an unconfirmed id is never attempted. */ + sdkSessionIdConfirmed?: boolean; }; export type ContinuityDecisionInput = { @@ -132,6 +134,23 @@ function retryCheckpointDecision( }; } +/** + * A binding whose SDK id was minted locally and never acknowledged (no init, no + * replay echo before the attempt failed) must not be resumed: Claude Code + * answers "No conversation found with session ID" and every retry would mint + * another dead id (oh-my-openagent#7562). Cold-seed instead. + */ +function withoutUnconfirmedResume( + decision: ContinuityDecision, + binding: ContinuityBindingSnapshot, +): ContinuityDecision { + if (binding.sdkSessionIdConfirmed !== false) return decision; + if (decision.kind === "reattach" || decision.kind === "fork") { + return { kind: "flatten", reason: "session_unconfirmed" }; + } + return decision; +} + function decideFromBinding(input: ContinuityDecisionInput, binding: ContinuityBindingSnapshot): ContinuityDecision { if (!input.transcriptAvailable) return { kind: "flatten", reason: "transcript_missing" }; const drift = identityDrift(input, binding); @@ -213,7 +232,7 @@ export function decideNativeContinuity(input: ContinuityDecisionInput): Continui const { entry, binding } = input; if (!entry) { if (!binding) return { kind: "bootstrap" }; - return decideFromBinding(input, binding); + return withoutUnconfirmedResume(decideFromBinding(input, binding), binding); } const divergence = entry.pendingForkReason ?? entry.taintedReason; diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts index 7f1f1b7188..cd3faa00fe 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts @@ -44,6 +44,7 @@ export type ContinuityReason = | "resume_mode_off" | "query_failed" | "turn_attribution_failed" + | "session_unconfirmed" | "abort_timeout" | "extensions_removed" | "session_shutdown" diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts index e9e40fdb32..19372f53d9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts @@ -10,6 +10,11 @@ export type ContinuityBinding = { sentCount: number; sentHashes: readonly string[]; sentPrefixHash?: string; + /** + * False while the SDK has not yet acknowledged this session id (no init and + * no replay echo): continuity must never resume or fork such an id. + */ + sdkSessionIdConfirmed?: boolean; lastAssistantUuid: string | null; accountName: string; modelId: string; @@ -76,12 +81,14 @@ export function bindingFromEntry( | "systemPromptHash" | "toolsetHash" | "assistantUuidByIndex" + | "sdkSessionIdConfirmed" >, sentHashes: readonly string[], ): ContinuityBinding { return { senpiSessionId: entry.senpiSessionId, sdkSessionId: entry.sdkSessionId, + sdkSessionIdConfirmed: entry.sdkSessionIdConfirmed, sentCount: entry.sentCount, sentHashes: [...sentHashes], lastAssistantUuid: entry.assistantUuidByIndex.get(entry.sentCount) ?? null, diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts index c9409dd981..ff69e0bd6a 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-pump.ts @@ -123,8 +123,11 @@ function handleMessage( const turn = currentTurn(entry); if (!turn || !registry.isCurrentGeneration(entry.senpiSessionId, turn.generation)) return false; if (!turn.claimed) { - if (isReplayFor(message, turn.uuid)) claimTurn(entry, turn); - else if (message.type === "stream_event") bufferBeforeReplay(registry, entry, turn, message); + if (isReplayFor(message, turn.uuid)) { + // The SDK echoing our user message proves it runs under this session id. + entry.sdkSessionIdConfirmed = true; + claimTurn(entry, turn); + } else if (message.type === "stream_event") bufferBeforeReplay(registry, entry, turn, message); else if (message.type === "result") { // A result that fails before the SDK ever echoed our user message (a // 400 version floor, a session limit) must surface as that failure so diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts index 18ef4460e0..2cb7fce5e5 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts @@ -34,12 +34,11 @@ function recordAssistantUuid(entry: ClaudeSdkOauthSessionEntry, sentCount: numbe * SAME turn's retry fork past the orphaned message instead of appending it * twice (issue #723 retry storm). In-memory only — nothing here is persisted. */ +/** Claude Code answered "No conversation found with session ID": the bound id is dead, never resume it again. */ +const RESUME_TARGET_MISSING = /no conversation found with session id/i; + function publishBinding(entry: ClaudeSdkOauthSessionEntry, binding: Parameters[0]): void { - if (!entry.sdkSessionIdConfirmed) { - forgetBinding(entry.senpiSessionId); - return; - } - rememberBinding(binding); + rememberBinding({ ...binding, sdkSessionIdConfirmed: entry.sdkSessionIdConfirmed }); } function rememberRetryCheckpoint(entry: ClaudeSdkOauthSessionEntry, hashes: readonly string[]): void { @@ -86,7 +85,11 @@ export function createSessionTurnAttempt( // The queue failed (completion rejected: pump failure, query end, // attribution error). The payload was still pushed, so the retry needs // the same checkpoint the aborted path records. - rememberRetryCheckpoint(entry, hashes); + if (error instanceof Error && RESUME_TARGET_MISSING.test(error.message)) { + forgetBinding(entry.senpiSessionId); + } else { + rememberRetryCheckpoint(entry, hashes); + } throw error; } finally { staged.emit(); diff --git a/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts index 839ee65cd8..a85b7f1f3f 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts @@ -57,18 +57,35 @@ function replay(uuid: string, sessionId: string): SDKMessage { } as SDKMessage; } -function result(uuid: string, sessionId: string, isError: boolean): SDKMessage { +const API_FAILURE = + "API Error: 400 Claude Code 2.1.241 does not support this model; version 2.1.251 or newer is required."; +const RESUME_MISSING = "Claude Code returned an error result: No conversation found with session ID: dead-id"; + +function result(uuid: string, sessionId: string, failure: string | false): SDKMessage { return { type: "result", subtype: "success", user_message_uuid: uuid, uuid: "result", session_id: sessionId, - is_error: isError, - result: isError ? "No conversation found with session ID" : "done", + is_error: failure !== false, + result: failure === false ? "done" : failure, } as unknown as SDKMessage; } +/** The NEXT user turn's admission (a new message, so the same-turn retry checkpoint does not apply). */ +function decide(binding: ReturnType) { + return decideNativeContinuity({ + entry: undefined, + binding: binding ? { ...binding, sentPrefixHash: undefined } : undefined, + currentHashes: [...HASHES, "next-turn-hash"], + accountName: "default", + modelId: "claude-test", + fingerprint: { toolsetHash: "tools-v1", systemPromptHash: "prompt-v1" }, + transcriptAvailable: true, + }); +} + function init(sessionId: string): SDKMessage { return { type: "system", subtype: "init", session_id: sessionId } as unknown as SDKMessage; } @@ -108,26 +125,43 @@ afterEach(() => { }); describe("claude-sdk-oauth unconfirmed continuity bindings", () => { - it("forgets a cold-seed id when failure arrives before init confirmation", async () => { + it("never resumes a cold-seed id that failed before the SDK acknowledged it (#7562)", async () => { const { query, entry, attempt } = fixture(); const consuming = consume(attempt.messages); const submitted = await submittedMessage(entry); - query.emit(result(submitted.uuid!, entry.sdkSessionId, true)); + // Neither init nor a replay echo arrived: the id was only ever minted locally. + query.emit(result(submitted.uuid!, entry.sdkSessionId, API_FAILURE)); - await expect(consuming).rejects.toThrow("No conversation found"); + await expect(consuming).rejects.toThrow("does not support this model"); expect(getSession(SESSION_ID)).toBeUndefined(); + const binding = getBinding(SESSION_ID); + expect(binding).toMatchObject({ sdkSessionId: entry.sdkSessionId, sdkSessionIdConfirmed: false }); + // A cold seed, not a resume of the dead id. + expect(decide(binding)).toEqual({ kind: "flatten", reason: "session_unconfirmed" }); + }); + + it("confirms the id from the SDK's replay echo even without an init message", async () => { + const { query, entry, attempt } = fixture(); + const consuming = consume(attempt.messages); + const submitted = await submittedMessage(entry); + query.emit(replay(submitted.uuid!, entry.sdkSessionId)); + query.emit(result(submitted.uuid!, entry.sdkSessionId, API_FAILURE)); + + await expect(consuming).rejects.toThrow("does not support this model"); + const binding = getBinding(SESSION_ID); + expect(binding).toMatchObject({ sdkSessionId: entry.sdkSessionId, sdkSessionIdConfirmed: true }); + expect(decide(binding).kind).toBe("reattach"); + }); + + it("forgets a resumed id that Claude Code reports as missing", async () => { + const { query, entry, attempt } = fixture("sdk-dead"); + const consuming = consume(attempt.messages); + const submitted = await submittedMessage(entry); + query.emit(result(submitted.uuid!, "sdk-dead", RESUME_MISSING)); + + await expect(consuming).rejects.toThrow("No conversation found"); expect(getBinding(SESSION_ID)).toBeUndefined(); - expect( - decideNativeContinuity({ - entry: undefined, - binding: undefined, - currentHashes: HASHES, - accountName: "default", - modelId: "claude-test", - fingerprint: { toolsetHash: "tools-v1", systemPromptHash: "prompt-v1" }, - transcriptAvailable: false, - }), - ).toEqual({ kind: "bootstrap" }); + expect(decide(getBinding(SESSION_ID))).toEqual({ kind: "bootstrap" }); }); it("retains a retry checkpoint after init confirms the SDK session id", async () => { @@ -137,10 +171,14 @@ describe("claude-sdk-oauth unconfirmed continuity bindings", () => { const confirmedId = "sdk-confirmed"; query.emit(init(confirmedId)); query.emit(replay(submitted.uuid!, confirmedId)); - query.emit(result(submitted.uuid!, confirmedId, true)); + query.emit(result(submitted.uuid!, confirmedId, API_FAILURE)); - await expect(consuming).rejects.toThrow("No conversation found"); - expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: confirmedId, sentCount: 0 }); + await expect(consuming).rejects.toThrow("does not support this model"); + expect(getBinding(SESSION_ID)).toMatchObject({ + sdkSessionId: confirmedId, + sentCount: 0, + sdkSessionIdConfirmed: true, + }); }); it("records a successful confirmed turn exactly as before", async () => { @@ -154,6 +192,7 @@ describe("claude-sdk-oauth unconfirmed continuity bindings", () => { await consuming; expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: entry.sdkSessionId, + sdkSessionIdConfirmed: true, sentCount: 1, sentHashes: HASHES, }); @@ -164,10 +203,13 @@ describe("claude-sdk-oauth unconfirmed continuity bindings", () => { const { query, entry, attempt } = fixture(resumedId); const consuming = consume(attempt.messages); const submitted = await submittedMessage(entry); - query.emit(replay(submitted.uuid!, resumedId)); - query.emit(result(submitted.uuid!, resumedId, true)); + query.emit(result(submitted.uuid!, resumedId, API_FAILURE)); - await expect(consuming).rejects.toThrow("No conversation found"); - expect(getBinding(SESSION_ID)).toMatchObject({ sdkSessionId: resumedId, sentCount: 0 }); + await expect(consuming).rejects.toThrow("does not support this model"); + expect(getBinding(SESSION_ID)).toMatchObject({ + sdkSessionId: resumedId, + sentCount: 0, + sdkSessionIdConfirmed: true, + }); }); }); From b0adf44d754195a692271f638b8e9bdbc5b52a89 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 14:00:49 +0900 Subject: [PATCH 3/4] fix(claude-sdk-oauth): keep a dead resumed id forgotten through discard and report session_unconfirmed The retained-attempt wrapper calls discard() after a failed attempt, which re-published the checkpoint of an id Claude Code had just declared missing; discard now forgets it instead. session_unconfirmed joins the sanitized continuity reasons so observations and telemetry keep the attribution instead of downgrading it to other. --- .../claude-sdk-oauth/session-observability.ts | 1 + .../claude-sdk-oauth/session-turn-attempt.ts | 6 +++- ...aude-sdk-oauth-unconfirmed-binding.test.ts | 31 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts index cd3faa00fe..02ac85cf56 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts @@ -95,6 +95,7 @@ const SANITIZED_REASONS = new Set([ "resume_mode_off", "query_failed", "turn_attribution_failed", + "session_unconfirmed", "abort_timeout", "extensions_removed", "session_shutdown", diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts index 2cb7fce5e5..8e66f267bb 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-turn-attempt.ts @@ -57,6 +57,8 @@ export function createSessionTurnAttempt( staged: StagedContinuityDecision, ) { const generation = entry.generation; + // Claude Code declared the bound id dead: no later cleanup may re-publish it. + let resumeTargetMissing = false; return { messages: (async function* (): AsyncGenerator { const queue = new BoundedAsyncQueue(SESSION_STREAM_QUEUE_CAPACITY); @@ -86,6 +88,7 @@ export function createSessionTurnAttempt( // attribution error). The payload was still pushed, so the retry needs // the same checkpoint the aborted path records. if (error instanceof Error && RESUME_TARGET_MISSING.test(error.message)) { + resumeTargetMissing = true; forgetBinding(entry.senpiSessionId); } else { rememberRetryCheckpoint(entry, hashes); @@ -96,7 +99,8 @@ export function createSessionTurnAttempt( } })(), discard: (): void => { - rememberRetryCheckpoint(entry, hashes); + if (resumeTargetMissing) forgetBinding(entry.senpiSessionId); + else rememberRetryCheckpoint(entry, hashes); if (isCurrentGeneration(entry.senpiSessionId, generation)) { closeSession(entry.senpiSessionId, "attempt_discarded"); } diff --git a/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts index a85b7f1f3f..4821bce6c0 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts @@ -1,7 +1,9 @@ import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { afterEach, describe, expect, it } from "vitest"; +import { createAttemptMessages } from "../src/core/extensions/builtin/claude-sdk-oauth/auth-attempt.ts"; import type { SdkQueryHandle } from "../src/core/extensions/builtin/claude-sdk-oauth/sdk-boundary.ts"; import { decideNativeContinuity } from "../src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts"; +import { observeSessionSyncDecision } from "../src/core/extensions/builtin/claude-sdk-oauth/session-observability.ts"; import { forgetBinding, getBinding } from "../src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts"; import { closeSession, @@ -164,6 +166,35 @@ describe("claude-sdk-oauth unconfirmed continuity bindings", () => { expect(decide(getBinding(SESSION_ID))).toEqual({ kind: "bootstrap" }); }); + it("keeps a dead resumed id forgotten through the retained-attempt discard path", async () => { + // The production lane consumes the attempt through createAttemptMessages, whose + // retainedAttemptMessages() wrapper calls discard() on failure; discard must not + // re-publish the checkpoint of an id Claude Code already declared missing. + const { query, entry, attempt } = fixture("sdk-dead-wrapped"); + const messages = await createAttemptMessages( + { prompt: "", query: () => query, createAttempt: () => attempt }, + { accountName: "default", accounts: [], authLane: "oauth-slots", options: {} }, + ); + const consuming = consume(messages); + const submitted = await submittedMessage(entry); + query.emit(result(submitted.uuid!, "sdk-dead-wrapped", RESUME_MISSING)); + + await expect(consuming).rejects.toThrow("No conversation found"); + expect(getBinding(SESSION_ID)).toBeUndefined(); + expect(decide(getBinding(SESSION_ID))).toEqual({ kind: "bootstrap" }); + }); + + it("reports session_unconfirmed through the continuity observation instead of other", () => { + const observation = observeSessionSyncDecision({ + kind: "cold-seed", + reason: "session_unconfirmed", + deltaMessages: 1, + firstTurn: false, + senpiSessionId: SESSION_ID, + }); + expect(observation).toMatchObject({ kind: "flatten", reason: "session_unconfirmed" }); + }); + it("retains a retry checkpoint after init confirms the SDK session id", async () => { const { query, entry, attempt } = fixture(); const consuming = consume(attempt.messages); From f4cc6e592e3d93cce9bf31fb54f43a3be633b671 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 3 Sep 2026 14:03:18 +0900 Subject: [PATCH 4/4] docs(coding-agent): drop the superseded first-cut changelog bullet for the unconfirmed-binding fix --- packages/coding-agent/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b1b3d3be3a..ef7a8b691f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,7 +12,6 @@ ### Fixed - RPC logins now answer providers' mid-flow prompts over the extension UI dialog channel (`extension_ui_request` `input` for pasted codes, text, and secrets; `select` for account choices) and release an unanswered dialog when the login settles, so Anthropic Claude Pro/Max and other prompt-driven OAuth flows complete through the browser callback instead of failing with `Interactive login input is not supported over RPC` and a dead callback port ([#1316](https://github.com/code-yeongyu/senpi/issues/1316)). -- Claude SDK OAuth no longer publishes a continuity binding for a locally minted SDK session id until `system/init` confirms it, preventing repeated resume failures after a cold-seed error ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). - Claude SDK OAuth no longer loops on `No conversation found with session ID` after a failed cold seed: a session id is resumed only once Claude Code acknowledged it (init or replay echo), and an id Claude Code reports missing is dropped instead of retried ([oh-my-openagent#7562](https://github.com/code-yeongyu/oh-my-openagent/issues/7562)). - Bundled Claude Code is now 2.1.259 via `@anthropic-ai/claude-agent-sdk` 0.3.259, so `claude-sdk-oauth` sessions on `claude-fable-5-1` no longer fail with the API 400 that required version 2.1.251 or newer ([#1298](https://github.com/code-yeongyu/senpi/issues/1298)). - Claude SDK OAuth maps malformed or raw-string content entries to text (or an omission placeholder) instead of image blocks with undefined `media_type`/`data`, which made Claude Code abort the next query ([oh-my-openagent#7660](https://github.com/code-yeongyu/oh-my-openagent/issues/7660)).