Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

### Fixed

- Provider-owned OAuth account flows now persist their complete updated credential pool instead of having the generic login layer discard the real new account and append the pool's compatibility sentinel as a synthetic `login-N` slot ([#1262](https://github.com/code-yeongyu/senpi/pull/1262) by [@eddieparc](https://github.com/eddieparc)).

### Removed

## [2026.8.31] - 2026-08-31
Expand Down
15 changes: 15 additions & 0 deletions packages/ai/src/auth/pool/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,27 @@ function nextLoginSlotName(credential: PooledCredential): string {
throw new Error("Credential pool is full");
}

function hasProviderOwnedPool(credential: Credential): credential is PooledCredential & { accounts: CredentialSlot[] } {
return "accounts" in credential && Array.isArray(credential.accounts);
}

/**
* Appends an unnamed flat credential to a pool as a generated `login-N` slot. A
* flat or absent current entry keeps today's whole-write shape so no existing
* user's stored bytes change until a second credential actually exists.
*/
export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential {
// Provider-owned account flows already return the complete next pool. Treating
// that sentinel-backed result as one flat login discards its new real account
// and appends an unusable sentinel slot instead.
if (hasProviderOwnedPool(flat)) {
if (flat.accounts.length === 0 || !current || !Array.isArray(current.accounts)) return flat;
const returnedNames = new Set(flat.accounts.map((slot) => slot.name));
const concurrentAccounts = current.accounts.filter((slot) => !returnedNames.has(slot.name));
if (concurrentAccounts.length === 0) return flat;
const merged: PooledCredential = { ...flat, accounts: [...flat.accounts, ...concurrentAccounts] };
return merged;
}
if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) {
return flat;
}
Expand Down
28 changes: 28 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
## Provider-owned OAuth pools survive generic login persistence (2026-09-02)

### What changed

- `auth/pool/slots.ts`: `appendLoginSlot` now recognizes a provider-owned credential by its `accounts` array instead
of projecting sentinel top-level fields into a generated `login-N` slot. An explicitly empty provider pool remains
empty; a non-empty result merges accounts added by another login while both flows overlapped.
- `packages/ai/test/credential-pool-write-paths.test.ts`: covers a provider-owned OAuth login returning the current
account plus a newly named sibling, proving both accounts survive without a synthetic sentinel slot.
- `packages/ai/test/provider-owned-pool-login.test.ts`: deterministically overlaps two provider-owned login flows and
covers an explicitly empty returned pool.

### Why

- Claude SDK OAuth and Cursor CLI OAuth own account naming and return the full updated pool from their login flows.
The generic persistence layer treated that pooled result as a new flat credential, discarded the provider's real
new account, and appended the pool's compatibility sentinel as `login-2`. Credential rotation then selected the
unusable sentinel and request-time auth failed with `Provider is not configured`.

### Why an extension could not handle it

- The destructive second merge happens after the provider login returns, inside the shared credential-store mutation
in `Models.login`; provider extensions cannot alter that generic persistence boundary.

### Expected merge conflict zones

- LOW: provider-owned-pool detection and serialized account merge in `appendLoginSlot` in `auth/pool/slots.ts`.

## Cursor conversation cache eviction cannot break a live request (2026-08-31)

### What changed
Expand Down
59 changes: 59 additions & 0 deletions packages/ai/test/credential-pool-write-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,65 @@ describe("Models slot-preserving login/logout/refresh", () => {
expect(listSlots(stored).find((slot) => slot.name === "login-2")).toMatchObject({ key: "rotated-key" });
});

