diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4da6c6895f..ef7a8b691f 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 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 023cdf93e3..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,5 +1,27 @@ # claude-sdk-oauth +## 2026-09-03 - Never resume an SDK session id the SDK never acknowledged + +### What changed + +- `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 + +- 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 + +- 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: `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..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 @@ -44,6 +44,7 @@ export type ContinuityReason = | "resume_mode_off" | "query_failed" | "turn_attribution_failed" + | "session_unconfirmed" | "abort_timeout" | "extensions_removed" | "session_shutdown" @@ -94,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-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 a4ca5e3d8c..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 @@ -118,12 +118,16 @@ 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; 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-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..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 @@ -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,16 @@ 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 { + rememberBinding({ ...binding, sdkSessionIdConfirmed: entry.sdkSessionIdConfirmed }); +} + 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), }); @@ -50,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); @@ -70,7 +79,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); } @@ -78,14 +87,20 @@ 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)) { + resumeTargetMissing = true; + forgetBinding(entry.senpiSessionId); + } else { + rememberRetryCheckpoint(entry, hashes); + } throw error; } finally { staged.emit(); } })(), 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 new file mode 100644 index 0000000000..4821bce6c0 --- /dev/null +++ b/packages/coding-agent/test/claude-sdk-oauth-unconfirmed-binding.test.ts @@ -0,0 +1,246 @@ +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, + 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; +} + +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: 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; +} + +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("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); + // 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("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(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); + 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, API_FAILURE)); + + 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 () => { + 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, + sdkSessionIdConfirmed: true, + 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(result(submitted.uuid!, resumedId, API_FAILURE)); + + await expect(consuming).rejects.toThrow("does not support this model"); + expect(getBinding(SESSION_ID)).toMatchObject({ + sdkSessionId: resumedId, + sentCount: 0, + sdkSessionIdConfirmed: true, + }); + }); +});