Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type ContinuityReason =
| "resume_mode_off"
| "query_failed"
| "turn_attribution_failed"
| "session_unconfirmed"
| "abort_timeout"
| "extensions_removed"
| "session_shutdown"
Expand Down Expand Up @@ -94,6 +95,7 @@ const SANITIZED_REASONS = new Set<string>([
"resume_mode_off",
"query_failed",
"turn_attribution_failed",
"session_unconfirmed",
"abort_timeout",
"extensions_removed",
"session_shutdown",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -211,6 +212,7 @@ export class ClaudeSdkOauthSessionRegistry {
const target: ClaudeSdkOauthSessionEntry = {
...entryInput,
sdkSessionId,
sdkSessionIdConfirmed: input.resume !== undefined,
generation,
query,
inputController,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof rememberBinding>[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),
});
Expand All @@ -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<SDKMessage> {
const queue = new BoundedAsyncQueue<SDKMessage>(SESSION_STREAM_QUEUE_CAPACITY);
Expand All @@ -70,22 +79,28 @@ 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);
}
} catch (error) {
// 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");
}
Expand Down
Loading