test("provider-owned pooled login result replaces the current pool without a sentinel slot", async () => {
const current = {
type: "oauth",
access: "managed",
refresh: "managed",
expires: 4_102_444_800_000,
accounts: [
{
name: "default",
access: "default-access",
refresh: "default-refresh",
expires: 4_102_444_800_000,
source: "login" as const,
},
],
} satisfies PooledCredential;
const providerOwnedResult = {
...current,
accounts: [
...current.accounts,
{
name: "second",
access: "second-access",
refresh: "second-refresh",
expires: 4_102_444_800_000,
source: "login" as const,
},
],
} satisfies PooledCredential;
const provider = createProvider({
id: "provider-owned-pool",
name: "Provider-owned Pool",
baseUrl: "https://provider-owned-pool.example",
auth: {
oauth: {
name: "Provider-owned Pool",
login: async () => providerOwnedResult,
refresh: async (credential: OAuthCredential) => credential,
toAuth: async (credential) => ({ apiKey: credential.access }),
},
},
models: [],
api: "openai-responses" as never,
});
const store = new InMemoryCredentialStore();
await store.modify(provider.id, async () => current);
const models = createModels({ credentials: store });
models.setProvider(provider);

await models.login(provider.id, "oauth", promptInteraction("unused"));

const stored = (await store.read(provider.id)) as PooledCredential;
expect(listSlots(stored).map((slot) => slot.name)).toEqual(["default", "second"]);
expect(listSlots(stored).find((slot) => slot.name === "second")).toMatchObject({
access: "second-access",
refresh: "second-refresh",
});
});

test("logout with a slotId removes only that slot", async () => {
const store = new InMemoryCredentialStore();
await store.modify("pooltest", async () => pooledApiKeyEntry());
Expand Down
106 changes: 106 additions & 0 deletions packages/ai/test/provider-owned-pool-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts";
import type { AuthInteraction, OAuthCredential } from "../src/auth/types.ts";
import { createModels, createProvider } from "../src/models.ts";

const PROVIDER_ID = "provider-owned-pool";
const EXPIRES = 4_102_444_800_000;
type ProviderOwnedOAuthCredential = OAuthCredential & PooledCredential;

function pool(...names: string[]): ProviderOwnedOAuthCredential {
return {
type: "oauth",
access: "managed",
refresh: "managed",
expires: EXPIRES,
accounts: names.map((name) => ({
name,
access: `${name}-access`,
refresh: `${name}-refresh`,
expires: EXPIRES,
source: "login",
})),
};
}

function interaction(): AuthInteraction {
return {
signal: AbortSignal.timeout(5_000),
prompt: async () => "unused",
notify: () => {},
};
}

function provider(login: () => Promise<ProviderOwnedOAuthCredential>) {
return createProvider({
id: PROVIDER_ID,
name: "Provider-owned Pool",
baseUrl: "https://provider-owned-pool.example",
auth: {
oauth: {
name: "Provider-owned Pool",
login,
refresh: async (credential: OAuthCredential) => credential,
toAuth: async (credential) => ({ apiKey: credential.access }),
},
},
models: [],
api: "openai-responses" as never,
});
}

function deferred<T>() {
let settle: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return {
promise,
resolve(value: T) {
if (!settle) throw new Error("deferred resolver was not initialized");
settle(value);
},
};
}

describe("provider-owned pool login persistence", () => {
test("merges accounts added by overlapping login flows", async () => {
const entered = deferred<void>();
const first = deferred<ProviderOwnedOAuthCredential>();
const second = deferred<ProviderOwnedOAuthCredential>();
let callCount = 0;
const store = new InMemoryCredentialStore();
const models = createModels({ credentials: store });
models.setProvider(
provider(async () => {
const call = ++callCount;
if (call === 2) entered.resolve();
return call === 1 ? first.promise : second.promise;
}),
);
await store.modify(PROVIDER_ID, async () => pool("default"));

const firstLogin = models.login(PROVIDER_ID, "oauth", interaction());
const secondLogin = models.login(PROVIDER_ID, "oauth", interaction());
await entered.promise;
first.resolve(pool("default", "first"));
second.resolve(pool("default", "second"));
await Promise.all([firstLogin, secondLogin]);

const stored = (await store.read(PROVIDER_ID)) as PooledCredential;
expect(new Set(listSlots(stored).map((slot) => slot.name))).toEqual(new Set(["default", "first", "second"]));
});

test("preserves an explicitly empty provider-owned pool", async () => {
const store = new InMemoryCredentialStore();
await store.modify(PROVIDER_ID, async () => pool("default"));
const models = createModels({ credentials: store });
models.setProvider(provider(async () => pool()));

await models.login(PROVIDER_ID, "oauth", interaction());

const stored = (await store.read(PROVIDER_ID)) as PooledCredential;
expect(stored.accounts).toEqual([]);
});
});
6 changes: 6 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@

### Added

- `/fallback restore` returns the current session to the model and thinking level active before fallback without changing global defaults.

### Fixed

- Claude SDK OAuth sessions recreate restart continuity after compaction instead of permanently losing their sidecar and replaying the full compacted context on the next process start.

- Claude SDK OAuth and Cursor CLI OAuth account additions now retain the provider-added account instead of creating an unusable sentinel `login-N` entry that could be selected on the next turn and fail with `Provider is not configured` ([#1262](https://github.com/code-yeongyu/senpi/pull/1262) by [@eddieparc](https://github.com/eddieparc)).

### New Features

### Breaking Changes
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# changes

## 2026-09-02 - Restore the pre-fallback model

### What changed

- `/fallback restore` switches only the current session back to the model and thinking level active before fallback, then clears the live fallback state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The changelog says /fallback restore "then clears the live fallback state", but restoreFallbackPrimary() restores the model and thinking level without clearing _retryFallback.activeState. After the command, getFallbackStatus() still reports the session active and the state survives until a later turn-boundary revert. Either clear the fallback controller state in the restore path or reword the entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/changes.md, line 8:

<comment>The changelog says `/fallback restore` "then clears the live fallback state", but restoreFallbackPrimary() restores the model and thinking level without clearing `_retryFallback.activeState`. After the command, getFallbackStatus() still reports the session active and the state survives until a later turn-boundary revert. Either clear the fallback controller state in the restore path or reword the entry.</comment>

<file context>
@@ -1,5 +1,27 @@
+### What changed
+
+- Provider-owned `This operation was aborted` results enter the ordinary turn retry budget, which defaults to three retries, before model fallback. Explicit user aborts remain terminal.
+- `/fallback restore` switches only the current session back to the model and thinking level active before fallback, then clears the live fallback state.
+- `ExtensionSessionSettings` exposes the narrow `restoreFallbackPrimary()` action used by the builtin command.
+
</file context>

- `ExtensionSessionSettings` exposes the narrow `restoreFallbackPrimary()` action used by the builtin command.

### Why

- Operators had no direct session command to undo an unwanted fallback without manually reconstructing the original model selector and thinking level.

### Why an extension could not handle it

- The pre-fallback model/thinking state is owned by `AgentSession` and its retry controller. The builtin command uses only the narrow session action exposed by core.

### Expected merge conflict zones

- MEDIUM: `sessionSettings` binding in `_bindExtensionCore` and the `ExtensionSessionSettings` public contract.
- LOW: the builtin `/fallback` argument router.

## 2026-09-01 - Negotiate RPC session auto-titling

### What changed
Expand Down
13 changes: 12 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ import { expandPromptTemplateWithMetadata, type PromptTemplate } from "./prompt-
import { createProviderTimeoutRetryPlan, runBoundedRetryContinuation } from "./provider-timeout-retry.ts";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
import { isBillingErrorMessage } from "./retry-fallback/billing.ts";
import { formatSelector } from "./retry-fallback/chains.ts";
import { formatSelector, parseFallbackSelector } from "./retry-fallback/chains.ts";
import { RetryFallbackController } from "./retry-fallback/controller.ts";
import { SelectorCooldowns } from "./retry-fallback/cooldown.ts";
import {
Expand Down Expand Up @@ -6880,6 +6880,17 @@ export class AgentSession {
pinned: active.pinned,
};
},
restoreFallbackPrimary: async () => {
const active = this._retryFallback.activeState;
if (!active) return false;
const selector = parseFallbackSelector(active.originalSelector, this._modelRegistry);
const model = selector ? this._modelRegistry.find(selector.provider, selector.id) : undefined;
if (!model) return false;
const originalThinkingLevel = active.originalThinkingLevel;
await this.setSessionModel(model);
if (originalThinkingLevel !== undefined) this.setSessionThinkingLevel(originalThinkingLevel);
return true;
},
},
compact: (options) => {
const admission = this._claimPendingCompactionAdmission();
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# changes

## 2026-09-02 - Restore pre-fallback session state

### What changed

- `packages/coding-agent/src/core/agent-session.ts` exposes `restoreFallbackPrimary()` through the session settings binding. It resolves the controller's recorded original model, switches only the current session, restores its prior thinking level, and clears live fallback state through the normal manual model-change path.

### Why

- Operators needed a direct way to undo an unwanted fallback without changing global model defaults or reconstructing the previous thinking level.

### Why an extension could not handle it

- The original model/thinking snapshot is private `AgentSession` and retry-controller state. The builtin command consumes the narrow session action rather than reaching into core.

### Expected merge conflict zones

- MEDIUM: the `sessionSettings` object in `_bindExtensionCore`.

## 2026-08-31 - Session activity contract for host occupancy decisions

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# claude-sdk-oauth

## 2026-09-02 - Recreate restart bindings after compaction

### What changed

- Derive persisted sent-stream hashes directly from `SessionManager.buildSessionContext()`, the same compaction-aware active context projection used by the runtime. Do not reconstruct it from `getBranch()`: a long-running resident mirror may have externalized message bodies that the manager can restore but a copied branch cannot.
- Recreate the fixed-size restart sidecar after the first successful post-compaction assistant turn.
- Admit post-anchor user/tool-result and automatic goal-continuation metadata as unsent restart delta. A later assistant or unknown state entry still rejects the stale anchor, and a newer binding invalidation remains authoritative.
- Persist from the current in-memory continuity binding when a successful turn closes its resident registry entry before the host emits `message_end`; require its `sentCount` to equal the active context hashes before trusting it.

### Why

- Accepted compaction deleted the previous sidecar, and the old branch walker returned no hashes forever after. A first attempted fix rebuilt context from `getBranch()`, but live instrumentation on the affected 527-message session measured `hashes: 0` there while reopening the same file through `buildSessionContext()` produced 386 transmitted hashes. Restarting without a sidecar cold-seeded hundreds of messages; an aborted seed left a zero-count lineage that replayed the entire context again and failed with `invalid_request`.
- The corrected sidecar was then created on the real session, but restart admission deleted it because `goal-continuation`, memory state, and the next user input followed the anchored assistant. Those entries do not rewrite the trusted assistant; they are the delta the resumed query must consume.
- Live restarts also showed successful fallback turns after `resume_initialization_aborted` with no new marker: the registry entry had closed, but `message_end` returned before consulting the binding that `session-turn-attempt` had already committed. The next restart therefore found a stale anchor followed by later assistants and correctly rejected it.

### Expected merge conflict zones

- LOW: `session-registry-wiring.ts` at the `message_end` persistence seam, plus the restart regression.

## 2026-09-02 - Preserve selected OAuth slot during resume

### What changed

- Treat a non-sentinel top-level OAuth credential as one configured account even when the provider-owned `accounts` pool is absent.

### Why

- Session resume can project the selected account into the top-level credential while omitting the sibling pool. Counting only `accounts` incorrectly routes the session to the ambient lane and reports the provider as unconfigured.

## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: {
environment?: Record<string, string>,
): Promise<boolean> => {
const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : [];
const selectedStoredAccount =
stored?.type === "oauth" &&
stored.access !== SENTINEL_OAUTH_FIELDS.access &&
stored.refresh !== SENTINEL_OAUTH_FIELDS.refresh;
const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx));
const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length;
const accountCount = storedAccounts.length + environmentTokenCount;
const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount;
const settings = deps.readSettings?.();
const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient");
if (lane === "ambient") {
Expand Down
Loading