From 3a06864010e04118fa0f6c93e31af0df849239c4 Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 17:20:08 +0800 Subject: [PATCH 01/32] feat(providers): add a service-level model-list source Each endpoint service names the URL that lists the ids it serves, spelled out rather than derived from a variant's baseUrl and protocol: DeepSeek's `/anthropic` variant would derive `/anthropic/v1/models` and Vercel's bare-origin one a root `/models`, and neither route exists. Both Cloudflare entries serve no list at all, so they stay absent and those accounts remain freeform-only. --- packages/foundation/providers/AGENTS.md | 26 +++++++++++++--- .../providers/src/__tests__/resolve.test.ts | 29 ++++++++++++++++- packages/foundation/providers/src/catalog.ts | 31 +++++++++++++++++++ packages/foundation/providers/src/index.ts | 8 ++++- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/foundation/providers/AGENTS.md b/packages/foundation/providers/AGENTS.md index 9b32a969..21b772e5 100644 --- a/packages/foundation/providers/AGENTS.md +++ b/packages/foundation/providers/AGENTS.md @@ -38,6 +38,15 @@ Pure data plus pure functions: no hooks, no browser APIs, no I/O. Its only depen exactly this reason: the client used the raw field once and immediately disagreed with the resolver about the same account — showing a pinned endpoint for one that resolves per agent. Display, edit-form prefill, and resolution have to answer the question identically. +- **`models` is service-level and spelled out, never derived.** One secret reaches one model list, + and the ids are identical whichever protocol shape an agent resolves to — so the list belongs to + the service, not the variant, and one fetch serves every agent bound to the account. The URL is + written out because deriving it from a variant's `baseUrl` + protocol is wrong wherever variants + sit on different paths: DeepSeek's `/anthropic` variant would give `/anthropic/v1/models` and + Vercel's bare-origin one a root `/models`, neither of which exists. `wire` picks the auth header + and response shape only. Absent means the service serves no list, and the account is freeform-only + — true for both Cloudflare entries, whose `/compat` route has no model-list path (docs + verified + live). Anthropic's list defaults to `limit=20`, so the full list must be asked for. - **A missing variant is a claim about the vendor, so verify it.** Omitting `openai-responses` refuses codex outright, and an unverified assumption that "that endpoint doesn't serve it anyway" once shipped exactly that gap for xAI, OpenRouter and Vercel — all three do serve @@ -72,11 +81,18 @@ known provider" and fall through to current behavior, never fail a session. ## Not here yet Registering a **custom** provider for an endpoint no agent knows — opencode -`provider..{npm, models}`, pi `registerProvider` with `models[]` — is unimplemented. pi's -`models[]` requires `reasoning` / `input` / `cost` / `contextWindow` / `maxTokens`, which no -`/v1/models` response carries and `contextWindow` feeds pi's compaction math, so the metadata source -is a real decision. Until it lands, endpoints without a known provider keep the pre-existing -behavior (baseUrl override on a guessed provider). +`provider..{npm, models}`, pi `registerProvider` with `models[]` — is unimplemented. Endpoints +without a known provider keep the pre-existing behavior (baseUrl override on a guessed provider). + +Metadata is **not** the blocker it was once recorded as: both agents accept a bare id and fill the +rest themselves (checked against opencode's config schema, where every `Model` field is optional in +v1 and v2, and pi's `modelFromJson`, which defaults `contextWindow` to 128000 and `maxTokens` to +16384). The reason to still avoid declaring models is the opposite one — **declaring a model the +agent already knows destroys good metadata.** pi's `applyModelsJson` replaces on id match, so +redeclaring `deepseek-v4-pro` overwrites its real 1M context window with that 128000 default and +makes the session compact constantly, silently. If custom registration is ever built, it must +declare only ids the agent's own catalog lacks, and reach for pi's `modelOverrides` (a field-level +patch that does not replace) whenever a known model needs one value changed. **Do not fake the gap by passing a wire hint.** pi's `ProviderConfigInput` accepts `api`, so `registerProvider({ baseUrl, api })` typechecks — and the SDK discards it on any call without diff --git a/packages/foundation/providers/src/__tests__/resolve.test.ts b/packages/foundation/providers/src/__tests__/resolve.test.ts index 78e4c50b..fae1809b 100644 --- a/packages/foundation/providers/src/__tests__/resolve.test.ts +++ b/packages/foundation/providers/src/__tests__/resolve.test.ts @@ -1,7 +1,7 @@ import type { Account, AgentKind, AgentRuntimes } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; -import { serviceById } from '../catalog'; +import { endpointServiceById, modelListSource, serviceById } from '../catalog'; import { detectedLoginSuggestions } from '../detected-logins'; import { resolveBinding, serviceProtocols } from '../resolve'; import { fillTemplate, templatePlaceholders } from '../template'; @@ -276,6 +276,33 @@ describe('catalog helpers', () => { expect(serviceProtocols(undefined)).toEqual([]); }); + it('resolves a model-list source only for services that serve one', () => { + // Service root, deliberately not the `/anthropic` variant's path. + expect(modelListSource('deepseek')).toEqual({ + url: 'https://api.deepseek.com/models', + wire: 'openai', + }); + expect(modelListSource('anthropic-api')?.wire).toBe('anthropic'); + // Both Cloudflare routes serve no list, and oauth services have no secret to ask with. + expect(modelListSource('cloudflare-gateway')).toBeUndefined(); + expect(modelListSource('cloudflare-anthropic')).toBeUndefined(); + expect(modelListSource('claude-sub')).toBeUndefined(); + expect(modelListSource('custom')).toBeUndefined(); + expect(modelListSource(undefined)).toBeUndefined(); + }); + + it('keeps the model-list url independent of the variant an agent resolves to', () => { + // Deriving from the resolved variant is what this replaced: the anthropic variants of these two + // sit on different paths, so appending would ask a route that does not exist. + for (const id of ['deepseek', 'vercel-gateway']) { + const service = nullthrow(endpointServiceById(id), `${id} missing`); + const anthropic = nullthrow(service.variants.anthropic, `${id} anthropic variant missing`); + expect(nullthrow(service.models, `${id} model list missing`).url).not.toBe( + `${anthropic.baseUrl}/models`, + ); + } + }); + it('extracts and fills endpoint template placeholders', () => { const cloudflare = serviceById('cloudflare-anthropic'); if (cloudflare?.kind !== 'endpoint') throw new Error('cloudflare descriptor missing'); diff --git a/packages/foundation/providers/src/catalog.ts b/packages/foundation/providers/src/catalog.ts index b6cdf9bc..5287aed5 100644 --- a/packages/foundation/providers/src/catalog.ts +++ b/packages/foundation/providers/src/catalog.ts @@ -23,6 +23,21 @@ export interface ServiceVariant { knownProvider?: Partial>; } +/** + * Where to read the ids this service serves. Service-level, not per variant: one secret reaches one + * model list, and the ids are the same whichever protocol shape an agent ends up using. + * + * The URL is spelled out rather than derived from a variant's `baseUrl` + protocol, because + * derivation is wrong for any service whose variants sit on different paths — DeepSeek's + * `/anthropic` variant would yield `/anthropic/v1/models`, and Vercel's bare-origin one a root + * `/models`. Absent means the service serves no list and the account is freeform-only. + */ +export interface ServiceModelList { + url: string; + /** Decides auth header and response shape only; the chat/responses split is irrelevant here. */ + wire: 'anthropic' | 'openai'; +} + export type ServiceDescriptor = /** Delegates to an agent CLI's own login store — no secret handled by LinkCode. */ | { id: string; label: string; group: 'subscription'; kind: 'oauth'; agent: AgentKind } @@ -35,6 +50,7 @@ export type ServiceDescriptor = /** How the one secret authenticates. Service-level: every variant accepts the same secret. */ credentialType: 'api-key' | 'auth-token'; variants: Partial>; + models?: ServiceModelList; secretPlaceholder?: string; } /** Free-form endpoint — the full account form. */ @@ -55,6 +71,8 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ knownProvider: { opencode: 'anthropic', pi: 'anthropic' }, }, }, + // `limit` defaults to 20, so it must be asked for explicitly to get the whole list. + models: { url: 'https://api.anthropic.com/v1/models?limit=1000', wire: 'anthropic' }, secretPlaceholder: 'sk-ant-…', }, { @@ -72,6 +90,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // resolve to the Responses adapter, so reaching chat here needs a custom registration. 'openai-chat': { baseUrl: 'https://api.openai.com/v1' }, }, + models: { url: 'https://api.openai.com/v1/models', wire: 'openai' }, secretPlaceholder: 'sk-…', }, { @@ -89,6 +108,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // own `xai` entries are chat-shaped, and opencode/pi prefer those. 'openai-responses': { baseUrl: 'https://api.x.ai/v1' }, }, + models: { url: 'https://api.x.ai/v1/models', wire: 'openai' }, secretPlaceholder: 'xai-…', }, { @@ -107,6 +127,8 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ knownProvider: { opencode: 'deepseek', pi: 'deepseek' }, }, }, + // Service root, not the `/anthropic` variant's path — that one serves no list. + models: { url: 'https://api.deepseek.com/models', wire: 'openai' }, secretPlaceholder: 'sk-…', }, { @@ -124,6 +146,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // The "Anthropic skin" is guaranteed only for Claude models. anthropic: { baseUrl: 'https://openrouter.ai/api' }, }, + models: { url: 'https://openrouter.ai/api/v1/models', wire: 'openai' }, secretPlaceholder: 'sk-or-v1-…', }, { @@ -141,6 +164,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // Anthropic-shaped endpoint; translates server-side, so it also serves non-Anthropic models. anthropic: { baseUrl: 'https://ai-gateway.vercel.sh' }, }, + models: { url: 'https://ai-gateway.vercel.sh/v1/models', wire: 'openai' }, }, { id: 'cloudflare-gateway', @@ -148,6 +172,8 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ group: 'gateway', kind: 'endpoint', credentialType: 'auth-token', + // No `models`: `/compat` has no model-list route at all (docs + verified live), so a Cloudflare + // gateway account is freeform-only. variants: { // `/compat` serves chat completions only — Cloudflare's Responses route is a different path // (`/openai/responses`), so there is deliberately no responses variant here. @@ -185,3 +211,8 @@ export function endpointServiceById(id: string | undefined): EndpointService | u const service = serviceById(id); return service?.kind === 'endpoint' ? service : undefined; } + +/** Where to read this service's model ids, or undefined when it serves no list. */ +export function modelListSource(id: string | undefined): ServiceModelList | undefined { + return endpointServiceById(id)?.models; +} diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index d7aebb17..9af0dbc3 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -2,9 +2,15 @@ export type { EndpointService, ServiceDescriptor, ServiceGroup, + ServiceModelList, ServiceVariant, } from './catalog'; -export { endpointServiceById, SERVICE_CATALOG, serviceById } from './catalog'; +export { + endpointServiceById, + modelListSource, + SERVICE_CATALOG, + serviceById, +} from './catalog'; export type { DetectedLoginSuggestion } from './detected-logins'; export { detectedLoginSuggestions } from './detected-logins'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; From 2d5dc3a1088a24552b3909d8e4e8218734244d2b Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 17:54:30 +0800 Subject: [PATCH 02/32] feat(schema,engine): make the account's picked model set the only model source An account now carries the models the user selected (`Account.models`) instead of one free-text default, and the pick itself lives per agent as `ProviderConfig.model`. Nothing falls back to the agent's own choice any more, so a bound agent with no pick refuses to start rather than running on a model the user never chose; an agent with no account bound keeps resolving its own. `config.probe-models` now names a service and lets the daemon resolve the list URL from the catalog, so a saved account is probed by id and its stored secret never travels back out to the client. Both wire versions move: removing `Account.model`, renaming `defaultModel`, and dropping the `null` tier from `StartOptions.model` are breaking. `loadConfig` carries both old fields over on read, since zod would otherwise strip them and silently lose every existing user's configured model. The model inputs are gone from the account forms; the multi-select that replaces them lands with the picker work. --- apps/daemon/src/__tests__/config.test.ts | 11 ++- apps/daemon/src/config.ts | 23 ++++- packages/client/core/src/client.ts | 10 +- .../client/core/src/client/control-channel.ts | 16 ++-- packages/client/sdk/src/client.ts | 9 +- packages/client/sdk/src/operations.ts | 8 +- .../__tests__/default-models.test.ts | 35 ++----- .../settings/providers/__tests__/view.test.ts | 16 ++-- .../src/settings/providers/add-flow.tsx | 52 +++-------- .../src/settings/providers/default-models.ts | 24 ++--- .../workbench/src/settings/providers/view.ts | 8 +- .../src/surface/use-workbench-sessions.ts | 4 +- .../integration/dev-mock-transport.test.ts | 4 +- .../foundation/schema/src/model/account.ts | 22 +++-- .../schema/src/model/agent/input.ts | 7 +- .../schema/src/model/provider-config.ts | 6 +- packages/foundation/schema/src/wire/config.ts | 15 +-- .../foundation/schema/src/wire/message.ts | 4 +- .../__tests__/engine-agent-catalog.test.ts | 4 +- .../src/__tests__/engine-model-probe.test.ts | 93 ++++++++++++++++--- .../engine/src/__tests__/model-probe.test.ts | 44 ++++++--- .../src/__tests__/provider-config.test.ts | 22 +++-- .../src/__tests__/start-options-mcp.test.ts | 21 +++++ packages/host/engine/src/agent/model-probe.ts | 17 ++-- .../host/engine/src/agent/provider-config.ts | 10 +- .../host/engine/src/agent/request-handler.ts | 25 ++++- .../src/session/start-options-resolver.ts | 18 +++- .../__tests__/new-session-surface.test.tsx | 10 +- .../ui/src/shell/new-session-surface.tsx | 6 +- .../ui/src/shell/providers/account-detail.tsx | 7 +- 30 files changed, 343 insertions(+), 208 deletions(-) diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index b7ec922d..399b3b69 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -76,8 +76,9 @@ describe('loadConfig providers', () => { const config = loadConfig(vault); + // `defaultModel` carries over as the persisted pick; without that it would be silently stripped. expect(config.providers).toEqual({ - 'claude-code': { enabled: true, defaultModel: 'sonnet' }, + 'claude-code': { enabled: true, model: 'sonnet' }, }); expect(errorSpy).toHaveBeenCalled(); }); @@ -236,6 +237,14 @@ describe('loadConfig accounts', () => { expect(errorSpy).toHaveBeenCalled(); }); + it("carries a pre-selection account's single model over as its picked set", () => { + writeAccountsConfig([{ ...validAccount, model: 'deepseek-v4-pro' }]); + + expect(loadConfig(vault).accounts).toEqual([ + { ...validAccount, models: [{ id: 'deepseek-v4-pro' }] }, + ]); + }); + it('drops an account whose stored secret is gone, rather than half-loading it', () => { const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); // The post-migration on-disk shape: an api-key credential with no key. With an empty vault the diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index a9387305..690e1cce 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -211,7 +211,7 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed { // secret that is gone fails the schema and lands in the same drop-and-log path as a malformed one. const attached = withAccountSecret(store, value); migrated ||= attached.migrated; - const account = AccountSchema.safeParse(attached.value); + const account = AccountSchema.safeParse(withPickedModels(attached.value)); if (!account.success) { logger.warn({ operation: 'config.load' }, 'Dropping invalid account config'); continue; @@ -221,6 +221,25 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed { return { value: accounts, migrated }; } +/** Pre-selection configs stored one free-text model per account; carry it over as the picked set, + * or zod strips the unknown key and the user silently loses their model. Idempotent. */ +function withPickedModels(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value; + const { model, ...rest } = value as { model?: unknown; models?: unknown }; + if (typeof model !== 'string' || model === '' || rest.models !== undefined) return rest; + return { ...rest, models: [{ id: model }] }; +} + +/** Same carry-over for the per-agent default, which is now the persisted pick. */ +function withPickedModel(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value; + const { defaultModel, ...rest } = value as { defaultModel?: unknown; model?: unknown }; + if (typeof defaultModel !== 'string' || defaultModel === '' || rest.model !== undefined) { + return rest; + } + return { ...rest, model: defaultModel }; +} + /** * Parse element by element like {@link parseAccounts}: one invalid server is dropped and logged, * never blanking the rest. @@ -270,7 +289,7 @@ function parseProviders(store: SecretStore, raw: unknown): Parsed { - return this.control.probeAccountModels(endpoint, secret); + /** Models a service serves, read daemon-side with an unsaved secret or a saved account's own. */ + probeAccountModels( + service: string, + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }, + ): Promise { + return this.control.probeAccountModels(service, credential); } /** Masked custom MCP servers (env/header keys only — the daemon never returns values). */ diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 03202a44..13276f8c 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,6 +1,5 @@ import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -579,14 +578,19 @@ export class ControlChannel { })); } - /** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer - * the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */ - probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { + /** Ask the daemon which models a service serves, so the account forms can offer a real list to + * pick from. The daemon must do it — the renderer's CSP blocks the fetch, and it resolves the list + * URL from the service catalog itself. Pass a secret the add form has not saved yet, or the id of + * a saved account so its stored secret never leaves the daemon. */ + probeAccountModels( + service: string, + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }, + ): Promise { return this.sendCorrelated('accountModels', (clientReqId) => ({ kind: 'config.probe-models', clientReqId, - endpoint, - secret, + service, + credential, })); } diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index b1990617..2f27207c 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -10,7 +10,6 @@ import type { import { LinkCodeClient } from '@linkcode/client-core'; import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -291,12 +290,12 @@ export class LinkCodeSdkClient { return toResult(this.raw.setAccounts(accounts)); } - /** Enumerate what an endpoint serves, using a secret that is not saved yet. */ + /** Enumerate the models a service serves, with an unsaved secret or a saved account's own. */ probeAccountModels( - endpoint: AccountEndpoint, - secret: AccountSecret, + service: string, + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }, ): RequestResult { - return toResult(this.raw.probeAccountModels(endpoint, secret)); + return toResult(this.raw.probeAccountModels(service, credential)); } /** Masked custom MCP servers (data plane) — env/header keys only, never a secret value. */ diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index ed353144..b59c551b 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -7,7 +7,6 @@ import type { } from '@linkcode/client-core'; import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -278,9 +277,12 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe } export function probeAccountModels( - options: Options<{ endpoint: AccountEndpoint; secret: AccountSecret }>, + options: Options<{ + service: string; + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }; + }>, ): RequestResult { - return resolveClient(options).probeAccountModels(options.endpoint, options.secret); + return resolveClient(options).probeAccountModels(options.service, options.credential); } /** Masked custom MCP servers — env/header keys only, never a secret value. */ diff --git a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts index a8d49fda..88bef1cb 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts @@ -27,46 +27,23 @@ afterEach(() => { }); describe('configuredDefaultModels', () => { - it('uses an active account model before the provider default and ignores stale bindings', () => { + it('reads the per-agent pick and reports nothing for an agent that has none', () => { const providers = { - codex: { - enabled: true, - activeAccountId: 'account-1', - defaultModel: 'provider-model', - }, - 'claude-code': { - enabled: true, - activeAccountId: 'missing-account', - defaultModel: 'claude-provider-model', - }, + codex: { enabled: true, activeAccountId: 'account-1', model: 'gpt-5.6-sol' }, + // Bound but unpicked: no model to report, so a session start refuses rather than guessing. + 'claude-code': { enabled: true, activeAccountId: 'account-1' }, } satisfies ProvidersConfig; - const accounts = [ - { - id: 'account-1', - label: 'Configured account', - credential: { type: 'oauth', agent: 'codex' }, - model: 'account-model', - createdAt: 0, - }, - ] satisfies Accounts; - expect(configuredDefaultModels(providers, accounts)).toEqual({ - codex: 'account-model', - 'claude-code': 'claude-provider-model', - }); + expect(configuredDefaultModels(providers)).toEqual({ codex: 'gpt-5.6-sol' }); }); - it('keeps defaults unresolved until both configuration sources have loaded', () => { + it('keeps the pick unresolved until the provider config has loaded', () => { const { result, rerender } = renderHook(() => useConfiguredDefaultModels()); expect(result.current).toBeNull(); providersData = {}; rerender(); - expect(result.current).toBeNull(); - - accountsData = []; - rerender(); expect(result.current).toEqual({}); }); }); diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index 058ccb53..2c082c8e 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -12,7 +12,7 @@ import { } from '../view'; const providers: ProvidersConfig = { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', defaultModel: 'claude-opus-4-8' }, + 'claude-code': { enabled: true, activeAccountId: 'acc_a', model: 'claude-opus-4-8' }, codex: { enabled: false, activeAccountId: 'acc_b' }, opencode: { enabled: true }, }; @@ -29,14 +29,14 @@ describe('binding transforms', () => { it('unbinds by dropping only activeAccountId', () => { const next = withBinding(providers, 'claude-code', undefined); - expect(next['claude-code']).toEqual({ enabled: true, defaultModel: 'claude-opus-4-8' }); + expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); }); it('sets and clears the default model without touching the binding', () => { expect(withModel(providers, 'claude-code', 'claude-sonnet-5')['claude-code']).toEqual({ enabled: true, activeAccountId: 'acc_a', - defaultModel: 'claude-sonnet-5', + model: 'claude-sonnet-5', }); expect(withModel(providers, 'claude-code', undefined)['claude-code']).toEqual({ enabled: true, @@ -46,7 +46,7 @@ describe('binding transforms', () => { it('clears every binding of a removed account, identity-stable when none matched', () => { const next = withoutAccount(providers, 'acc_a'); - expect(next['claude-code']).toEqual({ enabled: true, defaultModel: 'claude-opus-4-8' }); + expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_b' }); expect(withoutAccount(providers, 'acc_missing')).toBe(providers); }); @@ -58,7 +58,7 @@ describe('view helpers', () => { const snippet = accountConfigSnippet(providers, 'acc_a'); expect(JSON.parse(snippet)).toEqual({ providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', defaultModel: 'claude-opus-4-8' }, + 'claude-code': { enabled: true, activeAccountId: 'acc_a', model: 'claude-opus-4-8' }, }, }); }); @@ -156,7 +156,7 @@ describe('view helpers', () => { service: 'openrouter', credential: { type: 'api-key', key: 'old-secret' }, endpoint: { baseUrl: 'https://old.example.com/v1', protocol: 'openai-chat' }, - model: 'old-model', + models: [{ id: 'old-model' }], extraEnv: { GATEWAY_MODE: 'strict' }, }; @@ -167,7 +167,6 @@ describe('view helpers', () => { secret: 'new-secret', baseUrl: 'https://new.example.com/v1', protocol: 'anthropic', - model: 'new-model', }), ).toEqual({ id: 'acc_a', @@ -176,7 +175,8 @@ describe('view helpers', () => { service: 'openrouter', credential: { type: 'auth-token', token: 'new-secret' }, endpoint: { baseUrl: 'https://new.example.com/v1', protocol: 'anthropic' }, - model: 'new-model', + // The picked set survives an edit: this form does not manage it. + models: [{ id: 'old-model' }], extraEnv: { GATEWAY_MODE: 'strict' }, }); }); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index 88f91860..ce907e0d 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -67,7 +67,6 @@ function catalogAccount(service: EndpointService, draft: CatalogDraft): Account ? { type: 'auth-token', token: draft.secret } : { type: 'api-key', key: draft.secret }, ...(!isObjectEmpty(trimmed) && { endpointParams: trimmed }), - ...(draft.model.trim() && { model: draft.model.trim() }), }; } @@ -94,13 +93,10 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account const base = account === undefined ? newAccountBase(draft.label) - : (({ - credential: _credential, - endpoint: _endpoint, - label: _label, - model: _model, - ...rest - }) => rest)(account); + : // `models` is deliberately kept: this form does not manage the picked set. + (({ credential: _credential, endpoint: _endpoint, label: _label, ...rest }) => rest)( + account, + ); return { ...base, label: draft.label.trim(), @@ -110,7 +106,6 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account : { type: 'api-key', key: draft.secret }, ...(draft.baseUrl.trim() && protocol && { endpoint: { baseUrl: draft.baseUrl.trim(), protocol } }), - ...(draft.model.trim() && { model: draft.model.trim() }), }; } @@ -356,7 +351,6 @@ function OauthCreateForm({ const CatalogDraftSchema = z.object({ label: z.string().min(1), secret: z.string().min(1), - model: z.string(), placeholders: z.record(z.string(), z.string()), }); type CatalogDraft = z.infer; @@ -398,7 +392,7 @@ function CatalogAccountForm({ formState: { isSubmitting }, } = useForm({ resolver: zodResolver(catalogDraftSchema(service)), - defaultValues: { label: serviceName, secret: '', model: '', placeholders: {} }, + defaultValues: { label: serviceName, secret: '', placeholders: {} }, }); const secretLabel = @@ -419,26 +413,16 @@ function CatalogAccountForm({ ))} -
-
- - {secretLabel} - - -
-
- - {t('form.model')} - - -
-
+ + {secretLabel} + +

{serviceProtocols(service.id).join(' · ')}

@@ -457,7 +441,6 @@ const CustomDraftSchema = z.object({ secret: z.string().min(1), baseUrl: z.string(), protocol: z.string(), - model: z.string(), }); type CustomDraft = z.infer; @@ -495,7 +478,6 @@ function CustomAccountForm({ // resolve time, so showing it would invite the user to "keep" a value that does nothing. baseUrl: (account && pinnedEndpoint(account)?.baseUrl) ?? '', protocol: (account && pinnedEndpoint(account)?.protocol) ?? '', - model: account?.model ?? '', }, }); @@ -556,10 +538,6 @@ function CustomAccountForm({ - - {t('form.model')} - -
); @@ -200,11 +214,13 @@ export function AddAccountForm({ /** Existing-account editor shown inside the account management dialog. */ export function EditAccountForm({ account, + sources, busy, onBack, onSubmit, }: { account: Account; + sources?: ModelSources; busy: boolean; onBack: () => void; onSubmit: (account: Account) => void; @@ -226,44 +242,70 @@ export function EditAccountForm({ {account.credential.type === 'oauth' ? ( - + ) : ( - + )} ); } -const OauthEditDraftSchema = z.object({ label: z.string().min(1) }); +const OauthEditDraftSchema = z.object({ + label: z.string().min(1), + models: z.array(AccountModelSchema), +}); type OauthEditDraft = z.infer; function OauthEditForm({ account, + sources, busy, onSubmit, }: { account: Account; + sources?: ModelSources; busy: boolean; onSubmit: (account: Account) => void; }): React.ReactNode { const t = useTranslations('settings.providers'); const { register, + control, handleSubmit, formState: { isSubmitting }, } = useForm({ resolver: zodResolver(OauthEditDraftSchema), - defaultValues: { label: account.label }, + defaultValues: { label: account.label, models: account.models ?? [] }, }); + const agent = account.credential.type === 'oauth' ? account.credential.agent : undefined; + const fetchModels = agent === undefined || !sources ? undefined : () => sources.oauth(agent); return (
onSubmit({ ...account, label: draft.label.trim() }))} + onSubmit={handleSubmit((draft) => + onSubmit({ + ...account, + label: draft.label.trim(), + ...(draft.models.length > 0 ? { models: draft.models } : { models: undefined }), + }), + )} > {t('form.label')} + ( + + )} + />

{t('oauthEditHint')}

@@ -337,7 +389,7 @@ function OauthCreateForm({ busy || label.trim() === '' ? undefined : (kind) => { - onboarding.login(kind, () => onSubmit(oauthAccount(service, label))); + onboarding.login(kind, () => onSubmit(oauthAccount(service, label, models))); } } onSubmitLoginCode={onboarding.submitLoginCode} @@ -352,6 +404,7 @@ const CatalogDraftSchema = z.object({ label: z.string().min(1), secret: z.string().min(1), placeholders: z.record(z.string(), z.string()), + models: z.array(AccountModelSchema), }); type CatalogDraft = z.infer; @@ -375,10 +428,12 @@ function placeholderLabel(key: string): string { function CatalogAccountForm({ service, + sources, busy, onSubmit, }: { service: EndpointService; + sources?: ModelSources; busy: boolean; onSubmit: (account: Account) => void; }): React.ReactNode { @@ -388,16 +443,34 @@ function CatalogAccountForm({ const { register, + control, + getValues, handleSubmit, formState: { isSubmitting }, } = useForm({ resolver: zodResolver(catalogDraftSchema(service)), - defaultValues: { label: serviceName, secret: '', placeholders: {} }, + defaultValues: { label: serviceName, secret: '', placeholders: {}, models: [] }, }); const secretLabel = service.credentialType === 'auth-token' ? t('credentialAuthToken') : t('credentialApiKey'); + /** The secret is read at click time rather than watched: the button stays enabled and says what + * is missing, instead of subscribing the whole form to every keystroke. */ + const fetchModels = + sources && service.models + ? async (): Promise => { + const secret = getValues('secret'); + if (!secret) throw new Error(t('models.secretFirst')); + return sources.probeInline( + service.id, + service.credentialType === 'auth-token' + ? { type: 'auth-token', token: secret } + : { type: 'api-key', key: secret }, + ); + } + : undefined; + return ( + ( + + )} + />

{serviceProtocols(service.id).join(' · ')}

@@ -441,16 +526,19 @@ const CustomDraftSchema = z.object({ secret: z.string().min(1), baseUrl: z.string(), protocol: z.string(), + models: z.array(AccountModelSchema), }); type CustomDraft = z.infer; /** The full free-form account form (any endpoint, any protocol) — no catalog seeding. */ function CustomAccountForm({ account, + sources, busy, onSubmit, }: { account?: Account; + sources?: ModelSources; busy: boolean; onSubmit: (account: Account) => void; }): React.ReactNode { @@ -478,8 +566,19 @@ function CustomAccountForm({ // resolve time, so showing it would invite the user to "keep" a value that does nothing. baseUrl: (account && pinnedEndpoint(account)?.baseUrl) ?? '', protocol: (account && pinnedEndpoint(account)?.protocol) ?? '', + models: account?.models ?? [], }, }); + // A saved account is probed by id so its stored secret stays on the daemon side. A custom account + // names no service, so nothing can list its models and the set stays freeform. + const service = account?.service; + const fetchModels = + sources !== undefined && + account !== undefined && + service !== undefined && + modelListSource(service) !== undefined + ? (): Promise => sources.probeAccount(service, account.id) + : undefined; const typeItems = [ { value: 'api-key', label: t('credentialApiKey') }, @@ -538,6 +637,18 @@ function CustomAccountForm({
+ ( + + )} + />
+ ) : null} +
+

+ {onFetch ? t('models.hint') : t('models.hintUnlistable')} +

+ {error !== undefined ?

{error}

: null} + {listed.length > 0 ? ( +
+ {listed.map((model) => ( + + ))} +
+ ) : null} +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') return; + // Enter here adds an id; letting it bubble would submit the whole account form. + event.preventDefault(); + addDraft(); + }} + placeholder={t('models.addPlaceholder')} + value={draft} + /> + +
+ + ); +} diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 98955b5b..b029e38f 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -21,6 +21,7 @@ import { useAgentRuntimes } from '../../agent-runtime/hooks'; import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; +import { useModelSources } from './model-selection'; import { useProvidersSettingsStore } from './store'; import { providerAccountDetailViewModel, @@ -48,6 +49,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { const bindAccount = useMutation(createAndBindAccount); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); + // The forms are presentation; only this page sits inside the data-plane provider tree. + const modelSources = useModelSources(); const view = useProvidersSettingsStore((state) => state.view); const select = useProvidersSettingsStore((state) => state.select); @@ -169,6 +172,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { {view.kind === 'add-form' ? ( { diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 9ddcecb9..5faf0e3f 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1025,7 +1025,17 @@ export const en = { copySecret: 'Copy', endpoint: 'Endpoint', protocols: 'Protocol shapes', - accountModel: 'Default model', + accountModel: 'Models', + models: { + label: 'Models', + hint: 'Fetch this service’s model list and tick the ones you want; only ticked models are offered in the composer.', + hintUnlistable: 'This endpoint serves no model list — add model ids by hand.', + refresh: 'Fetch list', + fetchFailed: 'Could not read the model list', + secretFirst: 'Enter the key first, then fetch the model list', + add: 'Add', + addPlaceholder: 'Add a model id by hand', + }, loginState: 'Login', loggedIn: 'Signed in', loggedOut: 'Signed out', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6978c123..3f88e8cb 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -999,7 +999,17 @@ export const zhCN = { copySecret: '复制', endpoint: '端点', protocols: '协议形态', - accountModel: '默认模型', + accountModel: '可用模型', + models: { + label: '可用模型', + hint: '获取该服务的模型列表后勾选;只有勾选的模型会出现在输入框的模型选择里。', + hintUnlistable: '该端点不提供模型列表,请手动填写模型 ID。', + refresh: '获取列表', + fetchFailed: '获取模型列表失败', + secretFirst: '请先填写密钥,再获取模型列表', + add: '添加', + addPlaceholder: '手动添加模型 ID', + }, loginState: '登录状态', loggedIn: '已登录', loggedOut: '未登录', From c430eba824b1d610cff0f8cf530baf9822219921 Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 20:17:14 +0800 Subject: [PATCH 05/32] feat(workbench,ui): offer only the bound account's models, and refuse to send without one The composer and the new-session surface now read the set picked on the agent's bound account, which outranks both the adapter-advertised catalog and the curated table: a claude-code account pointing at DeepSeek stops offering Anthropic ids it cannot reach. Present-but-empty and absent mean different things in that set, and the send gate turns on the difference. An account bound with nothing picked blocks sending, matching the daemon's own refusal instead of discovering it a round trip later. An agent with no account bound is absent, still resolves its own model, and is not blocked. AGENT_DEFAULT_MODELS is gone: guessing a provider's model is exactly what the picked set replaces, and an unresolved model now blocks the send rather than silently starting on a vendor default. Rebinding an agent drops a pick the new account does not list, since keeping it would run the next session on a model that account never offered. --- .../src/renderer/src/shell/desktop-shell.tsx | 3 + .../__tests__/default-models.test.ts | 51 +++++++++++++++- .../settings/providers/__tests__/view.test.ts | 23 ++++++++ .../src/settings/providers/default-models.ts | 36 ++++++++++- .../settings/providers/providers-settings.tsx | 2 +- .../workbench/src/settings/providers/view.ts | 18 +++++- .../workbench/src/surface/workbench.tsx | 7 ++- .../__tests__/new-session-surface.test.tsx | 59 +++++++++++++++++++ .../presentation/ui/src/shell/agent-models.ts | 8 --- .../ui/src/shell/conversation-surface.tsx | 16 ++++- .../ui/src/shell/new-session-surface.tsx | 33 +++++++---- .../presentation/ui/src/shell/shell-frame.tsx | 7 +++ 12 files changed, 234 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index f7722673..e266b09d 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -80,6 +80,7 @@ export function DesktopShell({ attachmentSupport, agentCatalogs, newSessionDefaultModels, + accountModels, newSessionPreferredModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -429,6 +430,7 @@ export function DesktopShell({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} + accountModels={accountModels} preferredModels={newSessionPreferredModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} @@ -453,6 +455,7 @@ export function DesktopShell({ composer={conversationComposer} agentKind={active?.kind} agentLabel={agentLabel} + accountModels={active ? accountModels?.[active.kind] : undefined} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} diff --git a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts index 88bef1cb..49a62362 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts @@ -4,7 +4,12 @@ import type { Accounts, ProvidersConfig } from '@linkcode/schema'; import { getProviderConfig } from '@linkcode/sdk'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configuredDefaultModels, useConfiguredDefaultModels } from '../default-models'; +import { + accountModelOptions, + configuredDefaultModels, + useAccountModelOptions, + useConfiguredDefaultModels, +} from '../default-models'; const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); @@ -47,3 +52,47 @@ describe('configuredDefaultModels', () => { expect(result.current).toEqual({}); }); }); + +describe('accountModelOptions', () => { + it('distinguishes a bound agent with nothing picked from one with no account at all', () => { + const providers = { + codex: { enabled: true, activeAccountId: 'acc_1' }, + 'claude-code': { enabled: true, activeAccountId: 'acc_2' }, + // No account bound: absent, so its pickers fall through and its sends are not blocked. + opencode: { enabled: true }, + } satisfies ProvidersConfig; + const accounts = [ + { + id: 'acc_1', + label: 'Picked', + credential: { type: 'api-key', key: 'k' }, + models: [{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, { id: 'deepseek-v4-flash' }], + createdAt: 0, + }, + { id: 'acc_2', label: 'Unpicked', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ] satisfies Accounts; + + expect(accountModelOptions(providers, accounts)).toEqual({ + codex: [ + { id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, + // A relay ships bare ids; the id doubles as the label rather than rendering blank. + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' }, + ], + 'claude-code': [], + }); + }); + + it('stays unresolved until both sources have loaded', () => { + const { result, rerender } = renderHook(() => useAccountModelOptions()); + + expect(result.current).toBeNull(); + + providersData = {}; + rerender(); + expect(result.current).toBeNull(); + + accountsData = []; + rerender(); + expect(result.current).toEqual({}); + }); +}); diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index a748f139..50a0680f 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -32,6 +32,29 @@ describe('binding transforms', () => { expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); }); + it('drops a pick the newly bound account does not offer, and keeps one it does', () => { + const offers = (id: string, models: string[]): Accounts[number] => ({ + id, + label: id, + credential: { type: 'api-key', key: 'k' }, + models: models.map((model) => ({ id: model })), + createdAt: 0, + }); + const pool = [offers('acc_keep', ['claude-opus-4-8']), offers('acc_drop', ['deepseek-v4-pro'])]; + + // Rebinding to an account that lists the pick leaves it alone. + expect(withBinding(providers, 'claude-code', 'acc_keep', pool)['claude-code']).toEqual({ + enabled: true, + activeAccountId: 'acc_keep', + model: 'claude-opus-4-8', + }); + // One that does not would otherwise start the next session on a model it never listed. + expect(withBinding(providers, 'claude-code', 'acc_drop', pool)['claude-code']).toEqual({ + enabled: true, + activeAccountId: 'acc_drop', + }); + }); + it('sets and clears the default model without touching the binding', () => { expect(withModel(providers, 'claude-code', 'claude-sonnet-5')['claude-code']).toEqual({ enabled: true, diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts index 872965c4..a8875e3c 100644 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ b/packages/client/workbench/src/settings/providers/default-models.ts @@ -1,6 +1,6 @@ -import type { AgentKind, ProvidersConfig } from '@linkcode/schema'; +import type { Accounts, AgentKind, AgentModelOption, ProvidersConfig } from '@linkcode/schema'; import { AgentKindSchema } from '@linkcode/schema'; -import { getProviderConfig } from '@linkcode/sdk'; +import { getAccounts, getProviderConfig } from '@linkcode/sdk'; import { useData } from '../../runtime/tayori'; /** The model each agent currently runs on, as session start resolves it: the agent's persisted pick. @@ -23,3 +23,35 @@ export function useConfiguredDefaultModels(): Partial> if (providers === undefined) return null; return configuredDefaultModels(providers); } + +/** + * The models each agent may be switched to: the set picked on its bound account, and nothing else. + * + * Present-but-empty and absent mean different things, and callers rely on the difference. An entry + * exists for every agent with an account bound, so `[]` says "bound, nothing picked yet" and blocks + * sends the way the daemon does. Absent says "no account bound", where the agent still resolves its + * own model — so its pickers fall through to the adapter catalog or the curated table, and nothing + * blocks. + */ +export function accountModelOptions( + providers: ProvidersConfig | undefined, + accounts: Accounts | undefined, +): Partial> { + const options: Partial> = {}; + for (const kind of AgentKindSchema.options) { + const accountId = providers?.[kind]?.activeAccountId; + if (accountId === undefined) continue; + const models = accounts?.find((candidate) => candidate.id === accountId)?.models ?? []; + options[kind] = models.map(({ id, label }) => ({ id, label: label ?? id })); + } + return options; +} + +/** `null` until both daemon-owned sources have loaded, so a picker never briefly offers a set the + * account does not actually have. */ +export function useAccountModelOptions(): Partial> | null { + const { data: providers } = useData(getProviderConfig, {}); + const { data: accounts } = useData(getAccounts, {}); + if (providers === undefined || accounts === undefined) return null; + return accountModelOptions(providers, accounts); +} diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index b029e38f..fb33dc49 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -77,7 +77,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { }; const handleSetBinding = (kind: AgentKind, accountId: string | undefined): void => { - void applyProviders(withBinding(providers ?? {}, kind, accountId)); + void applyProviders(withBinding(providers ?? {}, kind, accountId, pool)); }; const handleSetModel = (kind: AgentKind, model: string | undefined): void => { diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index e36e6da4..271641d4 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -219,18 +219,32 @@ export function providerAccountListViewModel( }; } -/** Bind (or, with undefined, unbind) an agent's active account; other fields survive untouched. */ +/** + * Bind (or, with undefined, unbind) an agent's active account; other fields survive untouched — with + * one exception. The pick lives per agent while the set it came from lives on the account, so a + * rebind can orphan it. Dropping a pick the new account does not offer leaves the agent unpicked, + * which blocks its sends until the user chooses again; keeping it would run the next session on a + * model that account never listed. + */ export function withBinding( providers: ProvidersConfig, kind: AgentKind, accountId: string | undefined, + accounts: Accounts = [], ): ProvidersConfig { const entry = providers[kind] ?? { enabled: true }; if (accountId === undefined) { const { activeAccountId: _cleared, ...rest } = entry; return { ...providers, [kind]: rest }; } - return { ...providers, [kind]: { ...entry, activeAccountId: accountId } }; + const offered = accounts.find((candidate) => candidate.id === accountId)?.models; + const orphaned = + entry.model !== undefined && !(offered ?? []).some(({ id }) => id === entry.model); + const { model: _dropped, ...kept } = entry; + return { + ...providers, + [kind]: { ...(orphaned ? kept : entry), activeAccountId: accountId }, + }; } /** Toggle whether the agent is offered in the client's agent picker. */ diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index fd1e18da..92f900e8 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -57,7 +57,10 @@ import { WorkbenchCommandPalette } from '../palette/command-palette'; import { openCommandPalette } from '../palette/store'; import { useWorkbenchSdkClient } from '../runtime/provider'; import { useMutation } from '../runtime/tayori'; -import { useConfiguredDefaultModels } from '../settings/providers/default-models'; +import { + useAccountModelOptions, + useConfiguredDefaultModels, +} from '../settings/providers/default-models'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; import { useSidebarGroupCollapseStore } from '../sidebar/collapse-store'; import { useSidebarOrderStore } from '../sidebar/order-store'; @@ -241,6 +244,7 @@ function WorkbenchSessionSurface({ const currentPlan: CurrentPlan | null = selectCurrentPlan(conversation); const { mentionItems, onMentionQueryChange } = useFileMentionSource(); const newSessionDefaultModels = useConfiguredDefaultModels(); + const accountModels = useAccountModelOptions(); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; // Announce observation of the focused session so the daemon replays buffered per-session state @@ -651,6 +655,7 @@ function WorkbenchSessionSurface({ newSessionWorkspaceId={newSessionWorkspaceId} onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} newSessionDefaultModels={newSessionDefaultModels} + accountModels={accountModels} agentCatalogs={agentCatalogs} newSessionPreferredModels={newSessionPreferredModels} newSessionPreferredEfforts={newSessionPreferredEfforts} diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 8da5b65f..d81840f9 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -4,6 +4,7 @@ import type { AgentStartCatalog } from '@linkcode/schema'; import { WorkspaceIdSchema } from '@linkcode/schema'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { wait } from 'foxts/wait'; import { useState } from 'react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import type { NewSessionBranchPickerComponentProps } from '../new-session-branch-picker'; @@ -43,6 +44,7 @@ const PROJECT_WORKSPACE = { }; const RE_MODEL_DEFAULT = /modelDefault/; const RE_SONNET_5 = /Sonnet 5/; +const RE_DEEPSEEK_PRO = /DeepSeek V4 Pro/; const RE_CONFIGURED_CLAUDE_MODEL = /configured\/claude-model/; const RE_OPUS_4_8 = /Opus 4.8/; const RE_MEDIUM_EFFORT = /Medium/; @@ -284,6 +286,8 @@ describe('NewSessionSurface', () => { render( { render( { expect(submitted?.model).toBeUndefined(); }); + it("offers only the bound account's picked models, ignoring the curated table", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); + await user.click(await screen.findByRole('menuitem', { name: RE_DEEPSEEK_PRO })); + expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); + // The curated Anthropic table would otherwise supply these for claude-code. + expect(screen.queryByRole('menuitemradio', { name: 'Opus 5' })).toBeNull(); + }); + + it('refuses to send when an account is bound but no model is picked', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).not.toHaveBeenCalled(); + }); + it('submits a model only after the user explicitly selects it', async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( >> = { - 'claude-code': 'claude-sonnet-5', - codex: 'gpt-5.6-sol', - 'grok-build': 'grok-4.5', -}; - const CODEX_BASE_EFFORTS = ['low', 'medium', 'high', 'xhigh'] satisfies EffortLevel[]; /** diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index af94c9b5..1a7d2ef3 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -1,4 +1,10 @@ -import type { AgentKind, ContentBlock, EffortLevel, QuestionOutcome } from '@linkcode/schema'; +import type { + AgentKind, + AgentModelOption, + ContentBlock, + EffortLevel, + QuestionOutcome, +} from '@linkcode/schema'; import { useRef } from 'react'; import type { StickToBottomContext } from 'use-stick-to-bottom'; import { ArtifactHostActionsProvider } from '../chat/artifacts/context'; @@ -32,6 +38,9 @@ export interface ConversationSurfaceProps { composer: ConversationComposerController; agentKind?: AgentKind; agentLabel?: string; + /** The models picked on this agent's bound account — the only ones it may switch to. Absent means + * no account is bound, so the adapter catalog or the curated table supplies the choices instead. */ + accountModels?: AgentModelOption[]; /** Frontend capability stub used until attachment support is advertised by the session. */ attachmentsSupported?: boolean; cwd?: string; @@ -85,6 +94,7 @@ export function ConversationSurface({ conversation, composer, agentKind, + accountModels, agentLabel, attachmentsSupported = false, cwd, @@ -183,7 +193,9 @@ export function ConversationSurface({ approvalPolicy={conversation.approvalPolicy} currentModel={conversation.currentModel} currentEffort={conversation.currentEffort} - agentModels={conversation.availableModels} + // The account's picked set is the user's own answer to "which models may this run on", + // so it outranks both the adapter catalog and the curated table. + agentModels={accountModels ?? conversation.availableModels} directiveControls={composer.directiveControls} onSend={composer.onSend} // Scrolls at submit, not acceptance: the jump must feel tied to pressing send, and a diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index f73fca2f..9a4bdd63 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -1,6 +1,7 @@ import type { AgentInput, AgentKind, + AgentModelOption, AgentStartCatalog, BranchMode, BranchSelection, @@ -35,7 +36,7 @@ import { useTranslations } from 'use-intl'; import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; -import { AGENT_DEFAULT_MODELS, AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; +import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; @@ -89,6 +90,9 @@ export interface NewSessionSurfaceProps { /** Effective user-configured model defaults. `null` means they are still loading; when omitted, * built-in provider defaults fill missing kinds for standalone consumers. */ defaultModels?: Readonly>> | null; + /** The models each agent may run on, picked on its bound account. An agent absent here has no + * account bound and falls back to its adapter catalog or the curated table. */ + accountModels?: Readonly>> | null; /** Last accepted model per provider. Unlike configured defaults, this is an explicit override. */ preferredModels?: Readonly>>; /** Last accepted effort per provider. Missing kinds retain the provider default. */ @@ -144,6 +148,7 @@ export function NewSessionSurface({ attachmentSupport, agentCatalogs, defaultModels, + accountModels, preferredModels, preferredEfforts, preferredBranches, @@ -182,20 +187,21 @@ export function NewSessionSurface({ const localModel = selectedModels[provider]; const selectedModel = localModel === undefined ? (preferredModels?.[provider] ?? null) : localModel; - // The catalog default is what the agent's own config would start on, so it outranks the built-in - // guess but yields to anything the user expressed through LinkCode. + // The catalog default is what the agent's own config would start on, so it yields to anything the + // user expressed through LinkCode. Nothing guesses past it: an unresolved model blocks the send + // rather than starting a session on a model nobody chose. const displayedModel = selectedModel ?? - (defaultModels === null - ? null - : (defaultModels?.[provider] ?? - catalog?.defaultModel ?? - AGENT_DEFAULT_MODELS[provider] ?? - null)); + (defaultModels === null ? null : (defaultModels?.[provider] ?? catalog?.defaultModel ?? null)); const localEffort = selectedEfforts[provider]; const effort = localEffort === undefined ? (preferredEfforts?.[provider] ?? null) : localEffort; const dynamicModels = catalog && catalog.models.length > 0 ? catalog.models : null; - const modelOption = resolveModel(dynamicModels ?? AGENT_MODEL_OPTIONS[provider], displayedModel); + // The account's picked set is the user's own answer to which models this agent may run on, so it + // outranks the adapter catalog and the curated table both. An entry present here means an account + // is bound, which is also what makes a missing model fatal rather than the agent's own business. + const boundSet = accountModels?.[provider]; + const pickable = boundSet ?? dynamicModels ?? AGENT_MODEL_OPTIONS[provider]; + const modelOption = resolveModel(pickable, displayedModel); const effortLevels = modelOption?.effortLevels; const constrainedEffort = effortLevels === undefined || effortLevels.includes(effort ?? 'low') ? effort : null; @@ -358,11 +364,14 @@ export function NewSessionSurface({ mentionItems={mentionItems} onMentionQueryChange={(query) => onMentionQueryChange(selected?.cwd, query)} runtimeCues={runtimeCues} - sendBlocked={cue !== undefined} + // With an account bound, its set is the only model source, so an unresolved model would + // be refused by the daemon anyway — refuse here instead of after a round trip. An agent + // with no account bound still resolves its own, and must not be blocked. + sendBlocked={cue !== undefined || (boundSet !== undefined && displayedModel === null)} currentModeId={modeId} currentModel={displayedModel} currentEffort={displayedEffort} - agentModels={dynamicModels} + agentModels={pickable ?? null} approvalPolicy={approvalPolicy} approvalPolicyPlaceholder={t('permissionMode')} selectableProviders={SELECTABLE_PROVIDERS} diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 5b3dc521..08f3f4cd 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -1,5 +1,6 @@ import type { AgentKind, + AgentModelOption, BranchSelection, ContentBlock, EffortLevel, @@ -57,6 +58,9 @@ export interface ShellFrameProps agentCatalogs?: AgentStartCatalogs; /** Effective daemon-configured default models for new sessions; null while unresolved. */ newSessionDefaultModels: Readonly>> | null; + /** The models each agent may run on, picked on its bound account. An agent absent here has no + * account bound and keeps falling back to whatever its adapter or the curated table advertises. */ + accountModels: Readonly>> | null; /** Last model accepted by LinkCode per provider, submitted as a new-session override. */ newSessionPreferredModels: Readonly>>; /** Last effort accepted by LinkCode per provider for new sessions. */ @@ -131,6 +135,7 @@ export function ShellFrame({ attachmentSupport, agentCatalogs, newSessionDefaultModels, + accountModels, newSessionPreferredModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -222,6 +227,7 @@ export function ShellFrame({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} + accountModels={accountModels} preferredModels={newSessionPreferredModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} @@ -243,6 +249,7 @@ export function ShellFrame({ composer={conversationComposer} agentKind={active?.kind} agentLabel={active ? active.kind : undefined} + accountModels={active ? accountModels?.[active.kind] : undefined} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} disabled={!active || active.status === 'stopped'} isRunning={isRunning} From c7f4e64737d40eb2bc2bf3acf289e838a817ac1e Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 20:52:09 +0800 Subject: [PATCH 06/32] feat(schema,engine): record which account each session run resolved to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live session's account is fixed at spawn — credentials and base URL are injected once — so the client needs to know it to scope that session's model menu. Nothing recorded it: the resolved account existed only inside `applyProviderDefaults`. `accountConfigBundle` now echoes `accountId` into the resolved config, which `resolveAccount` already reads on the way in, so the same key serves both directions and a client can pin a session to one account. Each run lifts just that id into `SessionRun`, and `SessionInfo` reports the latest run's, mirroring how `historyId` already works — a rebind between runs is legitimate, so only the newest describes what a session is actually talking to. Only the id is persisted; the rest of `config` carries secrets. --- .../schema/src/model/session/record.ts | 6 ++ .../__tests__/engine-agent-catalog.test.ts | 1 + .../__tests__/engine-session-records.test.ts | 81 +++++++++++++++++++ .../src/__tests__/provider-config.test.ts | 8 +- .../host/engine/src/agent/provider-config.ts | 12 ++- .../engine/src/session/lifecycle-service.ts | 18 +++-- .../src/session/session-record-registry.ts | 15 +++- 7 files changed, 132 insertions(+), 9 deletions(-) diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index 0717fdfb..25c0769c 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -36,6 +36,9 @@ export type SessionOrigin = z.infer; * session accumulates runs; `historyId` is backfilled once the adapter reports it (session-ref). */ export const SessionRunSchema = z.object({ historyId: AgentHistoryIdSchema.optional(), + /** The account this run resolved to. Credentials and base URL are injected once at spawn, so the + * account is fixed for the run's lifetime and a later rebind does not move it. */ + accountId: z.string().min(1).optional(), startedAt: TimestampSchema, endedAt: TimestampSchema.optional(), }); @@ -76,6 +79,9 @@ export const SessionInfoSchema = z.object({ automation: SessionAutomationSchema.optional(), /** Latest run's provider-local history id — the transcript to read this session's past from. */ historyId: AgentHistoryIdSchema.optional(), + /** Latest run's account. The model menu of a live session scopes to it, because the account + * cannot change mid-session. */ + accountId: z.string().min(1).optional(), /** Provider-history operations supported by this session's adapter/runtime. */ historyCapabilities: AgentHistoryCapabilitiesSchema.optional(), }); diff --git a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts index d587fdec..d8489422 100644 --- a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts +++ b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts @@ -62,6 +62,7 @@ describe('engine agent catalog', () => { cwd: '/repo', model: 'provider/model', config: { + accountId: 'catalog-account', apiKey: 'catalog-key', baseUrl: 'https://catalog.example.test', protocol: 'openai-chat', diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 969706d8..b302b87b 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -11,6 +11,7 @@ import type { } from '@linkcode/schema'; import { MessageIdSchema, textBlock } from '@linkcode/schema'; import { describe, expect, it, vi } from 'vitest'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; import type { SessionStore } from '../session/session-store'; import { InMemorySessionStore } from '../session/session-store'; import { InMemoryWorkspaceStore } from '../workspace/workspace-store'; @@ -923,3 +924,83 @@ describe('engine session records', () => { expect(await inner.load()).toHaveLength(1); }); }); + +describe('session account attribution', () => { + function storeBoundTo(accountId: string, model: string): InMemoryProviderConfigStore { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { 'claude-code': { enabled: true, activeAccountId: accountId, model } }, + accounts: [ + { + id: accountId, + label: 'Bound', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-test' }, + models: [{ id: model }], + createdAt: 0, + }, + ], + }); + return providers; + } + + it("records the account a run resolved to and reports the latest run's", async () => { + const providers = storeBoundTo('acc_bound', 'claude-opus-5'); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + await h.inject({ kind: 'session.list', clientReqId: 'r2' }); + + expect(listedSessions(h.sent, 'r2')[0]?.accountId).toBe('acc_bound'); + // Persisted per run, so a restart still knows what the session is talking to. + expect((await store.load())[0].runs[0].accountId).toBe('acc_bound'); + }); + + it('honours an account the client pinned over the bound one', async () => { + const providers = storeBoundTo('acc_bound', 'claude-opus-5'); + const pool = providers.getAccounts(); + providers.update({ + accounts: [ + ...pool, + { + id: 'acc_pinned', + label: 'Pinned', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-other' }, + models: [{ id: 'claude-sonnet-5' }], + createdAt: 0, + }, + ], + }); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + providers, + ); + await h.engine.start(); + + // This is how picking a model that belongs to another account reaches the daemon. + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { + kind: 'claude-code', + cwd: '/repo', + model: 'claude-sonnet-5', + config: { accountId: 'acc_pinned' }, + }, + }); + await h.inject({ kind: 'session.list', clientReqId: 'r2' }); + + expect(listedSessions(h.sent, 'r2')[0]?.accountId).toBe('acc_pinned'); + }); +}); diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index a4d8ef4c..ce49777f 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -45,6 +45,7 @@ describe('applyProviderDefaults account pool', () => { it('injects the credential from the account bound via activeAccountId', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; expect(applyProviderDefaults(baseOpts, providers, [account]).options.config).toEqual({ + accountId: 'acc_1', apiKey: 'sk-acc', }); }); @@ -76,6 +77,7 @@ describe('applyProviderDefaults account pool', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'gw' } }; const merged = applyProviderDefaults(baseOpts, providers, [gateway]); expect(merged.options.config).toEqual({ + accountId: 'gw', authToken: 'or-tok', baseUrl: 'https://relay.example.com/v1', protocol: 'openai-responses', @@ -107,6 +109,7 @@ describe('applyProviderDefaults account pool', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oa' } }; // Codex overrides the base URL of its own Responses provider, so it carries no knownProvider. expect(applyProviderDefaults(baseOpts, providers, [openai]).options.config).toEqual({ + accountId: 'oa', apiKey: 'sk-oa', baseUrl: 'https://api.openai.com/v1', protocol: 'openai-responses', @@ -148,7 +151,10 @@ describe('applyProviderDefaults account pool', () => { createdAt: 0, }; const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oauth_1' } }; - expect(applyProviderDefaults(baseOpts, providers, [oauth]).options.config).toEqual({}); + // The account still resolves — it just contributes no secret, only its id. + expect(applyProviderDefaults(baseOpts, providers, [oauth]).options.config).toEqual({ + accountId: 'oauth_1', + }); }); }); diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 5a53678a..0613d02b 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -102,7 +102,9 @@ function accountConfigBundle( ): { bundle: Record } | { unavailable: BindingUnavailableReason } { const binding = resolveBinding(account, kind); if (binding.tier === 'unavailable') return { unavailable: binding.reason }; - const bundle: Record = {}; + // Echoed back so the caller can record which account a run actually resolved to; `resolveAccount` + // reads the same key on the way in, which is how a client pins a session to one account. + const bundle: Record = { accountId: account.id }; const { credential, extraEnv } = account; if (credential.type === 'api-key') bundle.apiKey = credential.key; else if (credential.type === 'auth-token') bundle.authToken = credential.token; @@ -113,6 +115,14 @@ function accountConfigBundle( return { bundle }; } +/** The account a resolved `StartOptions` names — written by `accountConfigBundle`, or pinned by a + * client that picked a model belonging to a specific account. Callers record it per run; the rest of + * `config` carries secrets and must never be persisted. */ +export function resolvedAccountId(opts: StartOptions): string | undefined { + const id = opts.config?.accountId; + return typeof id === 'string' && id.length > 0 ? id : undefined; +} + export interface AppliedProviderDefaults { readonly options: StartOptions; /** Why the bound account cannot back this agent. A session must refuse to start rather than diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 67eb6d78..0ed0ad09 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -13,6 +13,7 @@ import type { } from '@linkcode/schema'; import { Effect, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { resolvedAccountId } from '../agent/provider-config'; import type { SessionDriver } from '../automation'; import type { EngineFailure } from '../failure'; import { RequestError, toOperationFailure } from '../failure'; @@ -101,7 +102,7 @@ export class SessionLifecycleService { createdVia: resolved.createdVia, createdAt: now, updatedAt: now, - runs: [{ startedAt: now }], + runs: [{ startedAt: now, ...accountOfRun(resolved) }], }; yield* sessions.startLive( replyTo, @@ -176,7 +177,7 @@ export class SessionLifecycleService { origin: { type: 'imported', historyId, importedAt: now }, createdAt: now, updatedAt: now, - runs: [{ historyId, startedAt: now }], + runs: [{ historyId, startedAt: now, ...accountOfRun(startOptions) }], }; yield* sessions.startLive( replyTo, @@ -252,7 +253,7 @@ export class SessionLifecycleService { liveCursor.contentFingerprint, ) : branchCursor; - records.beginRun(sourceSessionId); + records.beginRun(sourceSessionId, resolvedAccountId(startOptions)); yield* sessions.startLive( replyTo, source, @@ -314,7 +315,7 @@ export class SessionLifecycleService { } else if (record.cwd) { yield* workspaceTouch(workspaces, record.cwd); } - record.runs.push({ historyId, startedAt: Date.now() }); + record.runs.push({ historyId, startedAt: Date.now(), ...accountOfRun(startOptions) }); yield* sessions.startLive( replyTo, record, @@ -353,7 +354,7 @@ export class SessionLifecycleService { automation: options.automation, createdAt: now, updatedAt: now, - runs: [{ startedAt: now }], + runs: [{ startedAt: now, ...accountOfRun(startOptions) }], }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); yield* sessions.startLive(undefined, record, (adapter) => @@ -425,3 +426,10 @@ function workspaceRegisterWorktree( }), }); } + +/** The run's account, spread into a `SessionRun` so an unresolved one stays absent rather than + * writing `undefined` into the record. */ +function accountOfRun(opts: StartOptions): { accountId?: string } { + const accountId = resolvedAccountId(opts); + return accountId === undefined ? {} : { accountId }; +} diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 39fa3c9f..b3dad8cf 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -82,6 +82,7 @@ export class SessionRecordRegistry { createdVia: record.createdVia, automation: record.automation, historyId: latestHistoryId(record), + accountId: latestAccountId(record), })); } @@ -137,10 +138,10 @@ export class SessionRecordRegistry { this.persist(record); } - beginRun(sessionId: SessionId): void { + beginRun(sessionId: SessionId, accountId?: string): void { const record = this.records.get(sessionId); if (!record) return; - record.runs.push({ startedAt: Date.now() }); + record.runs.push({ startedAt: Date.now(), ...(accountId !== undefined && { accountId }) }); this.persist(record); } @@ -210,6 +211,16 @@ function storeFailure(operation: string, publicMessage: string, cause: unknown): return new OperationError({ subsystem: 'store', operation, publicMessage, cause }); } +/** The account the newest run resolved to. Older runs may name a different one — a rebind between + * runs is legitimate — so only the latest describes what a live session is actually talking to. */ +function latestAccountId(record: SessionRecord): string | undefined { + for (let index = record.runs.length - 1; index >= 0; index -= 1) { + const accountId = record.runs[index].accountId; + if (accountId !== undefined) return accountId; + } + return undefined; +} + function latestHistoryId(record: SessionRecord): AgentHistoryId | undefined { for (let index = record.runs.length - 1; index >= 0; index -= 1) { const historyId = record.runs[index].historyId; From 1608dc43f636de138dc2e6f4d32742c1d40e6381 Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 21:13:33 +0800 Subject: [PATCH 07/32] feat(workbench,ui): pick models across every account an agent can bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-session menu now spans every account `resolveBinding` accepts for the agent, grouped per account, so choosing a model also chooses which account serves it. One agent reaches several providers without a trip through Settings — previously the menu showed only the single bound account's set, which made it offer less than the curated table it replaced. A live session's menu stays scoped to its own account: credentials and base URL are injected at spawn, so offering another account's models would advertise a switch the adapter cannot make. Model identity becomes (account, model). Two accounts legitimately serve the same id — a direct DeepSeek key and an OpenRouter one both list `deepseek-v4-pro` — and the menu previously used the bare id as both its React key and its radio value, which would collapse the two into one unselectable row. `modelChoiceKey` keys them apart, the pick hands back the whole entry rather than a string to re-parse, and `resolveModel` takes an account tiebreak so the trigger label names the right one. The chosen account rides `config.accountId`, which `resolveAccount` already honours ahead of the bound one. --- .../src/renderer/src/shell/desktop-shell.tsx | 4 +- .../__tests__/default-models.test.ts | 98 +++++++++++++------ .../src/settings/providers/default-models.ts | 71 ++++++++++---- .../src/surface/use-workbench-sessions.ts | 10 +- .../workbench/src/surface/workbench.tsx | 14 ++- .../__tests__/new-session-surface.test.tsx | 54 ++++++++++ .../presentation/ui/src/shell/agent-models.ts | 23 ++++- .../ui/src/shell/composer-controls.tsx | 38 +++++-- .../presentation/ui/src/shell/composer.tsx | 12 ++- .../ui/src/shell/conversation-surface.tsx | 26 ++--- .../ui/src/shell/new-session-surface.tsx | 42 +++++--- .../presentation/ui/src/shell/shell-frame.tsx | 11 ++- 12 files changed, 310 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index e266b09d..7c69e7af 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -81,6 +81,7 @@ export function DesktopShell({ agentCatalogs, newSessionDefaultModels, accountModels, + sessionModels, newSessionPreferredModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -455,7 +456,8 @@ export function DesktopShell({ composer={conversationComposer} agentKind={active?.kind} agentLabel={agentLabel} - accountModels={active ? accountModels?.[active.kind] : undefined} + accountModels={sessionModels} + accountId={active?.accountId} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} diff --git a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts index 49a62362..058421ff 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts @@ -2,10 +2,12 @@ import type { Accounts, ProvidersConfig } from '@linkcode/schema'; import { getProviderConfig } from '@linkcode/sdk'; +import { modelChoiceKey } from '@linkcode/ui'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { accountModelOptions, + accountModelOptionsFor, configuredDefaultModels, useAccountModelOptions, useConfiguredDefaultModels, @@ -53,42 +55,82 @@ describe('configuredDefaultModels', () => { }); }); +const anthropicAccount = { + id: 'acc_anthropic', + label: 'Anthropic', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'k' }, + models: [{ id: 'claude-opus-5', label: 'Opus 5' }], + createdAt: 0, +} satisfies Accounts[number]; + +const deepseekAccount = { + id: 'acc_deepseek', + label: 'DeepSeek', + service: 'deepseek', + credential: { type: 'api-key', key: 'k' }, + models: [{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, { id: 'deepseek-v4-flash' }], + createdAt: 0, +} satisfies Accounts[number]; + describe('accountModelOptions', () => { - it('distinguishes a bound agent with nothing picked from one with no account at all', () => { - const providers = { - codex: { enabled: true, activeAccountId: 'acc_1' }, - 'claude-code': { enabled: true, activeAccountId: 'acc_2' }, - // No account bound: absent, so its pickers fall through and its sends are not blocked. - opencode: { enabled: true }, - } satisfies ProvidersConfig; - const accounts = [ + it('spans every account that can back the agent, tagged with the account it came from', () => { + const options = accountModelOptions([anthropicAccount, deepseekAccount]); + + // claude-code speaks both: Anthropic natively, DeepSeek through its Anthropic-shaped endpoint. + expect(options['claude-code']).toEqual([ { - id: 'acc_1', - label: 'Picked', - credential: { type: 'api-key', key: 'k' }, - models: [{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, { id: 'deepseek-v4-flash' }], - createdAt: 0, + id: 'claude-opus-5', + label: 'Opus 5', + description: 'Anthropic', + accountId: 'acc_anthropic', }, - { id: 'acc_2', label: 'Unpicked', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, - ] satisfies Accounts; - - expect(accountModelOptions(providers, accounts)).toEqual({ - codex: [ - { id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, + { + id: 'deepseek-v4-pro', + label: 'DeepSeek V4 Pro', + description: 'DeepSeek', + accountId: 'acc_deepseek', + }, + { + id: 'deepseek-v4-flash', // A relay ships bare ids; the id doubles as the label rather than rendering blank. - { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' }, - ], - 'claude-code': [], - }); + label: 'deepseek-v4-flash', + description: 'DeepSeek', + accountId: 'acc_deepseek', + }, + ]); }); - it('stays unresolved until both sources have loaded', () => { - const { result, rerender } = renderHook(() => useAccountModelOptions()); + it('omits an agent no account can back, and keeps a bindable-but-unpicked one empty', () => { + // grok-build only accepts an xAI account, so neither of these can back it. + expect(accountModelOptions([anthropicAccount, deepseekAccount])['grok-build']).toBeUndefined(); + // Bindable with nothing ticked: present-and-empty, which is what blocks sending. + expect( + accountModelOptions([{ ...anthropicAccount, models: undefined }])['claude-code'], + ).toEqual([]); + }); - expect(result.current).toBeNull(); + it('keeps same-id models from two accounts as separate, identifiable entries', () => { + const shared = { ...anthropicAccount, id: 'acc_other', label: 'Work key' }; + const options = accountModelOptions([anthropicAccount, shared])['claude-code'] ?? []; + + expect(options).toHaveLength(2); + expect(new Set(options.map(modelChoiceKey)).size).toBe(2); + }); + + it('scopes to one account for a live session, whose account cannot change', () => { + const accounts = [anthropicAccount, deepseekAccount]; + expect(accountModelOptionsFor(accounts, 'acc_deepseek')?.map(({ id }) => id)).toEqual([ + 'deepseek-v4-pro', + 'deepseek-v4-flash', + ]); + expect(accountModelOptionsFor(accounts, 'gone')).toBeUndefined(); + expect(accountModelOptionsFor(accounts, undefined)).toBeUndefined(); + }); + + it('stays unresolved until the account pool has loaded', () => { + const { result, rerender } = renderHook(() => useAccountModelOptions()); - providersData = {}; - rerender(); expect(result.current).toBeNull(); accountsData = []; diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts index a8875e3c..29d16b32 100644 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ b/packages/client/workbench/src/settings/providers/default-models.ts @@ -1,6 +1,8 @@ -import type { Accounts, AgentKind, AgentModelOption, ProvidersConfig } from '@linkcode/schema'; +import { resolveBinding } from '@linkcode/providers'; +import type { Account, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; import { AgentKindSchema } from '@linkcode/schema'; import { getAccounts, getProviderConfig } from '@linkcode/sdk'; +import type { ModelOption } from '@linkcode/ui'; import { useData } from '../../runtime/tayori'; /** The model each agent currently runs on, as session start resolves it: the agent's persisted pick. @@ -25,33 +27,64 @@ export function useConfiguredDefaultModels(): Partial> } /** - * The models each agent may be switched to: the set picked on its bound account, and nothing else. + * Every model a new session of this agent could run on: the picked sets of *all* accounts the agent + * can bind, not just the one bound now. Choosing a model therefore also chooses its account, which + * is what lets one agent reach several providers without a trip through Settings. + * + * `description` carries the account label so `groupModelsByProvider` renders one submenu per + * account, and `accountId` rides along so the pick names the account it came from — two accounts + * legitimately serve the same model id. * * Present-but-empty and absent mean different things, and callers rely on the difference. An entry - * exists for every agent with an account bound, so `[]` says "bound, nothing picked yet" and blocks - * sends the way the daemon does. Absent says "no account bound", where the agent still resolves its - * own model — so its pickers fall through to the adapter catalog or the curated table, and nothing - * blocks. + * exists whenever at least one account can back the agent, so `[]` says "bindable, nothing picked + * yet" and blocks sends the way the daemon does. Absent says "no account can back this agent", where + * it still resolves its own model — pickers fall through to the adapter catalog or the curated table, + * and nothing blocks. */ export function accountModelOptions( - providers: ProvidersConfig | undefined, accounts: Accounts | undefined, -): Partial> { - const options: Partial> = {}; +): Partial> { + const options: Partial> = {}; for (const kind of AgentKindSchema.options) { - const accountId = providers?.[kind]?.activeAccountId; - if (accountId === undefined) continue; - const models = accounts?.find((candidate) => candidate.id === accountId)?.models ?? []; - options[kind] = models.map(({ id, label }) => ({ id, label: label ?? id })); + const bindable = (accounts ?? []).filter( + (account) => resolveBinding(account, kind).tier !== 'unavailable', + ); + if (bindable.length === 0) continue; + options[kind] = bindable.flatMap((account) => modelOptionsOf(account)); } return options; } -/** `null` until both daemon-owned sources have loaded, so a picker never briefly offers a set the - * account does not actually have. */ -export function useAccountModelOptions(): Partial> | null { - const { data: providers } = useData(getProviderConfig, {}); +/** One account's picked models. A live session's menu uses this: its account is fixed at spawn, so + * offering another account's models would advertise a switch the adapter cannot make. */ +export function accountModelOptionsFor( + accounts: Accounts | undefined, + accountId: string | undefined, +): ModelOption[] | undefined { + if (accountId === undefined) return undefined; + const account = accounts?.find((candidate) => candidate.id === accountId); + return account === undefined ? undefined : modelOptionsOf(account); +} + +function modelOptionsOf(account: Account): ModelOption[] { + return (account.models ?? []).map(({ id, label }) => ({ + id, + label: label ?? id, + description: account.label, + accountId: account.id, + })); +} + +/** `null` until the account pool has loaded, so a picker never briefly offers a set that is not + * actually available. */ +export function useAccountModelOptions(): Partial> | null { + const { data: accounts } = useData(getAccounts, {}); + if (accounts === undefined) return null; + return accountModelOptions(accounts); +} + +/** The models a live session may switch between — its own account's, and only those. */ +export function useSessionModelOptions(accountId: string | undefined): ModelOption[] | undefined { const { data: accounts } = useData(getAccounts, {}); - if (providers === undefined || accounts === undefined) return null; - return accountModelOptions(providers, accounts); + return accountModelOptionsFor(accounts, accountId); } diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index 6cbf72e7..01664162 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -49,6 +49,8 @@ export interface WorkbenchSessions { kind: AgentKind; cwd: string; model?: string; + /** Pins the session to the account the picked model belongs to. */ + accountId?: string; effort?: EffortLevel; approvalPolicyId?: string; modeId?: SessionModeId; @@ -194,6 +196,8 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench kind: AgentKind; cwd: string; model?: string; + /** Pins the session to the account the picked model belongs to. */ + accountId?: string; effort?: EffortLevel; approvalPolicyId?: string; modeId?: SessionModeId; @@ -203,11 +207,15 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench // Captured now: by resolve time the surface still shows the draft, and the recorded // transition should be draft → new thread. const from = currentLocation; + // Pins the session to the account the picked model belongs to. The daemon merges its own + // credential bundle over this, so only the account choice travels from the client. + const { accountId, ...rest } = opts; + const startOptions = accountId === undefined ? rest : { ...rest, config: { accountId } }; // Rejections propagate to the caller (the new-session page stays up); onError above still // reports them via the error banner. let sessionId: SessionId; try { - const result = await createMutation.trigger({ opts }); + const result = await createMutation.trigger({ opts: startOptions }); sessionId = result.sessionId; showMcpWarnings(result.mcpWarnings, tMcpWarnings); } catch (error) { diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 92f900e8..2f87ef02 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -31,6 +31,7 @@ import type { ComposerDirectiveControls, ConversationComposerController, CurrentPlan, + ModelOption, NewSessionDraft, NewSessionSubmission, PermissionDecision, @@ -60,6 +61,7 @@ import { useMutation } from '../runtime/tayori'; import { useAccountModelOptions, useConfiguredDefaultModels, + useSessionModelOptions, } from '../settings/providers/default-models'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; import { useSidebarGroupCollapseStore } from '../sidebar/collapse-store'; @@ -245,6 +247,8 @@ function WorkbenchSessionSurface({ const { mentionItems, onMentionQueryChange } = useFileMentionSource(); const newSessionDefaultModels = useConfiguredDefaultModels(); const accountModels = useAccountModelOptions(); + // Scoped to the active session's own account: it was fixed at spawn and cannot change. + const sessionModels = useSessionModelOptions(active?.accountId); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; // Announce observation of the focused session so the daemon replays buffered per-session state @@ -378,6 +382,7 @@ function WorkbenchSessionSurface({ kind: submission.kind, cwd: submission.cwd, model: submission.model, + accountId: submission.accountId, effort: submission.effort ?? undefined, approvalPolicyId: submission.approvalPolicyId, modeId: submission.modeId, @@ -448,14 +453,16 @@ function WorkbenchSessionSurface({ .then(noop); } - function handleModelChange(model: string): Promise { + function handleModelChange(model: ModelOption): Promise { if (!sessions.activeId) return Promise.reject(new Error('No active session')); onClearError(); // Let the rejection propagate: the composer awaits it to decide whether to reflect the pick. // onError (wired into modelMutation above) still reports the failure via the error banner. const provider = active?.kind; - return modelMutation.trigger({ sessionId: sessions.activeId, model }).then(() => { - if (provider) rememberSelection(provider, { model }); + return modelMutation.trigger({ sessionId: sessions.activeId, model: model.id }).then(() => { + // The account is not part of a live switch — it is fixed at spawn, and this menu only ever + // offers the session's own account's models. + if (provider) rememberSelection(provider, { model: model.id }); }); } @@ -656,6 +663,7 @@ function WorkbenchSessionSurface({ onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} newSessionDefaultModels={newSessionDefaultModels} accountModels={accountModels} + sessionModels={sessionModels} agentCatalogs={agentCatalogs} newSessionPreferredModels={newSessionPreferredModels} newSessionPreferredEfforts={newSessionPreferredEfforts} diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index d81840f9..4f1efa8d 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -58,6 +58,8 @@ const RE_LOW_EFFORT = /Low/; const RE_GPT_56_SOL = /GPT-5.6-Sol/; const RE_PROVIDER_CLAUDE_CODE_MENU = /provider.*Claude Code/; const RE_MODEL_SONNET_5_MENU = /model.*Sonnet 5/; +const RE_OPUS_5 = /Opus 5/; +const RE_MODEL_MENU = /^model/; const RE_MODEL_GPT_56_SOL_MENU = /model.*GPT-5\.6-Sol/; const RE_MODEL_DEFAULT_MENU = /model.*modelDefault/; const RE_MODEL_PI_SONNET_MENU = /model.*Pi Sonnet/; @@ -812,6 +814,58 @@ describe('NewSessionSurface', () => { expect(submitted?.model).toBeUndefined(); }); + it('starts on the account the picked model belongs to, not the one bound', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + // Two accounts → one submenu each. Submenu triggers are keyboard-driven here: base-ui leaves + // them `pointer-events: none` in jsdom. + await user.click(screen.getByRole('button', { name: RE_OPUS_5 })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + (await screen.findByRole('menuitem', { name: 'DeepSeek' })).focus(); + await user.keyboard('{ArrowRight}'); + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })); + typeInComposer('hello'); + await pressInComposer('Enter'); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ model: 'deepseek-v4-pro', accountId: 'acc_ds' }), + ), + ); + }); + it("offers only the bound account's picked models, ignoring the curated table", async () => { const user = userEvent.setup(); render( diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/shell/agent-models.ts index 48f3f790..0cf6ebf9 100644 --- a/packages/presentation/ui/src/shell/agent-models.ts +++ b/packages/presentation/ui/src/shell/agent-models.ts @@ -3,6 +3,9 @@ import type { AgentKind, EffortLevel } from '@linkcode/schema'; export interface ModelOption { id: string; label: string; + /** The account offering this model, when the list spans several. Two accounts can serve the same + * `id`, so this is what makes an entry identifiable — see {@link modelChoiceKey}. */ + accountId?: string; /** Secondary line in the picker (adapter-advertised catalogs carry the provider name here, * disambiguating same-named models across providers); static table entries omit it. */ description?: string; @@ -18,6 +21,15 @@ export interface ModelProviderGroups { groups: Array<{ label: string; options: ModelOption[] }>; } +/** + * Identity of one entry in a model menu. The model id alone is not unique once a list spans + * accounts — a direct DeepSeek account and an OpenRouter one both serve `deepseek-v4-pro` — and + * reusing it as a React key or a radio value collapses the two into one unselectable row. + */ +export function modelChoiceKey(option: ModelOption): string { + return `${option.accountId ?? ''}:${option.id}`; +} + /** Group a catalog by its provider subtitle (`description`, per the adapter convention above), * preserving catalog order within groups and first-appearance order across them. Returns null * below two distinct providers — a single-provider list reads better flat. */ @@ -44,15 +56,20 @@ export function groupModelsByProvider( /** Resolve a reflected model id (from `model-update`) to its catalog entry. The daemon emits the * *served* id, which may be a pinned snapshot of an alias (e.g. `claude-haiku-4-5-20251001`); - * prefix-match only after an exact match fails so `gpt-5.4-mini` never mis-resolves to `gpt-5.4`. */ + * prefix-match only after an exact match fails so `gpt-5.4-mini` never mis-resolves to `gpt-5.4`. + * `accountId` narrows first where known, so a list spanning accounts labels the right entry. */ export function resolveModel( options: readonly ModelOption[] | undefined, id: string | null, + accountId?: string, ): ModelOption | undefined { if (id === null) return undefined; + const scoped = + accountId === undefined ? options : options?.filter((option) => option.accountId === accountId); + const candidates = scoped?.length ? scoped : options; return ( - options?.find((option) => option.id === id) ?? - options?.find((option) => id.startsWith(`${option.id}-`)) + candidates?.find((option) => option.id === id) ?? + candidates?.find((option) => id.startsWith(`${option.id}-`)) ); } diff --git a/packages/presentation/ui/src/shell/composer-controls.tsx b/packages/presentation/ui/src/shell/composer-controls.tsx index 7860235a..1b8f4b18 100644 --- a/packages/presentation/ui/src/shell/composer-controls.tsx +++ b/packages/presentation/ui/src/shell/composer-controls.tsx @@ -30,7 +30,7 @@ import { AGENT_LABELS, AgentIcon } from '../chat/agent-icon'; import type { EffortOption } from './agent-efforts'; import { EFFORT_OPTIONS_BY_ID } from './agent-efforts'; import type { ModelOption } from './agent-models'; -import { groupModelsByProvider, resolveModel } from './agent-models'; +import { groupModelsByProvider, modelChoiceKey, resolveModel } from './agent-models'; import type { AgentRuntimeCue, AgentRuntimeCues } from './agent-onboarding-card'; // Linear lookup: the policy/effort lists are a handful of entries at most. @@ -219,6 +219,7 @@ export function ModelSelectorMenu({ modelOptions, effortOptions, selectedModelId, + selectedAccountId, selectedEffortId, onSelectModel, onSelectEffort, @@ -235,8 +236,11 @@ export function ModelSelectorMenu({ modelOptions?: ModelOption[]; effortOptions?: EffortOption[]; selectedModelId: string | null; + /** Disambiguates the selection when the list spans accounts serving the same model id. */ + selectedAccountId?: string; selectedEffortId: EffortLevel | null; - onSelectModel: (model: string) => void; + /** Carries the whole entry: a cross-account list needs the account alongside the id. */ + onSelectModel: (model: ModelOption) => void; onSelectEffort: (effort: EffortLevel) => void; /** Draft-only escape hatch back to the provider/configured model default. */ onResetModel?: () => void; @@ -245,7 +249,7 @@ export function ModelSelectorMenu({ onSelectProvider?: (provider: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.composer'); - const selectedModel = resolveModel(modelOptions, selectedModelId); + const selectedModel = resolveModel(modelOptions, selectedModelId, selectedAccountId); const providerGroups = groupModelsByProvider(modelOptions); const selectedEffort = optionById(effortOptions, selectedEffortId) ?? @@ -327,12 +331,22 @@ export function ModelSelectorMenu({ onSelectModel(String(value))} + value={selectedModel === undefined ? '' : modelChoiceKey(selectedModel)} + onValueChange={(value) => { + // Keyed by (account, model), so map back to the entry rather than parsing it. + const picked = modelOptions?.find( + (option) => modelChoiceKey(option) === String(value), + ); + if (picked) onSelectModel(picked); + }} > {providerGroups === null ? ( modelOptions?.map((option) => ( - + {option.label} {option.description ? ( @@ -346,7 +360,11 @@ export function ModelSelectorMenu({ ) : ( <> {providerGroups.ungrouped.map((option) => ( - + {option.label} ))} @@ -357,7 +375,11 @@ export function ModelSelectorMenu({ {group.label} {group.options.map((option) => ( - + {option.label} ))} diff --git a/packages/presentation/ui/src/shell/composer.tsx b/packages/presentation/ui/src/shell/composer.tsx index 7ead16cf..aa0d8d2f 100644 --- a/packages/presentation/ui/src/shell/composer.tsx +++ b/packages/presentation/ui/src/shell/composer.tsx @@ -32,6 +32,7 @@ import { } from '../chat/prompt-input'; import { cn } from '../lib/cn'; import { effortOptionsForModel } from './agent-efforts'; +import type { ModelOption } from './agent-models'; import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import type { ComposerAttachment } from './composer-attachments'; @@ -174,7 +175,10 @@ export interface ComposerProps { onApprovalPolicyChange?: (policyId: string) => Promise; /** Sends the model switch (`set-model`); the active model is reflected from `model-update`, not * locally — a rejected switch keeps the previous model. */ - onModelChange?: (model: string) => Promise; + /** Receives the whole entry, so a cross-account pick names the account it belongs to. */ + onModelChange?: (model: ModelOption) => Promise; + /** The account the current model belongs to; disambiguates a list spanning several. */ + currentAccountId?: string; /** Sends the reasoning-effort switch (`set-effort`); reflected from `effort-update`, same contract. */ onEffortChange?: (effort: EffortLevel) => Promise; /** Clears a draft's explicit model override. Omitted for live sessions. */ @@ -228,6 +232,7 @@ export function Composer({ onModeChange, onApprovalPolicyChange, onModelChange, + currentAccountId, onEffortChange, onResetModel, onResetEffort, @@ -833,8 +838,8 @@ export function Composer({ // Server-reflected like mode/policy: the pick shows once `model-update` / `effort-update` echoes // it back; a rejected switch leaves the previous value and the failure lands in the error banner. - function selectModel(modelId: string): void { - void onModelChange?.(modelId).catch(noop); + function selectModel(model: ModelOption): void { + void onModelChange?.(model).catch(noop); } function selectEffort(effort: EffortLevel): void { @@ -1024,6 +1029,7 @@ export function Composer({ provider={agentKind} runtimeCues={runtimeCues} selectableProviders={selectableProviders} + selectedAccountId={currentAccountId} selectedEffortId={currentEffort ?? null} selectedModelId={currentModel ?? null} onResetEffort={onResetEffort} diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 1a7d2ef3..1c54021e 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -1,10 +1,4 @@ -import type { - AgentKind, - AgentModelOption, - ContentBlock, - EffortLevel, - QuestionOutcome, -} from '@linkcode/schema'; +import type { AgentKind, ContentBlock, EffortLevel, QuestionOutcome } from '@linkcode/schema'; import { useRef } from 'react'; import type { StickToBottomContext } from 'use-stick-to-bottom'; import { ArtifactHostActionsProvider } from '../chat/artifacts/context'; @@ -13,6 +7,7 @@ import { selectPendingPromptItems } from '../chat/conversation-prompts'; import { ConversationView } from '../chat/conversation-view'; import type { ConversationViewModel, PromptEditState } from '../chat/types'; import { cn } from '../lib/cn'; +import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './composer'; @@ -29,7 +24,7 @@ export interface ConversationComposerController { directiveControls: ComposerDirectiveControls; onModeChange?: (modeId: string) => Promise; onApprovalPolicyChange?: (policyId: string) => Promise; - onModelChange?: (model: string) => Promise; + onModelChange?: (model: ModelOption) => Promise; onEffortChange?: (effort: EffortLevel) => Promise; } @@ -38,9 +33,12 @@ export interface ConversationSurfaceProps { composer: ConversationComposerController; agentKind?: AgentKind; agentLabel?: string; - /** The models picked on this agent's bound account — the only ones it may switch to. Absent means - * no account is bound, so the adapter catalog or the curated table supplies the choices instead. */ - accountModels?: AgentModelOption[]; + /** The models picked on *this session's* account — the only ones it may switch to, because its + * account is fixed at spawn. Absent means no account backs it, so the adapter catalog or the + * curated table supplies the choices instead. */ + accountModels?: ModelOption[]; + /** The session's account, so a reflected model id resolves against the right entry. */ + accountId?: string; /** Frontend capability stub used until attachment support is advertised by the session. */ attachmentsSupported?: boolean; cwd?: string; @@ -95,6 +93,7 @@ export function ConversationSurface({ composer, agentKind, accountModels, + accountId, agentLabel, attachmentsSupported = false, cwd, @@ -193,9 +192,10 @@ export function ConversationSurface({ approvalPolicy={conversation.approvalPolicy} currentModel={conversation.currentModel} currentEffort={conversation.currentEffort} - // The account's picked set is the user's own answer to "which models may this run on", - // so it outranks both the adapter catalog and the curated table. + // The session account's picked set is the user's own answer to "which models may this run + // on", so it outranks both the adapter catalog and the curated table. agentModels={accountModels ?? conversation.availableModels} + currentAccountId={accountId} directiveControls={composer.directiveControls} onSend={composer.onSend} // Scrolls at submit, not acceptance: the jump must feel tied to pressing send, and a diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 9a4bdd63..52a6a995 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -1,7 +1,6 @@ import type { AgentInput, AgentKind, - AgentModelOption, AgentStartCatalog, BranchMode, BranchSelection, @@ -36,6 +35,7 @@ import { useTranslations } from 'use-intl'; import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; +import type { ModelOption } from './agent-models'; import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; @@ -58,6 +58,8 @@ export interface NewSessionSubmission { workspaceId: WorkspaceId; /** Absent falls back to the agent's persisted pick; there is no "return to default" tier. */ model?: string; + /** The account the picked model belongs to, pinning the session to it. */ + accountId?: string; /** Null explicitly returns this provider to its default effort. */ effort?: EffortLevel | null; approvalPolicyId?: string; @@ -92,7 +94,7 @@ export interface NewSessionSurfaceProps { defaultModels?: Readonly>> | null; /** The models each agent may run on, picked on its bound account. An agent absent here has no * account bound and falls back to its adapter catalog or the curated table. */ - accountModels?: Readonly>> | null; + accountModels?: Readonly>> | null; /** Last accepted model per provider. Unlike configured defaults, this is an explicit override. */ preferredModels?: Readonly>>; /** Last accepted effort per provider. Missing kinds retain the provider default. */ @@ -168,6 +170,10 @@ export function NewSessionSurface({ const [selectedModels, setSelectedModels] = useState>>( {}, ); + /** The account each picked model belongs to; null once the pick is reset. */ + const [selectedAccounts, setSelectedAccounts] = useState< + Partial> + >({}); const [selectedEfforts, setSelectedEfforts] = useState< Partial> >({}); @@ -196,12 +202,15 @@ export function NewSessionSurface({ const localEffort = selectedEfforts[provider]; const effort = localEffort === undefined ? (preferredEfforts?.[provider] ?? null) : localEffort; const dynamicModels = catalog && catalog.models.length > 0 ? catalog.models : null; - // The account's picked set is the user's own answer to which models this agent may run on, so it - // outranks the adapter catalog and the curated table both. An entry present here means an account - // is bound, which is also what makes a missing model fatal rather than the agent's own business. - const boundSet = accountModels?.[provider]; - const pickable = boundSet ?? dynamicModels ?? AGENT_MODEL_OPTIONS[provider]; - const modelOption = resolveModel(pickable, displayedModel); + // Every account that can back this agent contributes, so a pick chooses the account too. The set + // outranks the adapter catalog and the curated table both, and an entry present here means at + // least one account is bindable — which is what makes a missing model fatal rather than the + // agent's own business. + const bindableSet = accountModels?.[provider]; + const pickable = bindableSet ?? dynamicModels ?? AGENT_MODEL_OPTIONS[provider]; + const localAccount = selectedAccounts[provider]; + const selectedAccountId = localAccount ?? undefined; + const modelOption = resolveModel(pickable, displayedModel, selectedAccountId); const effortLevels = modelOption?.effortLevels; const constrainedEffort = effortLevels === undefined || effortLevels.includes(effort ?? 'low') ? effort : null; @@ -238,6 +247,10 @@ export function NewSessionSurface({ cwd: selected.cwd, workspaceId: selected.workspaceId, model: localModel === null ? undefined : (selectedModel ?? undefined), + // Pins the session to the account whose entry was picked; without it the daemon would fall + // back to whichever account happens to be bound. + ...(localModel !== null && + modelOption?.accountId !== undefined && { accountId: modelOption.accountId }), ...(localEffort === null ? { effort: null } : constrainedEffort !== null && { effort: constrainedEffort }), @@ -268,8 +281,11 @@ export function NewSessionSurface({ return Promise.resolve(); } - function handleModelChange(nextModel: string): Promise { - setSelectedModels((current) => ({ ...current, [provider]: nextModel })); + function handleModelChange(next: ModelOption): Promise { + setSelectedModels((current) => ({ ...current, [provider]: next.id })); + // The account is part of the pick: two accounts can serve the same id, and the session must + // start on the one whose entry was chosen. + setSelectedAccounts((current) => ({ ...current, [provider]: next.accountId ?? null })); return Promise.resolve(); } @@ -280,6 +296,7 @@ export function NewSessionSurface({ function handleResetModel(): void { setSelectedModels((current) => ({ ...current, [provider]: null })); + setSelectedAccounts((current) => ({ ...current, [provider]: null })); } function handleResetEffort(): void { @@ -367,11 +384,14 @@ export function NewSessionSurface({ // With an account bound, its set is the only model source, so an unresolved model would // be refused by the daemon anyway — refuse here instead of after a round trip. An agent // with no account bound still resolves its own, and must not be blocked. - sendBlocked={cue !== undefined || (boundSet !== undefined && displayedModel === null)} + sendBlocked={ + cue !== undefined || (bindableSet !== undefined && displayedModel === null) + } currentModeId={modeId} currentModel={displayedModel} currentEffort={displayedEffort} agentModels={pickable ?? null} + currentAccountId={selectedAccountId} approvalPolicy={approvalPolicy} approvalPolicyPlaceholder={t('permissionMode')} selectableProviders={SELECTABLE_PROVIDERS} diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 08f3f4cd..3340ec68 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -1,6 +1,5 @@ import type { AgentKind, - AgentModelOption, BranchSelection, ContentBlock, EffortLevel, @@ -12,6 +11,7 @@ import type { } from '@linkcode/schema'; import type { ConversationViewModel } from '../chat'; import type { PermissionDecision } from '../chat/conversation-prompts'; +import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import type { MentionItem } from './composer'; import type { ConversationComposerController } from './conversation-surface'; @@ -60,7 +60,10 @@ export interface ShellFrameProps newSessionDefaultModels: Readonly>> | null; /** The models each agent may run on, picked on its bound account. An agent absent here has no * account bound and keeps falling back to whatever its adapter or the curated table advertises. */ - accountModels: Readonly>> | null; + accountModels: Readonly>> | null; + /** The active session's own account's models — the whole live menu, since a running session's + * account is fixed at spawn. */ + sessionModels?: ModelOption[]; /** Last model accepted by LinkCode per provider, submitted as a new-session override. */ newSessionPreferredModels: Readonly>>; /** Last effort accepted by LinkCode per provider for new sessions. */ @@ -136,6 +139,7 @@ export function ShellFrame({ agentCatalogs, newSessionDefaultModels, accountModels, + sessionModels, newSessionPreferredModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -249,7 +253,8 @@ export function ShellFrame({ composer={conversationComposer} agentKind={active?.kind} agentLabel={active ? active.kind : undefined} - accountModels={active ? accountModels?.[active.kind] : undefined} + accountModels={sessionModels} + accountId={active?.accountId} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} disabled={!active || active.status === 'stopped'} isRunning={isRunning} From 8634d2c814d1c8f1efabe00bc7daa6d9526df08f Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 21:30:58 +0800 Subject: [PATCH 08/32] feat(workbench): give the model pick a single owner in daemon config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model an agent runs on was remembered twice: `providers[kind].model` on the daemon and `modelsByProvider` in renderer localStorage. Two owners meant Settings and the composer could disagree, and a scheduled or script session ignored whatever the composer last used. The accepted pick now writes daemon config, carrying the account it came from — choosing a model is also choosing who serves it, so leaving the old binding would run the next session on an account that never listed that model. The client copy is gone and the persisted store moves to v6 so a stale blob cannot resurrect a memory with no owner. Written once a selection is known to have been accepted rather than on the menu click, keeping the existing confirm-then-remember discipline: an abandoned draft never rewrites config, and a provider that rejects a model leaves the previous one standing. The pick still takes effect on the session immediately — it rides the start options either way. Nothing re-sends a configured model at session start now; the daemon resolves it, so the client specifying it again could only let the two disagree. --- .../src/renderer/src/shell/desktop-shell.tsx | 2 - .../src/settings/providers/default-models.ts | 26 ++++++++++- .../workbench/src/settings/providers/view.ts | 12 ++++- .../new-session-defaults-store.test.ts | 21 +++------ .../src/surface/new-session-defaults-store.ts | 26 +++-------- .../workbench/src/surface/workbench.tsx | 23 +++++++--- .../__tests__/new-session-surface.test.tsx | 45 ++++++++++--------- .../ui/src/shell/new-session-surface.tsx | 6 +-- .../presentation/ui/src/shell/shell-frame.tsx | 4 -- 9 files changed, 89 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 7c69e7af..fb469b89 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -82,7 +82,6 @@ export function DesktopShell({ newSessionDefaultModels, accountModels, sessionModels, - newSessionPreferredModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -432,7 +431,6 @@ export function DesktopShell({ agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} accountModels={accountModels} - preferredModels={newSessionPreferredModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts index 29d16b32..c12e7d5b 100644 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ b/packages/client/workbench/src/settings/providers/default-models.ts @@ -1,9 +1,10 @@ import { resolveBinding } from '@linkcode/providers'; import type { Account, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; import { AgentKindSchema } from '@linkcode/schema'; -import { getAccounts, getProviderConfig } from '@linkcode/sdk'; +import { getAccounts, getProviderConfig, setProviderConfig } from '@linkcode/sdk'; import type { ModelOption } from '@linkcode/ui'; -import { useData } from '../../runtime/tayori'; +import { useData, useMutation } from '../../runtime/tayori'; +import { withModel } from './view'; /** The model each agent currently runs on, as session start resolves it: the agent's persisted pick. * The bound account contributes the set that pick came from, never the pick itself. */ @@ -88,3 +89,24 @@ export function useSessionModelOptions(accountId: string | undefined): ModelOpti const { data: accounts } = useData(getAccounts, {}); return accountModelOptionsFor(accounts, accountId); } + +/** + * Persist what an agent runs on. This is the only model memory: the daemon owns it, so Settings and + * the composer cannot disagree and a scheduled or script session inherits the same pick. Passing the + * account rebinds the agent to it — picking a model is also picking who serves it. + * + * Called once a selection is known to have been accepted, not on the menu click, so an abandoned + * draft never rewrites config and a provider that rejects a model leaves the previous one standing. + */ +export function usePersistPickedModel(): ( + kind: AgentKind, + model: string, + accountId?: string, +) => Promise { + const { data: providers, mutate } = useData(getProviderConfig, {}); + const save = useMutation(setProviderConfig); + return async (kind, model, accountId) => { + await save.trigger({ providers: withModel(providers ?? {}, kind, model, accountId) }); + await mutate(); + }; +} diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 271641d4..0a540933 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -256,18 +256,26 @@ export function withEnabled( return { ...providers, [kind]: { ...providers[kind], enabled } }; } -/** Set (or, with undefined, clear) an agent's default model. */ +/** + * Set (or, with undefined, clear) the model an agent runs on. Passing the account the model came + * from rebinds the agent to it, because a model and the account serving it are one choice — leaving + * the old binding in place would run the next session on an account that never listed this model. + */ export function withModel( providers: ProvidersConfig, kind: AgentKind, model: string | undefined, + accountId?: string, ): ProvidersConfig { const entry = providers[kind] ?? { enabled: true }; if (model === undefined) { const { model: _cleared, ...rest } = entry; return { ...providers, [kind]: rest }; } - return { ...providers, [kind]: { ...entry, model } }; + return { + ...providers, + [kind]: { ...entry, model, ...(accountId !== undefined && { activeAccountId: accountId }) }, + }; } /** Drop every binding referencing a removed account; returns the input unchanged when none did. */ diff --git a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts index 768fa808..84472fe8 100644 --- a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts +++ b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts @@ -25,41 +25,34 @@ beforeEach(() => storage.clear()); afterAll(() => vi.unstubAllGlobals()); describe('new-session defaults', () => { - it('keeps successful model and effort choices isolated per provider', async () => { + it('keeps successful effort choices isolated per provider', async () => { const store = await loadStore(); + // A confirmed model rides the same shape but is not stored here — daemon config owns it. store .getState() .remember('claude-code', WORKSPACE_ID, { model: 'claude-opus-4-8', effort: 'high' }); store.getState().rememberSelection('claude-code', { effort: 'medium' }); store.getState().rememberSelection('codex', { model: 'gpt-5.6-terra', effort: 'low' }); - expect(store.getState().modelsByProvider).toEqual({ - 'claude-code': 'claude-opus-4-8', - codex: 'gpt-5.6-terra', - }); expect(store.getState().effortsByProvider).toEqual({ 'claude-code': 'medium', codex: 'low' }); }); - it('clears an explicitly rejected selection without disturbing the other axis', async () => { + it('clears an explicitly rejected effort', async () => { const store = await loadStore(); - store - .getState() - .remember('claude-code', WORKSPACE_ID, { model: 'claude-opus-4-8', effort: 'ultracode' }); + store.getState().remember('claude-code', WORKSPACE_ID, { effort: 'ultracode' }); store.getState().remember('claude-code', WORKSPACE_ID, { effort: null }); - expect(store.getState().modelsByProvider).toEqual({ 'claude-code': 'claude-opus-4-8' }); expect(store.getState().effortsByProvider).toEqual({}); }); - it('rehydrates model and effort choices after a renderer restart', async () => { + it('rehydrates effort choices after a renderer restart', async () => { const first = await loadStore(); - first.getState().remember('grok-build', WORKSPACE_ID, { model: 'grok-4.5', effort: 'medium' }); + first.getState().remember('grok-build', WORKSPACE_ID, { effort: 'medium' }); const restarted = await loadStore(); - expect(restarted.getState().modelsByProvider).toEqual({ 'grok-build': 'grok-4.5' }); expect(restarted.getState().effortsByProvider).toEqual({ 'grok-build': 'medium' }); }); @@ -84,7 +77,6 @@ describe('new-session defaults', () => { state: { lastProvider: 'codex', lastWorkspaceId: WORKSPACE_ID, - modelsByProvider: { codex: '' }, effortsByProvider: { codex: 'unsupported' }, }, version: 0, @@ -94,7 +86,6 @@ describe('new-session defaults', () => { const store = await loadStore(); expect(store.getState().lastProvider).toBeNull(); - expect(store.getState().modelsByProvider).toEqual({}); expect(store.getState().effortsByProvider).toEqual({}); expect(store.getState().branchesByWorkspace).toEqual({}); }); diff --git a/packages/client/workbench/src/surface/new-session-defaults-store.ts b/packages/client/workbench/src/surface/new-session-defaults-store.ts index 48244320..b4eb6e54 100644 --- a/packages/client/workbench/src/surface/new-session-defaults-store.ts +++ b/packages/client/workbench/src/surface/new-session-defaults-store.ts @@ -13,7 +13,6 @@ const PersistedNewSessionDefaultsSchema = z .object({ lastProvider: AgentKindSchema.nullable(), lastWorkspaceId: WorkspaceIdSchema.nullable(), - modelsByProvider: z.partialRecord(AgentKindSchema, z.string().min(1)), effortsByProvider: z.partialRecord(AgentKindSchema, EffortLevelSchema), branchesByWorkspace: z.record(z.string(), BranchSelectionSchema), }) @@ -21,7 +20,8 @@ const PersistedNewSessionDefaultsSchema = z type PersistedNewSessionDefaults = z.infer; export interface NewSessionSelection { - /** Null clears a remembered selection after an explicit reset or rejected reflection. */ + /** Confirmed model, for callers that route it onward. This store does not persist it — the model + * an agent runs on lives in daemon config (`usePersistPickedModel`), so there is one owner. */ model?: string | null; /** Null clears a remembered selection after an explicit reset or rejected reflection. */ effort?: EffortLevel | null; @@ -32,8 +32,6 @@ export interface NewSessionDefaultsState { lastProvider: AgentKind | null; /** Workspace of the last successful submit; ids that no longer exist are skipped at resolve time. */ lastWorkspaceId: WorkspaceId | null; - /** Last model accepted by LinkCode per provider; absent means defer to configured defaults. */ - modelsByProvider: Partial>; /** Last effort accepted by LinkCode per provider; absent means defer to the provider default. */ effortsByProvider: Partial>; /** Last explicitly selected branch per workspace. */ @@ -51,14 +49,7 @@ function selectionPatch( state: NewSessionDefaultsState, provider: AgentKind, selection: NewSessionSelection, -): Pick { - let modelsByProvider = state.modelsByProvider; - if (selection.model !== undefined) { - modelsByProvider = { ...modelsByProvider }; - if (selection.model === null) Reflect.deleteProperty(modelsByProvider, provider); - else modelsByProvider[provider] = selection.model; - } - +): Pick { let effortsByProvider = state.effortsByProvider; if (selection.effort !== undefined) { effortsByProvider = { ...effortsByProvider }; @@ -66,10 +57,7 @@ function selectionPatch( else effortsByProvider[provider] = selection.effort; } - return { - modelsByProvider, - effortsByProvider, - }; + return { effortsByProvider }; } /** Persists the new-session page's defaults, so the next draft preselects the last-used picks. */ @@ -84,7 +72,6 @@ export const useNewSessionDefaultsStore = create()( (set) => ({ lastProvider: null, lastWorkspaceId: null, - modelsByProvider: {}, effortsByProvider: {}, branchesByWorkspace: {}, remember: (provider, workspaceId, selection, branch) => @@ -101,12 +88,13 @@ export const useNewSessionDefaultsStore = create()( set((state) => selectionPatch(state, provider, selection)), }), { - name: 'linkcode.workbench.new-session-defaults:v5', + // v6 drops `modelsByProvider`: the model pick now lives in daemon config, so a stale blob + // would resurrect a client-side memory that no longer has an owner. + name: 'linkcode.workbench.new-session-defaults:v6', schema: PersistedNewSessionDefaultsSchema, partialize: (state) => ({ lastProvider: state.lastProvider, lastWorkspaceId: state.lastWorkspaceId, - modelsByProvider: state.modelsByProvider, effortsByProvider: state.effortsByProvider, branchesByWorkspace: state.branchesByWorkspace, }), diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 2f87ef02..54ecde8d 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -61,6 +61,7 @@ import { useMutation } from '../runtime/tayori'; import { useAccountModelOptions, useConfiguredDefaultModels, + usePersistPickedModel, useSessionModelOptions, } from '../settings/providers/default-models'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; @@ -278,7 +279,6 @@ function WorkbenchSessionSurface({ const setThreadOrder = useSidebarOrderStore((state) => state.setThreadOrder); const lastProvider = useNewSessionDefaultsStore((state) => state.lastProvider); const lastWorkspaceId = useNewSessionDefaultsStore((state) => state.lastWorkspaceId); - const newSessionPreferredModels = useNewSessionDefaultsStore((state) => state.modelsByProvider); const newSessionPreferredEfforts = useNewSessionDefaultsStore((state) => state.effortsByProvider); const newSessionPreferredBranches = useNewSessionDefaultsStore( (state) => state.branchesByWorkspace, @@ -286,6 +286,7 @@ function WorkbenchSessionSurface({ const onboarding = useAgentRuntimeOnboarding(); const rememberNewSessionDefaults = useNewSessionDefaultsStore((state) => state.remember); const rememberSelection = useNewSessionDefaultsStore((state) => state.rememberSelection); + const persistPickedModel = usePersistPickedModel(); const [previewExpandedKeys, addPreviewExpanded, removePreviewExpanded] = useSet(); const threadGroups = useMemo(() => { const { pinnedGroup, rest } = extractPinnedGroup(sessions.sessions, pinnedSessionIds); @@ -412,7 +413,18 @@ function WorkbenchSessionSurface({ sdkClient.raw.eventsSnapshot(sessionId), ); if (newlyConfirmed.model === undefined && newlyConfirmed.effort === undefined) return; - rememberSelection(submission.kind, newlyConfirmed); + if (newlyConfirmed.effort !== undefined) { + rememberSelection(submission.kind, { effort: newlyConfirmed.effort }); + } + // The model lands in daemon config rather than a client store, together with the account it + // came from, so Settings shows the rebind and non-composer sessions inherit the pick. + if (newlyConfirmed.model) { + void persistPickedModel( + submission.kind, + newlyConfirmed.model, + submission.accountId, + ).catch(noop); + } }) .catch(noop); } @@ -460,9 +472,9 @@ function WorkbenchSessionSurface({ // onError (wired into modelMutation above) still reports the failure via the error banner. const provider = active?.kind; return modelMutation.trigger({ sessionId: sessions.activeId, model: model.id }).then(() => { - // The account is not part of a live switch — it is fixed at spawn, and this menu only ever - // offers the session's own account's models. - if (provider) rememberSelection(provider, { model: model.id }); + // No account change: it is fixed at spawn, and this menu only ever offers the session's own + // account's models. + if (provider) void persistPickedModel(provider, model.id).catch(noop); }); } @@ -665,7 +677,6 @@ function WorkbenchSessionSurface({ accountModels={accountModels} sessionModels={sessionModels} agentCatalogs={agentCatalogs} - newSessionPreferredModels={newSessionPreferredModels} newSessionPreferredEfforts={newSessionPreferredEfforts} newSessionPreferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={RuntimeNewSessionBranchPicker} diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 4f1efa8d..57f88313 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -694,13 +694,14 @@ describe('NewSessionSurface', () => { expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); }); - it('shows and explicitly submits the last successful provider model without reselection', async () => { + it('shows and explicitly submits the configured model without reselection', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { typeInComposer('use my last model'); await pressInComposer('Enter'); - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-opus-4-8' })), - ); + // Shown but not re-sent: the daemon resolves the configured model, so specifying it again would + // only risk the two disagreeing. + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); it('drops remembered Codex ultra when the fallback model switches to Luna', async () => { @@ -729,7 +731,7 @@ describe('NewSessionSurface', () => { { expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('effort'); }); - it('submits a remembered dynamic-provider model even without a draft catalog', async () => { + it('shows a configured dynamic-provider model even without a draft catalog', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { typeInComposer('use remembered dynamic model'); await pressInComposer('Enter'); - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ model: 'anthropic/claude-sonnet-4-6' }), - ), - ); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); it('can return remembered model and effort choices to the configured ones', async () => { @@ -784,7 +783,6 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} defaultModels={{ 'claude-code': 'configured/claude-model' }} preferredEfforts={{ 'claude-code': 'high' }} - preferredModels={{ 'claude-code': 'claude-opus-4-8' }} draft={{ initialProvider: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, @@ -797,6 +795,11 @@ describe('NewSessionSurface', () => { />, ); + // Pick a model locally, so there is something to reset back to the configured one. + await user.click(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Opus 4.8' })); + await user.click(screen.getByRole('button', { name: RE_OPUS_4_8 })); await user.click(await screen.findByRole('menuitem', { name: 'resetToDefault' })); expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); @@ -1124,7 +1127,7 @@ describe('NewSessionSurface', () => { expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); }); - it("lets a remembered pick outrank the agent's own default", async () => { + it("lets the configured model outrank the agent's own default", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} onSubmit={onSubmit} - preferredModels={{ pi: 'pi/basic' }} + defaultModels={{ pi: 'pi/basic' }} workspaces={[]} />, ); + // The configured model wins the display over `catalog.defaultModel`; neither travels, since the + // daemon resolves the configured one itself. expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); typeInComposer('use my last model'); await pressInComposer('Enter'); - // A remembered pick is an explicit choice, so unlike the catalog default it does travel. - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ model: 'pi/basic' })), - ); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); it('submits compatible Pi catalog choices and suppresses stale effort for models without it', async () => { diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 52a6a995..e4ce734b 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -95,8 +95,6 @@ export interface NewSessionSurfaceProps { /** The models each agent may run on, picked on its bound account. An agent absent here has no * account bound and falls back to its adapter catalog or the curated table. */ accountModels?: Readonly>> | null; - /** Last accepted model per provider. Unlike configured defaults, this is an explicit override. */ - preferredModels?: Readonly>>; /** Last accepted effort per provider. Missing kinds retain the provider default. */ preferredEfforts?: Readonly>>; /** Last successfully used branch and checkout mode per workspace. */ @@ -151,7 +149,6 @@ export function NewSessionSurface({ agentCatalogs, defaultModels, accountModels, - preferredModels, preferredEfforts, preferredBranches, NewSessionBranchPickerComponent, @@ -191,8 +188,7 @@ export function NewSessionSurface({ const branchMode = selectedBranch?.mode ?? 'local'; const catalog = agentCatalogs?.[provider]; const localModel = selectedModels[provider]; - const selectedModel = - localModel === undefined ? (preferredModels?.[provider] ?? null) : localModel; + const selectedModel = localModel === undefined ? null : localModel; // The catalog default is what the agent's own config would start on, so it yields to anything the // user expressed through LinkCode. Nothing guesses past it: an unresolved model blocks the send // rather than starting a session on a model nobody chose. diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 3340ec68..7c9d5c33 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -64,8 +64,6 @@ export interface ShellFrameProps /** The active session's own account's models — the whole live menu, since a running session's * account is fixed at spawn. */ sessionModels?: ModelOption[]; - /** Last model accepted by LinkCode per provider, submitted as a new-session override. */ - newSessionPreferredModels: Readonly>>; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; newSessionPreferredBranches: Readonly>; @@ -140,7 +138,6 @@ export function ShellFrame({ newSessionDefaultModels, accountModels, sessionModels, - newSessionPreferredModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -232,7 +229,6 @@ export function ShellFrame({ agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} accountModels={accountModels} - preferredModels={newSessionPreferredModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} From 12fde6175f757e7ba0164098ba95a3197ed424f3 Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 21:51:43 +0800 Subject: [PATCH 09/32] refactor(ui,workbench,i18n): call the agent a Harness, not a provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Provider" meant two different things in adjacent UI: the composer's provider picker chose the *agent*, while the Providers settings page means accounts. `onOpenProviderSettings: (kind: AgentKind) => void` had both in one signature. Agent-meaning UI text and client identifiers now say harness — `selectableHarnesses`, `onHarnessChange`, `lastHarness`, and the composer's menu label. Account-meaning strings keep "provider", `AgentKind` and every wire and daemon term are untouched, and `groupModelsByProvider` stays as the one genuine model-provider use. Same UI/i18n-only discipline already recorded for Thread/`session`. Two things this turned up. Translation keys are not typechecked, so the rename would have silently emptied strings — a test asserting the old menu label is what caught it. And the store test had hand-copied its storage key, which drifted at the previous version bump and had quietly turned the malformed-blob test into a vacuous pass; the key is now exported and imported, and the test verified to fail against a well-formed blob. --- AGENTS.md | 1 + .../src/settings/history-import-tab.tsx | 2 +- .../new-session-defaults-store.test.ts | 9 ++- .../src/surface/new-session-defaults-store.ts | 23 ++++-- .../workbench/src/surface/workbench.tsx | 4 +- packages/presentation/i18n/src/locales/en.ts | 10 +-- .../presentation/i18n/src/locales/zh-cn.ts | 10 +-- .../__tests__/new-session-surface.test.tsx | 78 +++++++++---------- .../ui/src/shell/composer-controls.tsx | 50 ++++++------ .../presentation/ui/src/shell/composer.tsx | 26 +++---- .../ui/src/shell/new-session-surface.tsx | 68 ++++++++-------- .../ui/src/shell/plugins/plugins-tab.tsx | 4 +- 12 files changed, 148 insertions(+), 137 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee0d1beb..e9f69d6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ Large rewrites are encouraged when they're the right fix — replace subsystems - Keep table-definition / schema modules free of hooks and browser APIs so they stay importable anywhere. - Directory names must describe responsibility, not incidental data. For example, a sidebar footer belongs with sidebar/workbench presentation, not in a `host/` folder just because it displays host state; a layout adapter belongs under layout, not a one-file pseudo-subsystem. - Terminology: the product term **Thread** is the code/wire term **`session`** — the rename is UI/i18n-only. Never rename `session` in wire or code identifiers. +- Terminology: **"provider" means the account/service** (DeepSeek, OpenRouter) — never the agent. The agent is a **Harness**, so client-side UI text and identifiers use that (`selectableHarnesses`, `onHarnessChange`, `lastHarness`); `AgentKind` and every wire/daemon term stay as they are. The two meanings used to collide in adjacent UI — the composer's "provider" picker chose the *agent* while the Providers settings page meant accounts. `groupModelsByProvider` is the genuine exception: it groups by *model* provider. ## Tooling And Aliases diff --git a/apps/desktop/src/renderer/src/settings/history-import-tab.tsx b/apps/desktop/src/renderer/src/settings/history-import-tab.tsx index e153a5cc..b8e72466 100644 --- a/apps/desktop/src/renderer/src/settings/history-import-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/history-import-tab.tsx @@ -71,7 +71,7 @@ export function HistoryImportTab({ kind }: { kind: AgentKind }): React.ReactNode <> - {t('panelTitle', { provider: AGENT_LABELS[kind] })} + {t('panelTitle', { harness: AGENT_LABELS[kind] })} {surface.count > 0 && ( diff --git a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts index 84472fe8..7a98d95a 100644 --- a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts +++ b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts @@ -1,7 +1,10 @@ import { WorkspaceIdSchema } from '@linkcode/schema'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NEW_SESSION_DEFAULTS_STORAGE_KEY } from '../new-session-defaults-store'; -const STORAGE_KEY = 'linkcode.workbench.new-session-defaults:v5'; +// Imported rather than restated: a hand-copied key drifted once, and the mismatch turned the +// malformed-blob test below into a vacuous pass. +const STORAGE_KEY = NEW_SESSION_DEFAULTS_STORAGE_KEY; const WORKSPACE_ID = WorkspaceIdSchema.parse('workspace-1'); const stored = new Map(); const storage = { @@ -75,7 +78,7 @@ describe('new-session defaults', () => { STORAGE_KEY, JSON.stringify({ state: { - lastProvider: 'codex', + lastHarness: 'codex', lastWorkspaceId: WORKSPACE_ID, effortsByProvider: { codex: 'unsupported' }, }, @@ -85,7 +88,7 @@ describe('new-session defaults', () => { const store = await loadStore(); - expect(store.getState().lastProvider).toBeNull(); + expect(store.getState().lastHarness).toBeNull(); expect(store.getState().effortsByProvider).toEqual({}); expect(store.getState().branchesByWorkspace).toEqual({}); }); diff --git a/packages/client/workbench/src/surface/new-session-defaults-store.ts b/packages/client/workbench/src/surface/new-session-defaults-store.ts index b4eb6e54..577494a8 100644 --- a/packages/client/workbench/src/surface/new-session-defaults-store.ts +++ b/packages/client/workbench/src/surface/new-session-defaults-store.ts @@ -9,9 +9,18 @@ import { import { z } from 'zod'; import { create } from 'zustand'; +/** + * Exported so tests cannot drift from it — one did, and a silent key mismatch turned the + * malformed-blob test into a vacuous pass. + * + * v6 dropped `modelsByProvider` (the model pick moved to daemon config) and v7 renamed + * `lastProvider` to `lastHarness`; a stale blob is discarded by the schema either way. + */ +export const NEW_SESSION_DEFAULTS_STORAGE_KEY = 'linkcode.workbench.new-session-defaults:v7'; + const PersistedNewSessionDefaultsSchema = z .object({ - lastProvider: AgentKindSchema.nullable(), + lastHarness: AgentKindSchema.nullable(), lastWorkspaceId: WorkspaceIdSchema.nullable(), effortsByProvider: z.partialRecord(AgentKindSchema, EffortLevelSchema), branchesByWorkspace: z.record(z.string(), BranchSelectionSchema), @@ -29,7 +38,7 @@ export interface NewSessionSelection { export interface NewSessionDefaultsState { /** Provider of the last successful new-session submit; null before the first (→ claude-code). */ - lastProvider: AgentKind | null; + lastHarness: AgentKind | null; /** Workspace of the last successful submit; ids that no longer exist are skipped at resolve time. */ lastWorkspaceId: WorkspaceId | null; /** Last effort accepted by LinkCode per provider; absent means defer to the provider default. */ @@ -70,14 +79,14 @@ export const useNewSessionDefaultsStore = create()( PersistedNewSessionDefaults >( (set) => ({ - lastProvider: null, + lastHarness: null, lastWorkspaceId: null, effortsByProvider: {}, branchesByWorkspace: {}, remember: (provider, workspaceId, selection, branch) => set((state) => ({ ...selectionPatch(state, provider, selection), - lastProvider: provider, + lastHarness: provider, lastWorkspaceId: workspaceId, branchesByWorkspace: branch === undefined @@ -88,12 +97,10 @@ export const useNewSessionDefaultsStore = create()( set((state) => selectionPatch(state, provider, selection)), }), { - // v6 drops `modelsByProvider`: the model pick now lives in daemon config, so a stale blob - // would resurrect a client-side memory that no longer has an owner. - name: 'linkcode.workbench.new-session-defaults:v6', + name: NEW_SESSION_DEFAULTS_STORAGE_KEY, schema: PersistedNewSessionDefaultsSchema, partialize: (state) => ({ - lastProvider: state.lastProvider, + lastHarness: state.lastHarness, lastWorkspaceId: state.lastWorkspaceId, effortsByProvider: state.effortsByProvider, branchesByWorkspace: state.branchesByWorkspace, diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 54ecde8d..0a0f64c6 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -277,7 +277,7 @@ function WorkbenchSessionSurface({ const threadOrder = useSidebarOrderStore((state) => state.threadOrder); const setGroupOrder = useSidebarOrderStore((state) => state.setGroupOrder); const setThreadOrder = useSidebarOrderStore((state) => state.setThreadOrder); - const lastProvider = useNewSessionDefaultsStore((state) => state.lastProvider); + const lastHarness = useNewSessionDefaultsStore((state) => state.lastHarness); const lastWorkspaceId = useNewSessionDefaultsStore((state) => state.lastWorkspaceId); const newSessionPreferredEfforts = useNewSessionDefaultsStore((state) => state.effortsByProvider); const newSessionPreferredBranches = useNewSessionDefaultsStore( @@ -591,7 +591,7 @@ function WorkbenchSessionSurface({ const draft: NewSessionDraft | null = sessions.draft ? { initialWorkspaceId, - initialProvider: lastProvider ?? 'claude-code', + initialHarness: lastHarness ?? 'claude-code', } : null; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 5faf0e3f..be5f1269 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -348,7 +348,7 @@ export const en = { attachmentUnsupportedAgent: "This agent doesn't support image attachments yet", attachmentReadFailed: 'Failed to read the file', approvalTitle: 'How should {agent} actions be approved?', - provider: 'Provider', + harness: 'Harness', }, mode: { label: 'Mode', @@ -768,9 +768,9 @@ export const en = { tabMarket: 'Market', tabMcp: 'MCP', tabSkills: 'Skills', - discoveryFailed: 'Could not read {provider} plugins: {reason}', + discoveryFailed: 'Could not read {harness} plugins: {reason}', discoveryFailedUnknown: 'discovery failed', - runtimeMissing: '{provider} was not detected; install it to manage its plugins here.', + runtimeMissing: '{harness} was not detected; install it to manage its plugins here.', installedEmptyHint: 'No plugins installed for this agent yet — pick one from Market.', marketEmptyHint: 'No installable entries in this agent’s plugin marketplace.', marketCount: '{count} available', @@ -867,7 +867,7 @@ export const en = { }, historyImport: { portalLabel: 'Import chat history', - panelTitle: 'Import chat history from {provider}', + panelTitle: 'Import chat history from {harness}', conversationCount: '{count, plural, one {# conversation} other {# conversations}}', refresh: 'Refresh', sortLabel: 'Sort order', @@ -880,7 +880,7 @@ export const en = { importedBadge: 'Imported', open: 'Open', emptyTitle: 'No history yet', - emptyHint: 'This provider has no local conversation history on this machine.', + emptyHint: 'This harness has no local conversation history on this machine.', loadFailedTitle: 'Failed to load history', retry: 'Retry', showingLatest: diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 3f88e8cb..7650c538 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -338,7 +338,7 @@ export const zhCN = { attachmentUnsupportedAgent: '当前 agent 暂不支持图片附件', attachmentReadFailed: '读取文件失败', approvalTitle: '如何审批 {agent} 的操作?', - provider: '提供方', + harness: '编码助手', }, mode: { label: '模式', @@ -753,9 +753,9 @@ export const zhCN = { tabMarket: '市场', tabMcp: 'MCP', tabSkills: '技能', - discoveryFailed: '无法读取 {provider} 的插件:{reason}', + discoveryFailed: '无法读取 {harness} 的插件:{reason}', discoveryFailedUnknown: '扫描失败', - runtimeMissing: '未检测到 {provider},安装后即可在这里管理它的插件。', + runtimeMissing: '未检测到 {harness},安装后即可在这里管理它的插件。', installedEmptyHint: '该智能体还没有安装任何插件;到「市场」里挑一个。', marketEmptyHint: '该智能体的插件市场里没有可安装的条目。', marketCount: '{count} 个可安装', @@ -850,7 +850,7 @@ export const zhCN = { }, historyImport: { portalLabel: '导入聊天历史', - panelTitle: '从 {provider} 导入聊天历史', + panelTitle: '从 {harness} 导入聊天历史', conversationCount: '{count} 条对话', refresh: '刷新', sortLabel: '排序方式', @@ -863,7 +863,7 @@ export const zhCN = { importedBadge: '已导入', open: '打开', emptyTitle: '暂无历史对话', - emptyHint: '该提供方在本机还没有历史对话。', + emptyHint: '该编码助手在本机还没有历史对话。', loadFailedTitle: '无法加载历史记录', retry: '重试', showingLatest: '仅显示最近 {count} 条对话', diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 57f88313..4a16c31e 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -56,7 +56,7 @@ const RE_PI_WIDE = /Pi Wide/; const RE_HIGH_EFFORT = /High/; const RE_LOW_EFFORT = /Low/; const RE_GPT_56_SOL = /GPT-5.6-Sol/; -const RE_PROVIDER_CLAUDE_CODE_MENU = /provider.*Claude Code/; +const RE_HARNESS_CLAUDE_CODE_MENU = /harness.*Claude Code/; const RE_MODEL_SONNET_5_MENU = /model.*Sonnet 5/; const RE_OPUS_5 = /Opus 5/; const RE_MODEL_MENU = /^model/; @@ -122,7 +122,7 @@ describe('NewSessionSurface', () => { render( { { { { { render( { render( { chatWorkspace={CHAT_WORKSPACE} // Nothing guesses a model any more, so the configured one has to be supplied. defaultModels={{ 'claude-code': 'claude-sonnet-5' }} - draft={{ initialProvider: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -313,7 +313,7 @@ describe('NewSessionSurface', () => { render( { render( { { { { render( { render( { // Nothing guesses a model any more, so the configured one has to be supplied. defaultModels={{ 'claude-code': 'claude-sonnet-5' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -586,7 +586,7 @@ describe('NewSessionSurface', () => { await user.click(screen.getByRole('button', { name: RE_SONNET_5 })); const providerItem = await screen.findByRole('menuitem', { - name: RE_PROVIDER_CLAUDE_CODE_MENU, + name: RE_HARNESS_CLAUDE_CODE_MENU, }); expect(screen.getByRole('menuitem', { name: RE_MODEL_SONNET_5_MENU })).toBeTruthy(); providerItem.focus(); @@ -616,7 +616,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} preferredEfforts={{ 'claude-code': 'medium' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -643,7 +643,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} defaultModels={{ 'claude-code': 'custom/claude-model' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -668,7 +668,7 @@ describe('NewSessionSurface', () => { const props = { chatWorkspace: CHAT_WORKSPACE, draft: { - initialProvider: 'claude-code' as const, + initialHarness: 'claude-code' as const, initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }, mentionItems: [], @@ -703,7 +703,7 @@ describe('NewSessionSurface', () => { // than shadowed by a second client-side memory. defaultModels={{ 'claude-code': 'claude-opus-4-8' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -732,7 +732,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} preferredEfforts={{ codex: 'ultra' }} defaultModels={{ codex: 'gpt-5.6-sol' }} - draft={{ initialProvider: 'codex', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'codex', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -758,7 +758,7 @@ describe('NewSessionSurface', () => { { defaultModels={{ 'claude-code': 'configured/claude-model' }} preferredEfforts={{ 'claude-code': 'high' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -841,7 +841,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} defaultModels={{ 'claude-code': 'claude-opus-5' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -877,7 +877,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} defaultModels={{ 'claude-code': 'deepseek-v4-pro' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -903,7 +903,7 @@ describe('NewSessionSurface', () => { accountModels={{ 'claude-code': [] }} chatWorkspace={CHAT_WORKSPACE} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -929,7 +929,7 @@ describe('NewSessionSurface', () => { // Nothing guesses a model any more, so the configured one has to be supplied. defaultModels={{ 'claude-code': 'claude-sonnet-5' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -964,7 +964,7 @@ describe('NewSessionSurface', () => { render( { }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1026,7 +1026,7 @@ describe('NewSessionSurface', () => { { pi: { ...PI_CONFIGURED_CATALOG, defaultModel: 'pi/basic' }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1072,7 +1072,7 @@ describe('NewSessionSurface', () => { agentCatalogs={{ pi: PI_CONFIGURED_CATALOG }} chatWorkspace={CHAT_WORKSPACE} defaultModels={{ pi: 'pi/wide' }} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1095,7 +1095,7 @@ describe('NewSessionSurface', () => { agentCatalogs={{ pi: modelless }} chatWorkspace={CHAT_WORKSPACE} defaultModels={{ pi: 'pi/wide' }} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1115,7 +1115,7 @@ describe('NewSessionSurface', () => { agentCatalogs={{ pi: PI_CONFIGURED_CATALOG }} chatWorkspace={CHAT_WORKSPACE} defaultModels={{ pi: 'pi/basic' }} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1133,7 +1133,7 @@ describe('NewSessionSurface', () => { { }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1220,7 +1220,7 @@ describe('NewSessionSurface', () => { render( = { plan: ListTodoIcon, goal: TargetIcon, @@ -182,7 +182,7 @@ export function SessionModeChip({ ); } -/** Availability badge on a provider submenu item; nothing renders for a ready runtime. */ +/** Availability badge on a harness submenu item; nothing renders for a ready runtime. */ function RuntimeCueBadge({ cue }: { cue?: AgentRuntimeCue }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); if (!cue) return null; @@ -213,8 +213,8 @@ function RuntimeCueBadge({ cue }: { cue?: AgentRuntimeCue }): React.ReactNode { export function ModelSelectorMenu({ disabled, - provider, - selectableProviders, + harness, + selectableHarnesses, runtimeCues, modelOptions, effortOptions, @@ -225,13 +225,13 @@ export function ModelSelectorMenu({ onSelectEffort, onResetModel, onResetEffort, - onSelectProvider, + onSelectHarness, }: { disabled: boolean; - provider?: AgentKind; - /** Providers offered for selection; absent/empty when the session's provider is fixed. */ - selectableProviders?: AgentKind[]; - /** Runtime availability per provider: a cue renders as a muted badge on the submenu item. */ + harness?: AgentKind; + /** Harnesses offered for selection; absent/empty when the session's harness is fixed. */ + selectableHarnesses?: AgentKind[]; + /** Runtime availability per harness: a cue renders as a muted badge on the submenu item. */ runtimeCues?: AgentRuntimeCues; modelOptions?: ModelOption[]; effortOptions?: EffortOption[]; @@ -242,11 +242,11 @@ export function ModelSelectorMenu({ /** Carries the whole entry: a cross-account list needs the account alongside the id. */ onSelectModel: (model: ModelOption) => void; onSelectEffort: (effort: EffortLevel) => void; - /** Draft-only escape hatch back to the provider/configured model default. */ + /** Draft-only escape hatch back to the harness/configured model default. */ onResetModel?: () => void; - /** Draft-only escape hatch back to the provider effort default. */ + /** Draft-only escape hatch back to the harness effort default. */ onResetEffort?: () => void; - onSelectProvider?: (provider: AgentKind) => void; + onSelectHarness?: (harness: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.composer'); const selectedModel = resolveModel(modelOptions, selectedModelId, selectedAccountId); @@ -254,18 +254,18 @@ export function ModelSelectorMenu({ const selectedEffort = optionById(effortOptions, selectedEffortId) ?? (selectedEffortId ? EFFORT_OPTIONS_BY_ID[selectedEffortId] : undefined); - const providers = selectableProviders ?? []; + const harnesses = selectableHarnesses ?? []; const hasEfforts = Boolean(effortOptions?.length); const hasModels = Boolean(modelOptions?.length); const modelLabel = selectedModel?.label ?? selectedModelId ?? t('modelDefault'); const effortLabel = selectedEffort?.label ?? t('effortDefault'); - // A draft provider picker must keep the model axis visible even when that provider discovers + // A draft harness picker must keep the model axis visible even when that harness discovers // its concrete model only after session start (OpenCode/Pi). The live update replaces Default. - const showsModel = providers.length > 0 || hasModels || selectedModelId !== null; + const showsModel = harnesses.length > 0 || hasModels || selectedModelId !== null; - if (!hasEfforts && !showsModel && providers.length === 0) return null; + if (!hasEfforts && !showsModel && harnesses.length === 0) return null; const selectorLabels: string[] = []; - if (provider) selectorLabels.push(AGENT_LABELS[provider]); + if (harness) selectorLabels.push(AGENT_LABELS[harness]); if (showsModel) selectorLabels.push(modelLabel); if (hasEfforts) selectorLabels.push(`${t('effort')}: ${effortLabel}`); @@ -276,7 +276,7 @@ export function ModelSelectorMenu({ disabled={disabled} render={ onSetBinding(binding.kind, checked ? accountId : undefined)} + onCheckedChange={(checked) => onSetAccountEnabled(agent.kind, checked)} /> ); From 00b75fec26721949d94f3e7716f347c45ebe23d6 Mon Sep 17 00:00:00 2001 From: Peron Date: Fri, 7 Aug 2026 22:04:52 +0800 Subject: [PATCH 17/32] docs(schema,engine,agent-adapter): record enabled accounts vs the agent's fallback --- packages/foundation/schema/src/model/account.ts | 7 ++++--- packages/host/agent-adapter/AGENTS.md | 2 +- .../engine/src/session/start-options-resolver.ts | 15 +++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index 18eb9eb5..dacc776c 100644 --- a/packages/foundation/schema/src/model/account.ts +++ b/packages/foundation/schema/src/model/account.ts @@ -3,9 +3,10 @@ import { AgentKindSchema, TimestampSchema } from './primitives'; /** * A model-provider credential in the global account pool (data plane). The daemon persists these - * in ~/.linkcode/config.json (0600) and injects the agent's bound account (`activeAccountId`) into - * the adapter at session start. One credential can back several agents — natively when its - * endpoint speaks the agent's protocol, via conversion otherwise. + * in ~/.linkcode/config.json (0600) and injects one into the adapter at session start: whichever + * `StartOptions.config.accountId` names, or the agent's `activeAccountId` when nothing does. One + * credential can back several agents — natively when its endpoint speaks the agent's protocol, via + * conversion otherwise — and several accounts can serve one agent at the same time. */ /** What an endpoint speaks on the wire; decides native-routing vs. conversion. */ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index e46443a4..ef046143 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -113,7 +113,7 @@ Product code must branch on `historyCapabilities` — never assume an op is supp levels (Claude `max`) and live-switchable levels share validation and reflection behavior. The engine caches the emitted effort and replays it when the newly created session attaches. -- **A live session can change account, but never in place.** Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. No adapter sees it: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. +- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one); which it *falls back to* when a session names none is `activeAccountId`, used by automation, schedules, and IM threads. Sessions started from a picker carry `config.accountId` and ignore the fallback. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records the model and account it resolved to, and a relaunch reads them back, so a thread keeps its own pick even after the fallback moves. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env. - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** Precedence: model-ref (`providerID/modelID`, which decides routing) → for pi, the resumed session's own last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in the agent's own catalog, from `@linkcode/providers`) → for pi, its first available provider. Before `knownProvider` existed a bare model id left the credential uninjected entirely; putting it ahead of the resumed provider instead strands a resumed session on a provider that never got the key. - **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built). diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 324afb79..1457a2a4 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -3,7 +3,7 @@ import { Effect } from 'effect'; import { isObjectEmpty } from 'foxts/is-object-empty'; import type { CustomMcpServerService } from '../agent/custom-mcp-service'; import type { ProviderConfigStore } from '../agent/provider-config'; -import { applyProviderDefaults } from '../agent/provider-config'; +import { applyProviderDefaults, resolvedAccountId } from '../agent/provider-config'; import type { TranslatorService } from '../agent/translator'; import { translationUpstream, withTranslatorEndpoint } from '../agent/translator'; import { OperationError, RequestError } from '../failure'; @@ -34,7 +34,10 @@ export class SessionStartOptionsResolver { ): Effect.Effect { const providers = this.providers.get(); const defaults = applyProviderDefaults(options, providers, this.providers.getAccounts()); - const accountBound = providers[options.kind]?.activeAccountId !== undefined; + // Whether an account actually resolved — the caller's pin or, failing that, the agent's + // configured default. Asking that rather than "is a default set" also covers a pinned session + // on an agent with no default at all. + const accountResolved = resolvedAccountId(defaults.options) !== undefined; const { translator } = this; const withCustomMcpServers = this.withCustomMcpServers.bind(this); const withSimulatorMcp = this.withSimulatorMcp.bind(this); @@ -45,13 +48,13 @@ export class SessionStartOptionsResolver { return yield* Effect.fail( new RequestError({ code: 'unsupported', - message: `The bound account cannot back ${options.kind} (${defaults.unavailable})`, + message: `The account cannot back ${options.kind} (${defaults.unavailable})`, }), ); } - if (accountBound && defaults.options.model === undefined) { - // With an account bound, its selected set is the only model source and nothing falls back to - // the agent's own choice. Unbound agents keep running on whatever they resolve themselves. + if (accountResolved && defaults.options.model === undefined) { + // With an account in play, its selected set is the only model source and nothing falls back + // to the agent's own choice. Agents with no account keep resolving their own. return yield* Effect.fail( new RequestError({ code: 'unsupported', From 1e930040ebe2b61c994bc0ef4d2e83ad5c367916 Mon Sep 17 00:00:00 2001 From: Peron Date: Sat, 8 Aug 2026 12:38:40 +0800 Subject: [PATCH 18/32] fix(engine): announce a relaunch, and keep a probe's key with its own service --- .../src/__tests__/engine-model-probe.test.ts | 54 +++++++++++++++++++ .../__tests__/engine-session-records.test.ts | 7 +++ .../host/engine/src/agent/request-handler.ts | 20 +++++-- .../src/session/session-record-registry.ts | 4 ++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index b2de8813..9a9e0832 100644 --- a/packages/host/engine/src/__tests__/engine-model-probe.test.ts +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -169,4 +169,58 @@ describe('config.probe-models', () => { }); expect(seen).toEqual(['/models']); }); + + it('refuses to send an account secret to a service it does not belong to', async () => { + const reached: string[] = []; + relay = await startRelay((url) => { + reached.push(url); + return { status: 200, body: JSON.stringify({ data: [] }) }; + }); + const providerStore = new InMemoryProviderConfigStore(); + providerStore.update({ + accounts: [ + { + id: 'acc_anthropic', + label: 'Anthropic', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-anthropic' }, + createdAt: 0, + }, + { + id: 'acc_custom', + label: 'Custom', + credential: { type: 'api-key', key: 'sk-custom' }, + createdAt: 0, + }, + ], + }); + const h = createHarness(providerStore); + await h.engine.start(); + + // The destination and the credential are independent client-chosen fields; pairing them freely + // would hand one vendor's key to another. + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-5', + service: 'openrouter', + credential: { type: 'account', accountId: 'acc_anthropic' }, + }); + const crossed = await replyFor(h.sent, 'probe-5'); + if (crossed.kind !== 'request.failed') throw new Error('no request.failed for probe-5'); + expect(crossed.message).toContain('does not belong to the service being probed'); + + // A pre-catalog account names no service, so nothing it could legitimately match. + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-6', + service: 'deepseek', + credential: { type: 'account', accountId: 'acc_custom' }, + }); + const serviceless = await replyFor(h.sent, 'probe-6'); + if (serviceless.kind !== 'request.failed') throw new Error('no request.failed for probe-6'); + expect(serviceless.message).toContain('does not belong to the service being probed'); + + // Neither request may reach the wire at all. + expect(reached).toEqual([]); + }); }); diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 65aa51bf..bab1a1aa 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -1150,6 +1150,13 @@ describe('live account switching', () => { expect(runs).toHaveLength(2); expect(runs[1].accountId).toBe('acc_second'); + // The relaunch sends no `session.started` and re-reports the historyId the record already has, + // so without this cue clients keep listing the previous account indefinitely. + expect(h.sent).toContainEqual({ + kind: 'session.changed', + sessionId: h.sessionId, + reason: 'updated', + }); await h.inject({ kind: 'session.list', clientReqId: 'listed' }); expect(listedSessions(h.sent, 'listed')[0]?.accountId).toBe('acc_second'); }); diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index f5deb272..8e673ca6 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -155,7 +155,10 @@ export class AgentRequestHandler { if (!source) { throw new Error(`${payload.service} serves no model list`); } - const models = await this.probeModels(source, this.probeSecret(payload.credential)); + const models = await this.probeModels( + source, + this.probeSecret(payload.service, payload.credential), + ); this.transport.send( createWireMessage({ kind: 'config.probe-models.result', @@ -211,9 +214,17 @@ export class AgentRequestHandler { } } - /** The secret to probe with. A saved account is named by id rather than shipping its secret back - * out to the client and in again; an oauth login holds none, so it cannot be probed. */ + /** + * The secret to probe with. A saved account is named by id rather than shipping its secret back + * out to the client and in again; an oauth login holds none, so it cannot be probed. + * + * The destination and the credential arrive as two independent client-chosen fields, so the + * account must belong to the service being probed — otherwise a request could aim one vendor's + * key at another vendor's endpoint. An account with no service is refused for the same reason: + * only catalog services are probeable, so nothing it could legitimately match. + */ private probeSecret( + service: string, credential: Extract['credential'], ): AccountSecret { if (credential.type === 'inline') return credential.secret; @@ -221,6 +232,9 @@ export class AgentRequestHandler { .getAccounts() .find((candidate) => candidate.id === credential.accountId); if (!account) throw new Error('Account not found'); + if (account.service !== service) { + throw new Error('That account does not belong to the service being probed'); + } if (account.credential.type === 'oauth') { throw new Error('A subscription login holds no secret to read the model list with'); } diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index ed4ceebb..350b2a40 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -147,6 +147,10 @@ export class SessionRecordRegistry { if (!record) return; record.runs.push({ startedAt: Date.now(), ...definedFields(run) }); this.persist(record); + // A new run re-points the identity `list()` projects — `accountId`, `historyId` — so clients + // must revalidate. Nothing else announces a relaunch: it sends no `session.started`, and a + // resumed run already carries the historyId that would otherwise notify via `bindHistoryId`. + this.onChanged(sessionId, 'updated'); } setTitleFromContent(sessionId: SessionId, content: ContentBlock[]): void { From acca89dc81a5c60161a96a8e97a85155273990dd Mon Sep 17 00:00:00 2001 From: Peron Date: Sat, 8 Aug 2026 12:47:31 +0800 Subject: [PATCH 19/32] fix(ui,workbench): let an agent's own catalog survive an unrelated account --- .../src/renderer/src/shell/desktop-shell.tsx | 2 + .../src/settings/providers/default-models.ts | 21 ++++ .../workbench/src/surface/workbench.tsx | 3 + .../__tests__/new-session-surface.test.tsx | 95 ++++++++++++++++++- .../ui/src/shell/new-session-surface.tsx | 71 +++++++++----- .../presentation/ui/src/shell/shell-frame.tsx | 4 + 6 files changed, 170 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 545d0594..c0684084 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -80,6 +80,7 @@ export function DesktopShell({ attachmentSupport, agentCatalogs, newSessionDefaultModels, + newSessionDefaultAccounts, accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -429,6 +430,7 @@ export function DesktopShell({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} + defaultAccounts={newSessionDefaultAccounts} accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts index 1d5d1be5..22e64f96 100644 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ b/packages/client/workbench/src/settings/providers/default-models.ts @@ -26,6 +26,27 @@ export function useConfiguredDefaultModels(): Partial> return configuredDefaultModels(providers); } +/** The account each agent falls back to when a session names none. An agent absent here resolves + * its own credentials, so nothing about it is the account world's business. */ +export function configuredDefaultAccounts( + providers: ProvidersConfig | undefined, +): Partial> { + const defaults: Partial> = {}; + for (const kind of AgentKindSchema.options) { + const accountId = providers?.[kind]?.activeAccountId; + if (accountId !== undefined) defaults[kind] = accountId; + } + return defaults; +} + +/** Undefined until the config has loaded, so a draft never briefly treats an agent as running on + * its own login when it actually resolves through an account. */ +export function useConfiguredDefaultAccounts(): Partial> | undefined { + const { data: providers } = useData(getProviderConfig, {}); + if (providers === undefined) return undefined; + return configuredDefaultAccounts(providers); +} + /** * Every model this agent offers: the picked sets of each account that can back it *and* is enabled * for it. Choosing a model therefore also chooses its account, which is what lets one agent reach diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index 34be5cb7..d52d681c 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -60,6 +60,7 @@ import { useWorkbenchSdkClient } from '../runtime/provider'; import { useMutation } from '../runtime/tayori'; import { useAccountModelOptions, + useConfiguredDefaultAccounts, useConfiguredDefaultModels, } from '../settings/providers/default-models'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; @@ -245,6 +246,7 @@ function WorkbenchSessionSurface({ const currentPlan: CurrentPlan | null = selectCurrentPlan(conversation); const { mentionItems, onMentionQueryChange } = useFileMentionSource(); const newSessionDefaultModels = useConfiguredDefaultModels(); + const newSessionDefaultAccounts = useConfiguredDefaultAccounts(); const accountModels = useAccountModelOptions(); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; @@ -664,6 +666,7 @@ function WorkbenchSessionSurface({ newSessionWorkspaceId={newSessionWorkspaceId} onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} newSessionDefaultModels={newSessionDefaultModels} + newSessionDefaultAccounts={newSessionDefaultAccounts} accountModels={accountModels} agentCatalogs={agentCatalogs} newSessionPreferredEfforts={newSessionPreferredEfforts} diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 4a16c31e..e5032cd4 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -869,12 +869,13 @@ describe('NewSessionSurface', () => { ); }); - it("offers only the bound account's picked models, ignoring the curated table", async () => { + it("offers only the account world's picked models once an agent resolves through one", async () => { const user = userEvent.setup(); render( { expect(screen.queryByRole('menuitemradio', { name: 'Opus 5' })).toBeNull(); }); - it('refuses to send when an account is bound but no model is picked', async () => { + it('refuses to send when an agent resolves through an account but no model is picked', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { expect(onSubmit).not.toHaveBeenCalled(); }); + it('keeps an agent’s own catalog on offer when no account resolves for it', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); + expect(screen.getByRole('menuitemradio', { name: RE_PI_SONNET })).toBeTruthy(); + + // And it stays sendable: with no account resolving, the agent picks its own model. + await user.keyboard('{Escape}'); + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('does not let an adapter default unblock a send the daemon would refuse', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('pins no account on an untouched draft, leaving the agent’s default authoritative', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const shared = (accountId: string) => ({ id: 'shared-model', label: 'Shared', accountId }); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('accountId'); + }); + it('submits a model only after the user explicitly selects it', async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 0e4ee620..b6e4c36c 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -92,8 +92,12 @@ export interface NewSessionSurfaceProps { /** Effective user-configured model defaults. `null` means they are still loading; when omitted, * built-in harness defaults fill missing kinds for standalone consumers. */ defaultModels?: Readonly>> | null; - /** The models each agent may run on, picked on its bound account. An agent absent here has no - * account bound and falls back to its adapter catalog or the curated table. */ + /** The account each agent falls back to when a session names none. Its presence is what makes an + * agent resolve *through* an account: absent, the agent runs on its own CLI login and keeps its + * own catalog on offer. */ + defaultAccounts?: Readonly>>; + /** The models each agent offers, from every account enabled for it. An agent absent here has no + * account that can back it and falls back to its adapter catalog or the curated table. */ accountModels?: Readonly>> | null; /** Last accepted effort per harness. Missing kinds retain the harness default. */ preferredEfforts?: Readonly>>; @@ -134,6 +138,22 @@ function workspaceById( return null; } +/** + * The models a draft may pick from. An agent resolving through an account offers that account world + * alone. One running on its own login keeps its own catalog and merely *gains* the accounts as extra + * options — replacing it would let a key added for one agent hijack another's menu, and agents that + * accept any endpoint (opencode, pi) treat every account as bindable. + */ +function pickableModels( + accountSet: ModelOption[] | undefined, + ownCatalog: ModelOption[] | undefined, + { throughAccount }: { throughAccount: boolean }, +): ModelOption[] | undefined { + if (throughAccount) return accountSet; + if (accountSet === undefined) return ownCatalog; + return ownCatalog === undefined ? accountSet : [...accountSet, ...ownCatalog]; +} + /** Unified new-session page: heading + shared `Composer` + workspace context bar. Model, effort, * and workflow-mode picks ride into the submission; the session reflects them from then on. */ export function NewSessionSurface({ @@ -148,6 +168,7 @@ export function NewSessionSurface({ attachmentSupport, agentCatalogs, defaultModels, + defaultAccounts, accountModels, preferredEfforts, preferredBranches, @@ -189,23 +210,29 @@ export function NewSessionSurface({ const catalog = agentCatalogs?.[harness]; const localModel = selectedModels[harness]; const selectedModel = localModel === undefined ? null : localModel; - // The catalog default is what the agent's own config would start on, so it yields to anything the - // user expressed through LinkCode. Nothing guesses past it: an unresolved model blocks the send - // rather than starting a session on a model nobody chose. + // A default account means the agent resolves through one, and then its enabled accounts' picked + // sets are the only model source — the adapter's own default is not a candidate at all, and + // offering it would show a model the daemon then refuses. Without one the agent runs on its own + // login and keeps deciding for itself. + const defaultAccountId = defaultAccounts?.[harness]; + const throughAccount = defaultAccountId !== undefined; const displayedModel = selectedModel ?? - (defaultModels === null ? null : (defaultModels?.[harness] ?? catalog?.defaultModel ?? null)); + (defaultModels === null + ? null + : (defaultModels?.[harness] ?? (throughAccount ? null : (catalog?.defaultModel ?? null)))); const localEffort = selectedEfforts[harness]; const effort = localEffort === undefined ? (preferredEfforts?.[harness] ?? null) : localEffort; const dynamicModels = catalog && catalog.models.length > 0 ? catalog.models : null; - // Every account that can back this agent contributes, so a pick chooses the account too. The set - // outranks the adapter catalog and the curated table both, and an entry present here means at - // least one account is bindable — which is what makes a missing model fatal rather than the - // agent's own business. - const bindableSet = accountModels?.[harness]; - const pickable = bindableSet ?? dynamicModels ?? AGENT_MODEL_OPTIONS[harness]; + const accountSet = accountModels?.[harness]; + const pickable = pickableModels(accountSet, dynamicModels ?? AGENT_MODEL_OPTIONS[harness], { + throughAccount, + }); const localAccount = selectedAccounts[harness]; - const selectedAccountId = localAccount ?? undefined; + // Untouched, the draft reads against the agent's own default account, so a model id two accounts + // both serve resolves to the right entry instead of whichever comes first in the pool. + const selectedAccountId = + localAccount === undefined ? defaultAccountId : (localAccount ?? undefined); const modelOption = resolveModel(pickable, displayedModel, selectedAccountId); const effortLevels = modelOption?.effortLevels; const constrainedEffort = @@ -243,10 +270,10 @@ export function NewSessionSurface({ cwd: selected.cwd, workspaceId: selected.workspaceId, model: localModel === null ? undefined : (selectedModel ?? undefined), - // Pins the session to the account whose entry was picked; without it the daemon would fall - // back to whichever account happens to be bound. - ...(localModel !== null && - modelOption?.accountId !== undefined && { accountId: modelOption.accountId }), + // Only an account the user actually picked pins the session. Deriving one from an untouched + // draft would pin whichever account happens to list that model id first, quietly overriding + // the agent's own default when two accounts serve the same id. + ...(typeof localAccount === 'string' && { accountId: localAccount }), ...(localEffort === null ? { effort: null } : constrainedEffort !== null && { effort: constrainedEffort }), @@ -377,12 +404,10 @@ export function NewSessionSurface({ mentionItems={mentionItems} onMentionQueryChange={(query) => onMentionQueryChange(selected?.cwd, query)} runtimeCues={runtimeCues} - // With an account bound, its set is the only model source, so an unresolved model would - // be refused by the daemon anyway — refuse here instead of after a round trip. An agent - // with no account bound still resolves its own, and must not be blocked. - sendBlocked={ - cue !== undefined || (bindableSet !== undefined && displayedModel === null) - } + // Mirrors the daemon: once an account resolves, its set is the only model source, so an + // unresolved model would be refused anyway — refuse here instead of after a round trip. + // An agent with no account resolving still picks its own model, and must not be blocked. + sendBlocked={cue !== undefined || (throughAccount && displayedModel === null)} currentModeId={modeId} currentModel={displayedModel} currentEffort={displayedEffort} diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 6d908c00..c273823f 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -58,6 +58,8 @@ export interface ShellFrameProps agentCatalogs?: AgentStartCatalogs; /** Effective daemon-configured default models for new sessions; null while unresolved. */ newSessionDefaultModels: Readonly>> | null; + /** The account each agent falls back to when a session names none. */ + newSessionDefaultAccounts?: Readonly>>; /** The models each agent may run on, picked on its bound account. An agent absent here has no * account bound and keeps falling back to whatever its adapter or the curated table advertises. */ accountModels: Readonly>> | null; @@ -133,6 +135,7 @@ export function ShellFrame({ attachmentSupport, agentCatalogs, newSessionDefaultModels, + newSessionDefaultAccounts, accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -224,6 +227,7 @@ export function ShellFrame({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} + defaultAccounts={newSessionDefaultAccounts} accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} From 8554d418b270d7abfed8b2212cc57711f00db6fe Mon Sep 17 00:00:00 2001 From: Peron Date: Sat, 8 Aug 2026 17:55:20 +0800 Subject: [PATCH 20/32] fix(engine): record a mid-run model pick, and let a stale account pin fall back --- .../__tests__/engine-session-records.test.ts | 104 ++++++++++++++++++ .../host/engine/src/agent/provider-config.ts | 28 +++-- .../src/session/session-event-processor.ts | 5 + .../src/session/session-record-registry.ts | 11 ++ 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index bab1a1aa..024d80a2 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -1007,6 +1007,110 @@ describe('session account attribution', () => { }); describe('a session keeps its own pick', () => { + it('replays a model picked mid-run, not the one the run launched with', async () => { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_one', model: 'model-a' }, + }, + accounts: [ + { + id: 'acc_one', + label: 'One', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-one' }, + models: [{ id: 'model-a' }, { id: 'model-b' }], + createdAt: 0, + }, + ], + }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + + // Same account, so this switches in place and launches nothing — the ordinary way a user + // changes model. The adapter reflects the id it actually serves. + await h.inject({ + kind: 'agent.input', + clientReqId: 'pick', + sessionId, + input: { type: 'set-model', model: 'model-b', accountId: 'acc_one' }, + }); + h.adapters[0].emit({ type: 'model-update', model: 'model-b' }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('model-b'); + }); + + it('falls back to the agent’s default when the pinned account has been deleted', async () => { + const providers = new InMemoryProviderConfigStore(); + const surviving = { + id: 'acc_default', + label: 'Default', + service: 'anthropic-api' as const, + credential: { type: 'api-key' as const, key: 'sk-default' }, + models: [{ id: 'model-default' }], + createdAt: 0, + }; + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_default', model: 'model-default' }, + }, + accounts: [ + surviving, + { + id: 'acc_doomed', + label: 'Doomed', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-doomed' }, + models: [{ id: 'model-doomed' }], + createdAt: 0, + }, + ], + }); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + providers, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { + kind: 'claude-code', + cwd: '/repo', + model: 'model-doomed', + config: { accountId: 'acc_doomed' }, + }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + + // The run's pin now names an account that no longer exists. Replaying it verbatim would start + // the agent with no credential at all, and the thread could never recover. + providers.update({ accounts: [surviving] }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.config?.apiKey).toBe('sk-default'); + expect(resumed.resumedWith?.config?.accountId).toBe('acc_default'); + }); + it('resumes on the run’s account and model after the daemon default moved', async () => { const providers = new InMemoryProviderConfigStore(); providers.update({ diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 0613d02b..285c2adb 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -77,8 +77,10 @@ export function accountBinding( /** * Resolve the session's account: explicit `opts.config.accountId`, else the agent's - * `activeAccountId`. Undefined when neither resolves or the id is stale (account deleted) — - * the caller then falls back to the legacy `providers[kind].apiKey`. + * `activeAccountId`. A requested id that no longer resolves falls through to that default rather + * than stranding the session — a relaunch replays a pin recorded on the run, and the account it + * names can be deleted in between. Undefined when neither resolves, which leaves the caller on the + * legacy `providers[kind].apiKey`. */ function resolveAccount( opts: StartOptions, @@ -87,9 +89,12 @@ function resolveAccount( ): Account | undefined { const requestedId = typeof opts.config?.accountId === 'string' ? opts.config.accountId : undefined; - const id = requestedId ?? config?.activeAccountId; - if (id === undefined) return undefined; - return accounts.find((account) => account.id === id); + for (const id of [requestedId, config?.activeAccountId]) { + if (id === undefined) continue; + const account = accounts.find((candidate) => candidate.id === id); + if (account) return account; + } + return undefined; } /** The adapter-facing bundle an account contributes to `StartOptions.config`; each adapter maps @@ -149,9 +154,16 @@ export function applyProviderDefaults( const resolved = accountConfigBundle(account, opts.kind); if ('unavailable' in resolved) return { options: next, unavailable: resolved.unavailable }; next.config = { ...next.config, ...resolved.bundle }; - } else if (config?.apiKey !== undefined) { - // Legacy: no account bound — fall back to the provider's bare api key. - next.config = { ...next.config, apiKey: config.apiKey }; + } else { + // Nothing resolved, so an id left in `config` would claim an account the session does not have: + // `resolvedAccountId` reads it, the caller treats the account as bound, and the session starts + // with no credential at all. + if (typeof next.config?.accountId === 'string') { + const { accountId: _stale, ...rest } = next.config; + next.config = rest; + } + // Legacy: no account at all — fall back to the provider's bare api key. + if (config?.apiKey !== undefined) next.config = { ...next.config, apiKey: config.apiKey }; } return { options: next }; } diff --git a/packages/host/engine/src/session/session-event-processor.ts b/packages/host/engine/src/session/session-event-processor.ts index a8e1f455..24ea837a 100644 --- a/packages/host/engine/src/session/session-event-processor.ts +++ b/packages/host/engine/src/session/session-event-processor.ts @@ -102,6 +102,11 @@ export class SessionEventProcessor { case 'session-ref': this.records.bindHistoryId(sessionId, event.historyId); break; + // Adapters emit this only once a switch is accepted, and with the id they actually serve — + // so recording it here, rather than where a pick is sent, is what a relaunch should replay. + case 'model-update': + this.records.setRunModel(sessionId, event.model); + break; case 'title-update': this.records.setProviderTitle(sessionId, event.title); break; diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 350b2a40..7563bc62 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -132,6 +132,17 @@ export class SessionRecordRegistry { this.onChanged(sessionId, 'updated'); } + /** Record the model the newest run is now on. A pick accepted mid-run never launches anything, so + * without this a relaunch replays the model the run started with and silently drops it. Not an + * identity change — `SessionInfo` does not project the model — so it notifies nobody. */ + setRunModel(sessionId: SessionId, model: string): void { + const record = this.records.get(sessionId); + const run = record?.runs.at(-1); + if (!record || !run || run.model === model) return; + run.model = model; + this.persist(record); + } + sealCurrentRun(sessionId: SessionId): void { const record = this.records.get(sessionId); const run = record?.runs.at(-1); From f91b7235f13ebc553e227afb079cc3baace9f1b0 Mon Sep 17 00:00:00 2001 From: Peron Date: Sat, 8 Aug 2026 17:57:34 +0800 Subject: [PATCH 21/32] fix(ui): keep a live thread's own catalog when it runs on its own login --- .../src/settings/providers/default-models.ts | 11 +++++----- .../workbench/src/settings/providers/view.ts | 3 ++- .../ui/src/__tests__/agent-models.test.ts | 20 +++++++++++++++++++ .../presentation/ui/src/shell/agent-models.ts | 18 +++++++++++++++++ .../ui/src/shell/conversation-surface.tsx | 8 ++++++-- .../ui/src/shell/new-session-surface.tsx | 18 +---------------- .../presentation/ui/src/shell/shell-frame.tsx | 4 ++-- 7 files changed, 54 insertions(+), 28 deletions(-) diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts index 22e64f96..f9826add 100644 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ b/packages/client/workbench/src/settings/providers/default-models.ts @@ -57,12 +57,11 @@ export function useConfiguredDefaultAccounts(): Partial { }); }); +describe('pickableModels', () => { + const account = [{ id: 'deepseek-v4-pro', label: 'DeepSeek', accountId: 'acc_x' }]; + const own = [{ id: 'opencode/native', label: 'Native' }]; + + it('keeps an agent’s own catalog when it does not resolve through an account', () => { + // opencode and pi accept any endpoint, so a key added for another agent is "bindable" to them; + // replacing here would hand an unrelated vendor's models to a thread on its own CLI login. + expect(pickableModels(account, own, { throughAccount: false })).toEqual([...account, ...own]); + expect(pickableModels(undefined, own, { throughAccount: false })).toEqual(own); + expect(pickableModels(account, null, { throughAccount: false })).toEqual(account); + }); + + it('lets the account world stand alone once one resolves', () => { + expect(pickableModels(account, own, { throughAccount: true })).toEqual(account); + // Present-and-empty is a real answer — "this account offers nothing" — not a missing one. + expect(pickableModels([], own, { throughAccount: true })).toEqual([]); + }); +}); + describe('switchesAccount', () => { const onSecond = { id: 'model-a', label: 'A', accountId: 'acc_second' }; diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/shell/agent-models.ts index dd0b7af1..0705b3a3 100644 --- a/packages/presentation/ui/src/shell/agent-models.ts +++ b/packages/presentation/ui/src/shell/agent-models.ts @@ -30,6 +30,24 @@ export function modelChoiceKey(option: ModelOption): string { return `${option.accountId ?? ''}:${option.id}`; } +/** + * The models a surface may offer. An agent resolving *through* an account offers that account world + * alone. One running on its own login keeps its own catalog and merely *gains* the accounts as extra + * options — replacing it would let a key added for one agent hijack another's menu, since agents + * that accept any endpoint (opencode, pi) treat every account as bindable. + */ +export function pickableModels( + accountSet: ModelOption[] | null | undefined, + ownCatalog: ModelOption[] | null | undefined, + { throughAccount }: { throughAccount: boolean }, +): ModelOption[] | undefined { + // Null and absent both mean "this source offers nothing"; only present-and-empty is a real set. + if (throughAccount) return accountSet ?? undefined; + if (accountSet === null || accountSet === undefined) return ownCatalog ?? undefined; + if (ownCatalog === null || ownCatalog === undefined) return accountSet; + return [...accountSet, ...ownCatalog]; +} + /** Whether picking this entry leaves the account a session is currently running on. Credentials and * base URL are injected once at spawn, so such a pick relaunches the agent rather than rebinding it * in place. Unknown accounts on either side mean the question doesn't apply. */ diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index acb61730..30d7715f 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -8,6 +8,7 @@ import { ConversationView } from '../chat/conversation-view'; import type { ConversationViewModel, PromptEditState } from '../chat/types'; import { cn } from '../lib/cn'; import type { ModelOption } from './agent-models'; +import { pickableModels } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './composer'; @@ -192,8 +193,11 @@ export function ConversationSurface({ currentModel={conversation.currentModel} currentEffort={conversation.currentEffort} // The session account's picked set is the user's own answer to "which models may this run - // on", so it outranks both the adapter catalog and the curated table. - agentModels={accountModels ?? conversation.availableModels} + // on", so it outranks the adapter catalog — but only for a thread that resolved through an + // account at all. One on its own CLI login keeps its own catalog on offer. + agentModels={pickableModels(accountModels, conversation.availableModels, { + throughAccount: accountId !== undefined, + })} currentAccountId={accountId} // Live thread: leaving its account means relaunching the agent, which the menu says out // loud rather than letting a process restart happen invisibly. diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index b6e4c36c..27fedb29 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -36,7 +36,7 @@ import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; import type { ModelOption } from './agent-models'; -import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; +import { AGENT_MODEL_OPTIONS, pickableModels, resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; @@ -138,22 +138,6 @@ function workspaceById( return null; } -/** - * The models a draft may pick from. An agent resolving through an account offers that account world - * alone. One running on its own login keeps its own catalog and merely *gains* the accounts as extra - * options — replacing it would let a key added for one agent hijack another's menu, and agents that - * accept any endpoint (opencode, pi) treat every account as bindable. - */ -function pickableModels( - accountSet: ModelOption[] | undefined, - ownCatalog: ModelOption[] | undefined, - { throughAccount }: { throughAccount: boolean }, -): ModelOption[] | undefined { - if (throughAccount) return accountSet; - if (accountSet === undefined) return ownCatalog; - return ownCatalog === undefined ? accountSet : [...accountSet, ...ownCatalog]; -} - /** Unified new-session page: heading + shared `Composer` + workspace context bar. Model, effort, * and workflow-mode picks ride into the submission; the session reflects them from then on. */ export function NewSessionSurface({ diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index c273823f..6e2d3036 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -60,8 +60,8 @@ export interface ShellFrameProps newSessionDefaultModels: Readonly>> | null; /** The account each agent falls back to when a session names none. */ newSessionDefaultAccounts?: Readonly>>; - /** The models each agent may run on, picked on its bound account. An agent absent here has no - * account bound and keeps falling back to whatever its adapter or the curated table advertises. */ + /** The models each agent may run on, pooled from the accounts enabled for it. An agent absent here + * has no account that can back it and falls back to its adapter or the curated table. */ accountModels: Readonly>> | null; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; From edda1063500758b518a3e98121ff1f8678dd3c98 Mon Sep 17 00:00:00 2001 From: Peron Date: Mon, 10 Aug 2026 10:31:01 +0800 Subject: [PATCH 22/32] refactor(schema,engine,workbench): pin a thread by its own picks, not by what resolved for it Two fields each carried a request and an observation: `StartOptions.config.accountId` was both the client's pin and the resolver's echo of what resolved, and `SessionRun.model` was documented as what a run resolved to while `pinnedOptions` replayed it as a choice. Splitting both removes the patches that reconciled them. --- .../src/surface/use-workbench-sessions.ts | 6 +- .../foundation/schema/src/model/account.ts | 4 +- .../schema/src/model/agent/input.ts | 5 + .../schema/src/model/session/record.ts | 19 ++- .../foundation/schema/src/wire/message.ts | 4 +- packages/host/agent-adapter/AGENTS.md | 2 +- .../__tests__/engine-agent-catalog.test.ts | 1 - .../__tests__/engine-session-records.test.ts | 102 +++++++++++++++- .../src/__tests__/provider-config.test.ts | 45 ++++--- .../host/engine/src/agent/provider-config.ts | 60 ++++------ .../engine/src/session/lifecycle-service.ts | 110 +++++++++++++----- .../engine/src/session/request-handler.ts | 10 +- .../src/session/session-event-processor.ts | 5 - .../src/session/session-record-registry.ts | 83 ++++++++----- .../src/session/start-options-resolver.ts | 18 ++- 15 files changed, 322 insertions(+), 152 deletions(-) diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index 01664162..8be49146 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -207,15 +207,11 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench // Captured now: by resolve time the surface still shows the draft, and the recorded // transition should be draft → new thread. const from = currentLocation; - // Pins the session to the account the picked model belongs to. The daemon merges its own - // credential bundle over this, so only the account choice travels from the client. - const { accountId, ...rest } = opts; - const startOptions = accountId === undefined ? rest : { ...rest, config: { accountId } }; // Rejections propagate to the caller (the new-session page stays up); onError above still // reports them via the error banner. let sessionId: SessionId; try { - const result = await createMutation.trigger({ opts: startOptions }); + const result = await createMutation.trigger({ opts }); sessionId = result.sessionId; showMcpWarnings(result.mcpWarnings, tMcpWarnings); } catch (error) { diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index dacc776c..920ad5c4 100644 --- a/packages/foundation/schema/src/model/account.ts +++ b/packages/foundation/schema/src/model/account.ts @@ -4,7 +4,7 @@ import { AgentKindSchema, TimestampSchema } from './primitives'; /** * A model-provider credential in the global account pool (data plane). The daemon persists these * in ~/.linkcode/config.json (0600) and injects one into the adapter at session start: whichever - * `StartOptions.config.accountId` names, or the agent's `activeAccountId` when nothing does. One + * `StartOptions.accountId` names, or the agent's `activeAccountId` when nothing does. One * credential can back several agents — natively when its endpoint speaks the agent's protocol, via * conversion otherwise — and several accounts can serve one agent at the same time. */ @@ -49,7 +49,7 @@ export const AccountModelSchema = z.object({ export type AccountModel = z.infer; export const AccountSchema = z.object({ - /** Stable id referenced by `providers[kind].activeAccountId` and `StartOptions.config.accountId`. */ + /** Stable id referenced by `providers[kind].activeAccountId` and `StartOptions.accountId`. */ id: z.string().min(1), /** User-facing name. */ label: z.string().min(1), diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index be0fa84b..4af26ac1 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -68,6 +68,11 @@ export const StartOptionsSchema = z.object({ * (`ProviderConfig.model`); if that is unset too, the session refuses to start rather than * letting the agent choose for itself. */ model: z.string().optional(), + /** The account the model was picked from, which outranks the agent's `activeAccountId` fallback. + * A request only: resolution consumes it, injects the credential bundle into `config`, and reports + * the account that actually resolved separately — so an id naming a deleted account can never read + * back as "an account is backing this run". */ + accountId: z.string().min(1).optional(), /** Initial session mode (e.g. plan / accept-edits), if the agent advertises modes. */ modeId: SessionModeIdSchema.optional(), /** Initial reasoning effort, if the selected adapter supports effort. */ diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index 8e4f9cfa..a09c4d02 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { EffortLevelSchema } from '../agent/input'; import { AgentHistoryCapabilitiesSchema } from '../history'; import { ImPlatformSchema } from '../im'; import { @@ -7,6 +8,7 @@ import { SessionIdSchema, TimestampSchema, } from '../primitives'; +import { ApprovalPolicyIdSchema } from './control'; import { SessionStatusSchema } from './lifecycle'; /** @@ -32,16 +34,25 @@ export const SessionOriginSchema = z.discriminatedUnion('type', [ ]); export type SessionOrigin = z.infer; -/** One live start/resume of a session. Providers usually mint a new native id per resume, so a - * session accumulates runs; `historyId` is backfilled once the adapter reports it (session-ref). */ +/** + * One live start/resume of a session. Providers usually mint a new native id per resume, so a + * session accumulates runs; `historyId` is backfilled once the adapter reports it (session-ref). + * + * Everything below `historyId` is what the thread is *set to* — the choices it launched with plus + * every pick accepted since — and a relaunch replays them so the thread keeps them when the + * configured default moves. What an adapter resolved for itself is deliberately absent: recording + * that would pin every thread to its first launch and cut it off from the agent's default for good. + */ export const SessionRunSchema = z.object({ historyId: AgentHistoryIdSchema.optional(), /** The account this run resolved to. Credentials and base URL are injected once at spawn, so the * account is fixed for the run's lifetime and a later rebind does not move it. */ accountId: z.string().min(1).optional(), - /** The model this run resolved to. Recorded with the account because the two are one choice, and - * read back on relaunch: a thread keeps its own pick instead of adopting a default that moved. */ + /** Recorded with the account because the two are one choice. */ model: z.string().min(1).optional(), + /** Both axes live on the adapter a relaunch destroys, so they are replayed from here or lost. */ + effort: EffortLevelSchema.optional(), + approvalPolicyId: ApprovalPolicyIdSchema.optional(), startedAt: TimestampSchema, endedAt: TimestampSchema.optional(), }); diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 759512b5..108ccf86 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,11 +9,11 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 74 as const; +export const WIRE_PROTOCOL_VERSION = 75 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ -export const MIN_COMPATIBLE_WIRE_VERSION = 74 as const; +export const MIN_COMPATIBLE_WIRE_VERSION = 75 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index ef046143..485a07e0 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -113,7 +113,7 @@ Product code must branch on `historyCapabilities` — never assume an op is supp levels (Claude `max`) and live-switchable levels share validation and reflection behavior. The engine caches the emitted effort and replays it when the newly created session attaches. -- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one); which it *falls back to* when a session names none is `activeAccountId`, used by automation, schedules, and IM threads. Sessions started from a picker carry `config.accountId` and ignore the fallback. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records the model and account it resolved to, and a relaunch reads them back, so a thread keeps its own pick even after the fallback moves. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. +- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one); which it *falls back to* when a session names none is `activeAccountId`, used by automation, schedules, and IM threads. Sessions started from a picker carry `StartOptions.accountId` and ignore the fallback; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to the agent's default instead of starting a session with no credential. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the fallback moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and the agent's configured default could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env. - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** Precedence: model-ref (`providerID/modelID`, which decides routing) → for pi, the resumed session's own last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in the agent's own catalog, from `@linkcode/providers`) → for pi, its first available provider. Before `knownProvider` existed a bare model id left the credential uninjected entirely; putting it ahead of the resumed provider instead strands a resumed session on a provider that never got the key. - **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built). diff --git a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts index d8489422..d587fdec 100644 --- a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts +++ b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts @@ -62,7 +62,6 @@ describe('engine agent catalog', () => { cwd: '/repo', model: 'provider/model', config: { - accountId: 'catalog-account', apiKey: 'catalog-key', baseUrl: 'https://catalog.example.test', protocol: 'openai-chat', diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 024d80a2..63113959 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -25,6 +25,15 @@ import { settleEngineTasks as tick, } from './fixtures/session-harness'; +/** An adapter that takes the input and then refuses it, like one asked for an unsupported level. */ +class PickyAdapter extends FakeAdapter { + override send(input: Parameters[0]) { + return super.send(input).then(() => { + throw new Error('claude-code: effort refused'); + }); + } +} + class CwdlessHistoryAdapter extends FakeAdapter { override readHistory(opts: AgentHistoryReadOptions) { return Promise.resolve({ @@ -997,7 +1006,7 @@ describe('session account attribution', () => { kind: 'claude-code', cwd: '/repo', model: 'claude-sonnet-5', - config: { accountId: 'acc_pinned' }, + accountId: 'acc_pinned', }, }); await h.inject({ kind: 'session.list', clientReqId: 'r2' }); @@ -1036,14 +1045,13 @@ describe('a session keeps its own pick', () => { h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); // Same account, so this switches in place and launches nothing — the ordinary way a user - // changes model. The adapter reflects the id it actually serves. + // changes model. The accepted pick is what gets recorded, not anything the adapter reflects. await h.inject({ kind: 'agent.input', clientReqId: 'pick', sessionId, input: { type: 'set-model', model: 'model-b', accountId: 'acc_one' }, }); - h.adapters[0].emit({ type: 'model-update', model: 'model-b' }); await tick(); await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); @@ -1051,6 +1059,88 @@ describe('a session keeps its own pick', () => { expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('model-b'); }); + it('leaves a model the adapter resolved for itself out of the pin', async () => { + const providers = new InMemoryProviderConfigStore(); + providers.update({ providers: { 'claude-code': { enabled: true } } }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + // What the CLI resolved on its own, reflected at launch: display state, not a choice anyone made. + h.adapters[0].emit({ type: 'model-update', model: 'adapter-choice' }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + + // Recording that reflection would pin the thread to its first launch, and the agent's default + // could never reach it again. + providers.update({ providers: { 'claude-code': { enabled: true, model: 'configured' } } }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('configured'); + expect((await store.load())[0].runs[0].model).toBeUndefined(); + }); + + it('replays the effort and approval tier picked on the live session', async () => { + const h = harness(new InMemorySessionStore()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + // Both axes live on the adapter, so a relaunch is where they get lost. + await h.inject({ + kind: 'agent.input', + clientReqId: 'effort', + sessionId, + input: { type: 'set-effort', effort: 'xhigh' }, + }); + await h.inject({ + kind: 'agent.input', + clientReqId: 'policy', + sessionId, + input: { type: 'set-approval-policy', policyId: 'acceptEdits' }, + }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.effort).toBe('xhigh'); + expect(resumed.resumedWith?.approvalPolicyId).toBe('acceptEdits'); + }); + + it('records nothing when the session refuses the pick', async () => { + const store = new InMemorySessionStore(); + const h = harness(store, () => new PickyAdapter()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'effort', + sessionId, + input: { type: 'set-effort', effort: 'max' }, + }); + await tick(); + + expect(h.adapters[0].sentInputs).toContainEqual({ type: 'set-effort', effort: 'max' }); + expect((await store.load())[0].runs[0].effort).toBeUndefined(); + }); + it('falls back to the agent’s default when the pinned account has been deleted', async () => { const providers = new InMemoryProviderConfigStore(); const surviving = { @@ -1093,7 +1183,7 @@ describe('a session keeps its own pick', () => { kind: 'claude-code', cwd: '/repo', model: 'model-doomed', - config: { accountId: 'acc_doomed' }, + accountId: 'acc_doomed', }, }); const sessionId = startedId(h.sent, 'r1'); @@ -1108,7 +1198,9 @@ describe('a session keeps its own pick', () => { const resumed = nullthrow(h.adapters.at(-1)); expect(resumed.resumedWith?.config?.apiKey).toBe('sk-default'); - expect(resumed.resumedWith?.config?.accountId).toBe('acc_default'); + // The new run records the account that actually backed it, so the thread's pin recovers too. + await h.inject({ kind: 'session.list', clientReqId: 'r4' }); + expect(listedSessions(h.sent, 'r4')[0]?.accountId).toBe('acc_default'); }); it('resumes on the run’s account and model after the daemon default moved', async () => { diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index ce49777f..b82264a9 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -7,7 +7,7 @@ const baseOpts: StartOptions = { kind: 'codex', cwd: '/repo' }; describe('applyProviderDefaults', () => { it('returns the input untouched when no config exists for the kind', () => { const providers: ProvidersConfig = { 'claude-code': { enabled: true, apiKey: 'sk-x' } }; - expect(applyProviderDefaults(baseOpts, providers).options).toBe(baseOpts); + expect(applyProviderDefaults(baseOpts, providers).options).toEqual(baseOpts); }); it('fills the persisted pick only when the client did not specify one', () => { @@ -44,13 +44,14 @@ describe('applyProviderDefaults account pool', () => { it('injects the credential from the account bound via activeAccountId', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; - expect(applyProviderDefaults(baseOpts, providers, [account]).options.config).toEqual({ - accountId: 'acc_1', - apiKey: 'sk-acc', - }); + const merged = applyProviderDefaults(baseOpts, providers, [account]); + expect(merged.options.config).toEqual({ apiKey: 'sk-acc' }); + // Reported, not echoed into the adapter-facing config: the caller records what actually backed + // the run, and nothing downstream can mistake a request for a resolution. + expect(merged.accountId).toBe('acc_1'); }); - it('lets an explicit opts.config.accountId override activeAccountId', () => { + it('lets an explicit opts.accountId override activeAccountId, and consumes it', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; const other: Account = { id: 'acc_2', @@ -58,12 +59,24 @@ describe('applyProviderDefaults account pool', () => { credential: { type: 'api-key', key: 'sk-other' }, createdAt: 0, }; - const merged = applyProviderDefaults( - { ...baseOpts, config: { accountId: 'acc_2' } }, - providers, - [account, other], - ); + const merged = applyProviderDefaults({ ...baseOpts, accountId: 'acc_2' }, providers, [ + account, + other, + ]); expect(merged.options.config).toMatchObject({ apiKey: 'sk-other' }); + expect(merged.accountId).toBe('acc_2'); + expect(merged.options.accountId).toBeUndefined(); + }); + + it('reports no account for a requested id that no longer resolves, whatever the agent has', () => { + const stale: StartOptions = { ...baseOpts, accountId: 'deleted' }; + // No entry for the kind at all: the request's own id is the only account-shaped thing in play, + // and it must not survive as one. + for (const providers of [{}, { codex: { enabled: true } }] satisfies ProvidersConfig[]) { + const merged = applyProviderDefaults(stale, providers, [account]); + expect(merged.accountId).toBeUndefined(); + expect(merged.options.accountId).toBeUndefined(); + } }); it('injects authToken, baseUrl and protocol for an auth-token account with an endpoint', () => { @@ -77,7 +90,6 @@ describe('applyProviderDefaults account pool', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'gw' } }; const merged = applyProviderDefaults(baseOpts, providers, [gateway]); expect(merged.options.config).toEqual({ - accountId: 'gw', authToken: 'or-tok', baseUrl: 'https://relay.example.com/v1', protocol: 'openai-responses', @@ -109,7 +121,6 @@ describe('applyProviderDefaults account pool', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oa' } }; // Codex overrides the base URL of its own Responses provider, so it carries no knownProvider. expect(applyProviderDefaults(baseOpts, providers, [openai]).options.config).toEqual({ - accountId: 'oa', apiKey: 'sk-oa', baseUrl: 'https://api.openai.com/v1', protocol: 'openai-responses', @@ -151,10 +162,10 @@ describe('applyProviderDefaults account pool', () => { createdAt: 0, }; const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oauth_1' } }; - // The account still resolves — it just contributes no secret, only its id. - expect(applyProviderDefaults(baseOpts, providers, [oauth]).options.config).toEqual({ - accountId: 'oauth_1', - }); + // The account still resolves — it just contributes nothing for the adapter to read. + const merged = applyProviderDefaults(baseOpts, providers, [oauth]); + expect(merged.options.config).toEqual({}); + expect(merged.accountId).toBe('oauth_1'); }); }); diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 285c2adb..6ecf5bb6 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -76,20 +76,18 @@ export function accountBinding( } /** - * Resolve the session's account: explicit `opts.config.accountId`, else the agent's - * `activeAccountId`. A requested id that no longer resolves falls through to that default rather - * than stranding the session — a relaunch replays a pin recorded on the run, and the account it - * names can be deleted in between. Undefined when neither resolves, which leaves the caller on the - * legacy `providers[kind].apiKey`. + * Resolve the session's account: explicit `opts.accountId`, else the agent's `activeAccountId`. A + * requested id that no longer resolves falls through to that default rather than stranding the + * session — a relaunch replays a pin recorded on the run, and the account it names can be deleted in + * between. Undefined when neither resolves, which leaves the caller on the legacy + * `providers[kind].apiKey`. */ function resolveAccount( opts: StartOptions, config: ProviderConfig | undefined, accounts: Accounts, ): Account | undefined { - const requestedId = - typeof opts.config?.accountId === 'string' ? opts.config.accountId : undefined; - for (const id of [requestedId, config?.activeAccountId]) { + for (const id of [opts.accountId, config?.activeAccountId]) { if (id === undefined) continue; const account = accounts.find((candidate) => candidate.id === id); if (account) return account; @@ -107,9 +105,7 @@ function accountConfigBundle( ): { bundle: Record } | { unavailable: BindingUnavailableReason } { const binding = resolveBinding(account, kind); if (binding.tier === 'unavailable') return { unavailable: binding.reason }; - // Echoed back so the caller can record which account a run actually resolved to; `resolveAccount` - // reads the same key on the way in, which is how a client pins a session to one account. - const bundle: Record = { accountId: account.id }; + const bundle: Record = {}; const { credential, extraEnv } = account; if (credential.type === 'api-key') bundle.apiKey = credential.key; else if (credential.type === 'auth-token') bundle.authToken = credential.token; @@ -120,24 +116,20 @@ function accountConfigBundle( return { bundle }; } -/** The account a resolved `StartOptions` names — written by `accountConfigBundle`, or pinned by a - * client that picked a model belonging to a specific account. Callers record it per run; the rest of - * `config` carries secrets and must never be persisted. */ -export function resolvedAccountId(opts: StartOptions): string | undefined { - const id = opts.config?.accountId; - return typeof id === 'string' && id.length > 0 ? id : undefined; -} - export interface AppliedProviderDefaults { readonly options: StartOptions; + /** The account whose bundle was injected — the request's pick when it still resolves, else the + * agent's default. Present only when a credential/endpoint bundle actually landed, so callers can + * treat it as "an account is backing this run" rather than a claim the request made. */ + readonly accountId?: string; /** Why the bound account cannot back this agent. A session must refuse to start rather than * run against an endpoint the agent cannot speak; pre-session reads may ignore it. */ readonly unavailable?: BindingUnavailableReason; } -/** Apply the stored config to a session's StartOptions: resolve the bound account (or legacy - * per-agent api key), inject the credential/endpoint bundle into `config`, and fall back to the - * agent's persisted model pick. Returns a new object; never mutates the input. */ +/** Apply the stored config to a session's StartOptions: resolve the account (or legacy per-agent api + * key), inject the credential/endpoint bundle into `config`, and fall back to the agent's persisted + * model pick. Returns a new object; never mutates the input. */ export function applyProviderDefaults( opts: StartOptions, providers: ProvidersConfig, @@ -145,25 +137,21 @@ export function applyProviderDefaults( ): AppliedProviderDefaults { const config = providers[opts.kind]; const account = resolveAccount(opts, config, accounts); - if (!config && !account) return { options: opts }; - - const next: StartOptions = { ...opts }; + // The request's pick never survives resolution: `config` carries what the adapter reads, and the + // account that actually resolved is this function's answer. A stale id therefore cannot travel + // downstream and read back as an account the session does not have. + const { accountId: _requested, ...next } = { ...opts }; // The account holds the models the user may pick from; the pick itself is per agent. if (next.model === undefined && config?.model !== undefined) next.model = config.model; if (account) { const resolved = accountConfigBundle(account, opts.kind); if ('unavailable' in resolved) return { options: next, unavailable: resolved.unavailable }; - next.config = { ...next.config, ...resolved.bundle }; - } else { - // Nothing resolved, so an id left in `config` would claim an account the session does not have: - // `resolvedAccountId` reads it, the caller treats the account as bound, and the session starts - // with no credential at all. - if (typeof next.config?.accountId === 'string') { - const { accountId: _stale, ...rest } = next.config; - next.config = rest; - } - // Legacy: no account at all — fall back to the provider's bare api key. - if (config?.apiKey !== undefined) next.config = { ...next.config, apiKey: config.apiKey }; + return { + options: { ...next, config: { ...next.config, ...resolved.bundle } }, + accountId: account.id, + }; } + // Legacy: no account at all — fall back to the provider's bare api key. + if (config?.apiKey !== undefined) next.config = { ...next.config, apiKey: config.apiKey }; return { options: next }; } diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index ae06ff2e..45293e9d 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -15,7 +15,6 @@ import type { } from '@linkcode/schema'; import { Effect, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; -import { resolvedAccountId } from '../agent/provider-config'; import type { SessionDriver } from '../automation'; import type { EngineFailure } from '../failure'; import { RequestError, toOperationFailure } from '../failure'; @@ -24,7 +23,11 @@ import type { WorktreeService } from '../worktree/worktree-service'; import type { HistoryService } from './history-service'; import { decodeLiveBranchCursor } from './live-session'; import type { SessionOrchestrator } from './orchestrator'; -import type { SessionRecordRegistry } from './session-record-registry'; +import type { + SessionPin, + SessionRecordRegistry, + SessionRunIntent, +} from './session-record-registry'; import type { ResolvedStartOptions, SessionStartOptionsResolver } from './start-options-resolver'; type RunEffect = (effect: Effect.Effect, options?: Effect.RunOptions) => Promise; @@ -88,7 +91,11 @@ export class SessionLifecycleService { const { sessions, startOptions, workspaces, worktrees } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const { options: resolvedIntent, warnings } = yield* startOptions.resolve(options, sessionId); + const { + options: resolvedIntent, + accountId, + warnings, + } = yield* startOptions.resolve(options, sessionId); const resolved = yield* worktrees.provision(resolvedIntent, sessionId); if (options.cwd) { const parent = yield* workspaceTouch(workspaces, options.cwd); @@ -104,7 +111,7 @@ export class SessionLifecycleService { createdVia: resolved.createdVia, createdAt: now, updatedAt: now, - runs: [{ startedAt: now, ...runOf(resolved) }], + runs: [{ startedAt: now, ...runOf(resolved, accountId) }], }; yield* sessions.startLive( replyTo, @@ -161,10 +168,11 @@ export class SessionLifecycleService { const { history, sessions, startOptions: resolver, workspaces, worktrees } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const { options: resolvedIntent, warnings } = yield* resolver.resolve( - { ...options, kind }, - sessionId, - ); + const { + options: resolvedIntent, + accountId, + warnings, + } = yield* resolver.resolve({ ...options, kind }, sessionId); const startOptions = yield* worktrees.provision(resolvedIntent, sessionId); if (options.cwd) { const parent = yield* workspaceTouch(workspaces, options.cwd); @@ -179,7 +187,7 @@ export class SessionLifecycleService { origin: { type: 'imported', historyId, importedAt: now }, createdAt: now, updatedAt: now, - runs: [{ historyId, startedAt: now, ...runOf(startOptions) }], + runs: [{ historyId, startedAt: now, ...runOf(startOptions, accountId) }], }; yield* sessions.startLive( replyTo, @@ -324,15 +332,34 @@ export class SessionLifecycleService { } /** - * Point a live session at a model belonging to `accountId`. Credentials and base URL are injected - * once at spawn, so a cross-account switch cannot happen in place: it is a relaunch under the same - * id that resumes the transcript. A switch within the session's own account stays in place, which - * is why the error channel is the adapter's untyped one rather than {@link EngineFailure}. + * Route an input that changes what a relaunch must replay, and record it once the session has + * accepted it — a rejected pick never becomes the thread's own choice. Everything else is forwarded + * untouched, so the client's contract is one `agent.input` request either way. */ - switchModel( + applyInput(sessionId: SessionId, input: AgentInput): Effect.Effect { + switch (input.type) { + case 'set-model': + return this.switchModel(sessionId, input.model, input.accountId); + case 'set-effort': + return this.recordAccepted(sessionId, input, { effort: input.effort }); + case 'set-approval-policy': + return this.recordAccepted(sessionId, input, { approvalPolicyId: input.policyId }); + default: + return this.sessions.sendInput(sessionId, input); + } + } + + /** + * Point a live session at a model, on `accountId` when the pick names one. Credentials and base URL + * are injected once at spawn, so a cross-account switch cannot happen in place: it is a relaunch + * under the same id that resumes the transcript. A switch within the session's own account stays in + * place, which is why the error channel is the adapter's untyped one rather than + * {@link EngineFailure}. + */ + private switchModel( sessionId: SessionId, model: string, - accountId: string, + accountId?: string, ): Effect.Effect { return this.sessionSemaphore(sessionId).withPermit( Effect.suspend(() => { @@ -350,8 +377,14 @@ export class SessionLifecycleService { }), ); } - if (this.records.accountId(sessionId) === accountId) { - return this.sessions.sendInput(sessionId, { type: 'set-model', model, accountId }); + // A pick that names no account, or names the session's own, is a switch within the account + // the run already resolved to: the adapter takes it in place and the run keeps its own. + if (accountId === undefined || this.records.accountId(sessionId) === accountId) { + return this.recordAccepted( + sessionId, + { type: 'set-model', model, ...(accountId !== undefined && { accountId }) }, + { model }, + ); } if (this.sessions.isBusy(sessionId)) { return Effect.fail( @@ -388,7 +421,7 @@ export class SessionLifecycleService { const launchRun = this.launchRun.bind(this); const resumeStrategy = this.resumeStrategy.bind(this); return Effect.gen(function* () { - const resolved = yield* resolveForRecord(record, { model, config: { accountId } }); + const resolved = yield* resolveForRecord(record, { model, accountId }); yield* sessions.stopForReplacement(sessionId); yield* launchRun( undefined, @@ -402,15 +435,26 @@ export class SessionLifecycleService { ); } + /** Forward a pick to the live adapter and record it on the run only if the adapter took it. */ + private recordAccepted( + sessionId: SessionId, + input: AgentInput, + intent: SessionRunIntent, + ): Effect.Effect { + return this.sessions + .sendInput(sessionId, input) + .pipe(Effect.tap(() => Effect.sync(() => this.records.setRunIntent(sessionId, intent)))); + } + /** * Resolve the options an existing record relaunches under. Absent an explicit `override`, the - * thread's own last run supplies the model and account: the daemon's configured default answers - * for new and unpinned sessions, and adopting it here would silently move a running thread to - * whatever Settings now says. + * thread's own last run supplies the model, account, effort and approval tier: the daemon's + * configured default answers for new and unpinned sessions, and adopting it here would silently + * move a running thread to whatever Settings now says. */ private resolveForRecord( record: SessionRecord, - override?: Pick, + override?: SessionPin, ): Effect.Effect { const pinned = override ?? this.records.pinnedOptions(record.sessionId); return this.startOptions.resolve( @@ -435,7 +479,10 @@ export class SessionLifecycleService { ): Effect.Effect { const { historyId, ...startOptions } = options; return Effect.suspend(() => { - this.records.beginRun(record.sessionId, { ...runOf(resolved.options), historyId }); + this.records.beginRun(record.sessionId, { + ...runOf(resolved.options, resolved.accountId), + historyId, + }); return this.sessions.startLive( replyTo, record, @@ -468,7 +515,7 @@ export class SessionLifecycleService { const { sessions, startOptions: resolver, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const { options: startOptions } = yield* resolver.resolve( + const { options: startOptions, accountId } = yield* resolver.resolve( { kind: options.kind, cwd: options.cwd, model: options.model }, sessionId, ); @@ -482,7 +529,7 @@ export class SessionLifecycleService { automation: options.automation, createdAt: now, updatedAt: now, - runs: [{ startedAt: now, ...runOf(startOptions) }], + runs: [{ startedAt: now, ...runOf(startOptions, accountId) }], }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); yield* sessions.startLive(undefined, record, (adapter) => @@ -555,12 +602,15 @@ function workspaceRegisterWorktree( }); } -/** What a run resolved to, spread into a `SessionRun`. Unresolved fields stay absent rather than - * writing `undefined` into the record, and are what a later relaunch reads back to stay put. */ -function runOf(opts: StartOptions): { accountId?: string; model?: string } { - const accountId = resolvedAccountId(opts); +/** What a launch settled on, spread into a `SessionRun`. The account comes from the resolver rather + * than the options it produced, because only the resolver knows one actually backed the run. + * Unresolved fields stay absent rather than writing `undefined` into the record, and are what a later + * relaunch reads back to stay put. */ +function runOf(options: StartOptions, accountId: string | undefined): SessionPin { return { ...(accountId !== undefined && { accountId }), - ...(opts.model !== undefined && { model: opts.model }), + ...(options.model !== undefined && { model: options.model }), + ...(options.effort !== undefined && { effort: options.effort }), + ...(options.approvalPolicyId !== undefined && { approvalPolicyId: options.approvalPolicyId }), }; } diff --git a/packages/host/engine/src/session/request-handler.ts b/packages/host/engine/src/session/request-handler.ts index 49c71c81..bbdeb323 100644 --- a/packages/host/engine/src/session/request-handler.ts +++ b/packages/host/engine/src/session/request-handler.ts @@ -40,12 +40,10 @@ export class SessionRequestHandler { ); case 'agent.input': { const { input, sessionId } = payload; - // A model pick naming an account can mean a relaunch on that account, which lifecycle owns. - // Both paths answer with the same plain ack, so the client's contract is unchanged. - const applied = - input.type === 'set-model' && input.accountId !== undefined - ? this.lifecycle.switchModel(sessionId, input.model, input.accountId) - : this.sessions.sendInput(sessionId, input); + // Lifecycle owns inputs that outlive the adapter holding them: it records an accepted pick on + // the run, and a model pick naming another account is a relaunch. Every path answers with the + // same plain ack, so the client's contract is one request either way. + const applied = this.lifecycle.applyInput(sessionId, input); return this.responder.reply( payload.clientReqId, applied.pipe( diff --git a/packages/host/engine/src/session/session-event-processor.ts b/packages/host/engine/src/session/session-event-processor.ts index 24ea837a..a8e1f455 100644 --- a/packages/host/engine/src/session/session-event-processor.ts +++ b/packages/host/engine/src/session/session-event-processor.ts @@ -102,11 +102,6 @@ export class SessionEventProcessor { case 'session-ref': this.records.bindHistoryId(sessionId, event.historyId); break; - // Adapters emit this only once a switch is accepted, and with the id they actually serve — - // so recording it here, rather than where a pick is sent, is what a relaunch should replay. - case 'model-update': - this.records.setRunModel(sessionId, event.model); - break; case 'title-update': this.records.setProviderTitle(sessionId, event.title); break; diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 7563bc62..2f9f00ad 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -11,12 +11,24 @@ import type { } from '@linkcode/schema'; import { Effect } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import { OperationError } from '../failure'; import type { SessionStore } from './session-store'; const TITLE_MAX_LENGTH = 80; type RunTask = (effect: Effect.Effect) => void; +/** The fields of a run that say what the thread is *set to*, as opposed to how the run went. */ +type SessionPinnedRun = Pick; + +/** The same choices shaped as start options, which is how a relaunch replays them. `Pick` over + * `StartOptions` is the guarantee: a pinned field that a launch cannot accept fails typecheck. */ +export type SessionPin = Pick; + +/** A pick accepted on a live session; absent fields leave the run's current value alone. The account + * is not among them — credentials are injected at spawn, so moving accounts is a new run. */ +export type SessionRunIntent = Omit; + export class SessionRecordRegistry { private readonly records = new Map(); private runTask: RunTask | undefined; @@ -84,7 +96,7 @@ export class SessionRecordRegistry { createdVia: record.createdVia, automation: record.automation, historyId: latestHistoryId(record), - accountId: latestAccountId(record), + accountId: latestRunValue(record, 'accountId'), })); } @@ -132,14 +144,26 @@ export class SessionRecordRegistry { this.onChanged(sessionId, 'updated'); } - /** Record the model the newest run is now on. A pick accepted mid-run never launches anything, so - * without this a relaunch replays the model the run started with and silently drops it. Not an - * identity change — `SessionInfo` does not project the model — so it notifies nobody. */ - setRunModel(sessionId: SessionId, model: string): void { + /** + * Record a pick the session accepted, on the newest run. A pick accepted mid-run launches nothing, + * so without this a relaunch replays what the run started with and silently drops it. Callers write + * only picks — never a value an adapter resolved for itself, which would pin the thread to its own + * first launch. Not an identity change — `SessionInfo` projects none of these — so it notifies + * nobody. + */ + setRunIntent(sessionId: SessionId, intent: SessionRunIntent): void { const record = this.records.get(sessionId); const run = record?.runs.at(-1); - if (!record || !run || run.model === model) return; - run.model = model; + if (!record || !run) return; + const { + model = run.model, + effort = run.effort, + approvalPolicyId = run.approvalPolicyId, + } = intent; + if (model === run.model && effort === run.effort && approvalPolicyId === run.approvalPolicyId) { + return; + } + Object.assign(run, definedFields({ model, effort, approvalPolicyId })); this.persist(record); } @@ -194,24 +218,24 @@ export class SessionRecordRegistry { /** The account the newest run resolved to — what a live session is actually talking to. */ accountId(sessionId: SessionId): string | undefined { const record = this.records.get(sessionId); - return record ? latestAccountId(record) : undefined; + return record ? latestRunValue(record, 'accountId') : undefined; } /** - * What the newest run resolved to, shaped as a start-options override. A relaunch applies this so - * the thread keeps its own model and account; the daemon's configured default answers for new and - * unpinned sessions only, and may have moved since this one started. + * What the thread is set to, shaped as a start-options override. A relaunch applies this so the + * thread keeps its own choices; the daemon's configured default answers for new and unpinned + * sessions only, and may have moved since this one started. */ - pinnedOptions(sessionId: SessionId): Pick | undefined { + pinnedOptions(sessionId: SessionId): SessionPin | undefined { const record = this.records.get(sessionId); if (!record) return undefined; - const accountId = latestAccountId(record); - const model = latestModel(record); - if (accountId === undefined && model === undefined) return undefined; - return { - ...(model !== undefined && { model }), - ...(accountId !== undefined && { config: { accountId } }), - }; + const pin = definedFields({ + accountId: latestRunValue(record, 'accountId'), + model: latestRunValue(record, 'model'), + effort: latestRunValue(record, 'effort'), + approvalPolicyId: latestRunValue(record, 'approvalPolicyId'), + }); + return isObjectEmpty(pin) ? undefined : pin; } /** The in-memory record is authoritative while running; persistence is best-effort. */ @@ -253,20 +277,15 @@ function storeFailure(operation: string, publicMessage: string, cause: unknown): return new OperationError({ subsystem: 'store', operation, publicMessage, cause }); } -/** The account the newest run resolved to. Older runs may name a different one — a rebind between - * runs is legitimate — so only the latest describes what a live session is actually talking to. */ -function latestAccountId(record: SessionRecord): string | undefined { - for (let index = record.runs.length - 1; index >= 0; index -= 1) { - const accountId = record.runs[index].accountId; - if (accountId !== undefined) return accountId; - } - return undefined; -} - -function latestModel(record: SessionRecord): string | undefined { +/** The newest run that answers for `key`. Older runs may name a different value — a change between + * runs is legitimate — so only the latest describes what a live session is actually on. */ +function latestRunValue( + record: SessionRecord, + key: K, +): SessionRun[K] { for (let index = record.runs.length - 1; index >= 0; index -= 1) { - const model = record.runs[index].model; - if (model !== undefined) return model; + const value = record.runs[index][key]; + if (value !== undefined) return value; } return undefined; } diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 1457a2a4..fe1b1637 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -3,7 +3,7 @@ import { Effect } from 'effect'; import { isObjectEmpty } from 'foxts/is-object-empty'; import type { CustomMcpServerService } from '../agent/custom-mcp-service'; import type { ProviderConfigStore } from '../agent/provider-config'; -import { applyProviderDefaults, resolvedAccountId } from '../agent/provider-config'; +import { applyProviderDefaults } from '../agent/provider-config'; import type { TranslatorService } from '../agent/translator'; import { translationUpstream, withTranslatorEndpoint } from '../agent/translator'; import { OperationError, RequestError } from '../failure'; @@ -13,6 +13,8 @@ import { MCP_CAPABLE_AGENT_KINDS } from './mcp-capability'; export interface ResolvedStartOptions { readonly options: StartOptions; + /** The account backing this run, recorded per run so a relaunch stays on it. */ + readonly accountId?: string; /** Custom-MCP injection advisories, delivered on the `session.started` reply. */ readonly warnings: McpWarning[]; } @@ -34,10 +36,11 @@ export class SessionStartOptionsResolver { ): Effect.Effect { const providers = this.providers.get(); const defaults = applyProviderDefaults(options, providers, this.providers.getAccounts()); - // Whether an account actually resolved — the caller's pin or, failing that, the agent's + // Whether an account actually resolved — the request's pick or, failing that, the agent's // configured default. Asking that rather than "is a default set" also covers a pinned session // on an agent with no default at all. - const accountResolved = resolvedAccountId(defaults.options) !== undefined; + const { accountId } = defaults; + const account = accountId === undefined ? {} : { accountId }; const { translator } = this; const withCustomMcpServers = this.withCustomMcpServers.bind(this); const withSimulatorMcp = this.withSimulatorMcp.bind(this); @@ -52,7 +55,7 @@ export class SessionStartOptionsResolver { }), ); } - if (accountResolved && defaults.options.model === undefined) { + if (accountId !== undefined && defaults.options.model === undefined) { // With an account in play, its selected set is the only model source and nothing falls back // to the agent's own choice. Agents with no account keep resolving their own. return yield* Effect.fail( @@ -65,7 +68,7 @@ export class SessionStartOptionsResolver { const custom = yield* withCustomMcpServers(defaults.options); const resolved = withSimulatorMcp(custom.options, sessionId); const upstream = translationUpstream(resolved); - if (!upstream) return { options: resolved, warnings: custom.warnings }; + if (!upstream) return { options: resolved, ...account, warnings: custom.warnings }; if (!translator) { return yield* Effect.fail( new RequestError({ @@ -86,6 +89,7 @@ export class SessionStartOptionsResolver { }); return { options: withTranslatorEndpoint(resolved, url), + ...account, warnings: custom.warnings, }; }); @@ -93,7 +97,9 @@ export class SessionStartOptionsResolver { /** Fold enabled custom MCP servers into the session's server list, warning instead of * silently dropping: unsupported agent kinds and name collisions are user-visible facts. */ - private withCustomMcpServers(options: StartOptions): Effect.Effect { + private withCustomMcpServers( + options: StartOptions, + ): Effect.Effect<{ options: StartOptions; warnings: McpWarning[] }> { const warnings: McpWarning[] = []; const enabled = this.customMcp?.listEnabled() ?? []; if (enabled.length === 0) return Effect.succeed({ options, warnings }); From 5bd3c4df6adc307eb0949fa3480b68b53ab782ee Mon Sep 17 00:00:00 2001 From: Peron Date: Mon, 10 Aug 2026 10:41:24 +0800 Subject: [PATCH 23/32] docs(workbench): name the harness, and drop a pointer to a deleted hook --- .../workbench/src/surface/new-session-defaults-store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/workbench/src/surface/new-session-defaults-store.ts b/packages/client/workbench/src/surface/new-session-defaults-store.ts index 577494a8..bca55319 100644 --- a/packages/client/workbench/src/surface/new-session-defaults-store.ts +++ b/packages/client/workbench/src/surface/new-session-defaults-store.ts @@ -29,15 +29,15 @@ const PersistedNewSessionDefaultsSchema = z type PersistedNewSessionDefaults = z.infer; export interface NewSessionSelection { - /** Confirmed model, for callers that route it onward. This store does not persist it — the model - * an agent runs on lives in daemon config (`usePersistPickedModel`), so there is one owner. */ + /** Confirmed model, for callers that route it onward. This store does not persist it — the daemon + * owns both answers: `providers[kind].model` for the agent's default, the thread's run for a pick. */ model?: string | null; /** Null clears a remembered selection after an explicit reset or rejected reflection. */ effort?: EffortLevel | null; } export interface NewSessionDefaultsState { - /** Provider of the last successful new-session submit; null before the first (→ claude-code). */ + /** Harness of the last successful new-session submit; null before the first (→ claude-code). */ lastHarness: AgentKind | null; /** Workspace of the last successful submit; ids that no longer exist are skipped at resolve time. */ lastWorkspaceId: WorkspaceId | null; From 6293aad2974a3d84f443c15f1f64b9077ba594f8 Mon Sep 17 00:00:00 2001 From: Peron Date: Mon, 10 Aug 2026 11:16:13 +0800 Subject: [PATCH 24/32] fix(daemon): persist the run fields a relaunch replays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session_runs` had no columns for the account, model, effort or approval tier, so every pin was dropped on write and parsed back as `undefined` after any daemon restart — a thread moved to another account silently returned to the agent's default. The engine's in-memory store round-trips whole objects and cannot see this, hence the store-level test. --- apps/daemon/AGENTS.md | 5 + .../drizzle/0009_add_session_run_pin.sql | 4 + apps/daemon/drizzle/meta/0009_snapshot.json | 892 ++++++++++++++++++ apps/daemon/drizzle/meta/_journal.json | 7 + .../src/__tests__/session-store.test.ts | 77 ++ apps/daemon/src/db/schema.ts | 6 + apps/daemon/src/session-store.ts | 8 + 7 files changed, 999 insertions(+) create mode 100644 apps/daemon/drizzle/0009_add_session_run_pin.sql create mode 100644 apps/daemon/drizzle/meta/0009_snapshot.json create mode 100644 apps/daemon/src/__tests__/session-store.test.ts diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 805fcdfa..37525e94 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -86,6 +86,11 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr tables in `src/db/schema.ts`). The zod `SessionRecordSchema` is the contract: rows are re-validated through it on load; the table is just storage. After editing `src/db/schema.ts`, run `pnpm -F @linkcode/daemon exec drizzle-kit generate` and commit `drizzle/` — migrations run at boot. + - **A record field with no column is dropped in silence.** The store enumerates columns on write and + rebuilds the record on read, so an `.optional()` field added to the schema alone survives until the + next boot and then parses cleanly as `undefined`. Adding one is three edits (column, write, read) + plus a migration, and the engine's `InMemorySessionStore` cannot catch a missed one — only a + round-trip through this store can (`__tests__/session-store.test.ts`). - **`runtime.json`** — endpoint discovery (`{name,pid,startedAt,listeners:[{type,url}]}`), written `0600` only AFTER every listener binds and removed on graceful `SIGINT`/`SIGTERM` shutdown. diff --git a/apps/daemon/drizzle/0009_add_session_run_pin.sql b/apps/daemon/drizzle/0009_add_session_run_pin.sql new file mode 100644 index 00000000..cb28147b --- /dev/null +++ b/apps/daemon/drizzle/0009_add_session_run_pin.sql @@ -0,0 +1,4 @@ +ALTER TABLE `session_runs` ADD `account_id` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `model` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `effort` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `approval_policy_id` text; \ No newline at end of file diff --git a/apps/daemon/drizzle/meta/0009_snapshot.json b/apps/daemon/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..1ae3c26e --- /dev/null +++ b/apps/daemon/drizzle/meta/0009_snapshot.json @@ -0,0 +1,892 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d784a44f-1c49-4484-ad8b-7b6934f24d5e", + "prevId": "df784a45-0a6e-40a4-9dcd-52336eb9bdcf", + "tables": { + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_resources": { + "name": "session_resources", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_type": { + "name": "locator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator": { + "name": "locator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_locator_key": { + "name": "normalized_locator_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_resources_session_idx": { + "name": "session_resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_resources_locator_idx": { + "name": "session_resources_locator_idx", + "columns": ["session_id", "normalized_locator_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_resources_session_id_sessions_session_id_fk": { + "name": "session_resources_session_id_sessions_session_id_fk", + "tableFrom": "session_resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_policy_id": { + "name": "approval_policy_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/_journal.json b/apps/daemon/drizzle/meta/_journal.json index d6f2cca1..43301504 100644 --- a/apps/daemon/drizzle/meta/_journal.json +++ b/apps/daemon/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1785425562289, "tag": "0008_add_session_resources", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1786331414776, + "tag": "0009_add_session_run_pin", + "breakpoints": true } ] } diff --git a/apps/daemon/src/__tests__/session-store.test.ts b/apps/daemon/src/__tests__/session-store.test.ts new file mode 100644 index 00000000..921b73d0 --- /dev/null +++ b/apps/daemon/src/__tests__/session-store.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionRecordSchema } from '@linkcode/schema'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createSessionStore } from '../session-store'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function databasePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'linkcode-session-store-')); + temporaryDirectories.push(directory); + return join(directory, 'daemon.db'); +} + +describe('SQLite session store', () => { + /** + * The engine reads a thread's own picks back off its runs to relaunch it, so a field this table + * drops is a thread silently returning to the agent's configured default on the next daemon boot. + * The in-memory store round-trips whole objects and cannot catch that; only this can. + */ + it('round-trips every field of a run, not just the ones the engine happens to set', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-pinned', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 2, + runs: [ + { startedAt: 1, endedAt: 2, historyId: 'native-1', accountId: 'acc_first' }, + { + startedAt: 3, + historyId: 'native-2', + accountId: 'acc_second', + model: 'model-second', + effort: 'xhigh', + approvalPolicyId: 'acceptEdits', + }, + ], + }); + await createSessionStore(database).save(record); + + expect(await createSessionStore(database).load()).toEqual([record]); + }); + + it('keeps run order across a reload, since the array position is part of the record', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-ordered', + kind: 'codex', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [ + { startedAt: 1, model: 'first' }, + { startedAt: 2, model: 'second' }, + { startedAt: 3, model: 'third' }, + ], + }); + const store = createSessionStore(database); + await store.save(record); + // A later save rewrites the whole run list; the newest run is what a relaunch reads back. + await store.save({ ...record, runs: [...record.runs, { startedAt: 4, model: 'fourth' }] }); + + const [reloaded] = await createSessionStore(database).load(); + expect(reloaded.runs.map((run) => run.model)).toEqual(['first', 'second', 'third', 'fourth']); + }); +}); diff --git a/apps/daemon/src/db/schema.ts b/apps/daemon/src/db/schema.ts index 951ec49f..030956aa 100644 --- a/apps/daemon/src/db/schema.ts +++ b/apps/daemon/src/db/schema.ts @@ -43,6 +43,12 @@ export const sessionRuns = sqliteTable( /** Position within the session's run list — array order is part of the record. */ seq: integer('seq').notNull(), historyId: text('history_id'), + /** What the thread is set to, replayed on relaunch (`SessionRunSchema`). Every one of these must + * round-trip, or a restart silently moves the thread back onto the agent's configured default. */ + accountId: text('account_id'), + model: text('model'), + effort: text('effort'), + approvalPolicyId: text('approval_policy_id'), startedAt: integer('started_at').notNull(), endedAt: integer('ended_at'), }, diff --git a/apps/daemon/src/session-store.ts b/apps/daemon/src/session-store.ts index 0e4364ac..7aec1f69 100644 --- a/apps/daemon/src/session-store.ts +++ b/apps/daemon/src/session-store.ts @@ -85,6 +85,10 @@ export function createSessionStore(dbPath: string): SessionStore { sessionId: record.sessionId, seq, historyId: run.historyId ?? null, + accountId: run.accountId ?? null, + model: run.model ?? null, + effort: run.effort ?? null, + approvalPolicyId: run.approvalPolicyId ?? null, startedAt: run.startedAt, endedAt: run.endedAt ?? null, })), @@ -143,6 +147,10 @@ function toRecord(row: SessionRow, runRows: RunRow[]): SessionRecord { updatedAt: row.updatedAt, runs: runRows.map((run) => ({ historyId: run.historyId ?? undefined, + accountId: run.accountId ?? undefined, + model: run.model ?? undefined, + effort: run.effort ?? undefined, + approvalPolicyId: run.approvalPolicyId ?? undefined, startedAt: run.startedAt, endedAt: run.endedAt ?? undefined, })), From 95eed3acf140b47af39a7fd87557486f1f62ea59 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sun, 9 Aug 2026 07:30:51 +0800 Subject: [PATCH 25/32] feat(engine,providers,ui): adopt a detected CLI login as an account A delegated subscription is an account like any other, so the host creates it from the runtime probe instead of waiting for the user to import it from a suggestion card. --- .../settings/providers/__tests__/view.test.ts | 1 - .../src/settings/providers/add-flow.tsx | 2 +- .../settings/providers/providers-settings.tsx | 17 +--- .../workbench/src/settings/providers/view.ts | 15 +--- .../providers/src/__tests__/resolve.test.ts | 14 ++-- .../providers/src/detected-logins.ts | 14 ++-- packages/foundation/providers/src/index.ts | 4 +- .../__tests__/engine-detected-logins.test.ts | 83 +++++++++++++++++++ .../host/engine/src/agent/detected-logins.ts | 59 +++++++++++++ packages/host/engine/src/engine.ts | 14 ++++ packages/presentation/i18n/src/locales/en.ts | 1 - .../presentation/i18n/src/locales/zh-cn.ts | 1 - .../shell/providers/account-master-list.tsx | 48 +---------- 13 files changed, 178 insertions(+), 95 deletions(-) create mode 100644 packages/host/engine/src/__tests__/engine-detected-logins.test.ts create mode 100644 packages/host/engine/src/agent/detected-logins.ts diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index 99a199cb..9458fe75 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -199,7 +199,6 @@ describe('view helpers', () => { boundAgents: [], }, ], - detectedLogins: [{ service: 'chatgpt-sub', label: 'ChatGPT', email: 'codex@example.com' }], bindingCount: 2, agentCount: 5, }); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index 185a54f1..3b868619 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -44,7 +44,7 @@ function newAccountBase(label: string): Pick, label: string, models: AccountModel[] = [], diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 612174b5..9f528f2f 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -20,7 +20,7 @@ import { useTranslations } from 'use-intl'; import { useAgentRuntimes } from '../../agent-runtime/hooks'; import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; -import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; +import { AddAccountForm, EditAccountForm, ServiceCatalogView } from './add-flow'; import { useModelSources } from './model-selection'; import { useProvidersSettingsStore } from './store'; import { @@ -109,13 +109,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { select(account.id); }; - // One-click adoption of a detected CLI login: same account the oauth form would create. - const handleAdoptDetected = (serviceId: string): void => { - const service = serviceById(serviceId); - if (service?.kind !== 'oauth') return; - void handleAdd(oauthAccount(service, t(`serviceName.${service.id}`))); - }; - const handleRemove = async (): Promise => { if (!selected) return; const cleared = withoutAccount(providers ?? {}, selected.id); @@ -139,13 +132,7 @@ export function ProvidersSettingsPanel(): React.ReactNode {
{/* The page title is rendered by the settings shell; this is the lead subtitle. */}

{t('hint')}

- + providerAccountListItem(account, providers, runtimes)), - detectedLogins: detectedLoginSuggestions(accounts, runtimes).map(({ service, auth }) => ({ - service: service.id, - label: service.label, - ...(auth.email !== undefined && { email: auth.email }), - })), bindingCount: AGENT_KINDS.filter((kind) => providers?.[kind]?.activeAccountId !== undefined) .length, agentCount: AGENT_KINDS.length, diff --git a/packages/foundation/providers/src/__tests__/resolve.test.ts b/packages/foundation/providers/src/__tests__/resolve.test.ts index fae1809b..799e2cb2 100644 --- a/packages/foundation/providers/src/__tests__/resolve.test.ts +++ b/packages/foundation/providers/src/__tests__/resolve.test.ts @@ -2,7 +2,7 @@ import type { Account, AgentKind, AgentRuntimes } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; import { endpointServiceById, modelListSource, serviceById } from '../catalog'; -import { detectedLoginSuggestions } from '../detected-logins'; +import { detectedLogins } from '../detected-logins'; import { resolveBinding, serviceProtocols } from '../resolve'; import { fillTemplate, templatePlaceholders } from '../template'; @@ -313,7 +313,7 @@ describe('catalog helpers', () => { ); }); - it('suggests detected CLI logins the pool does not represent yet', () => { + it('reports detected CLI logins the pool does not represent yet', () => { const runtimes: AgentRuntimes = { 'claude-code': { status: 'available', @@ -321,13 +321,13 @@ describe('catalog helpers', () => { }, codex: { status: 'available', auth: { loggedIn: false } }, }; - const suggested = detectedLoginSuggestions([], runtimes); - expect(suggested.map(({ service, auth }) => [service.id, auth.email])).toEqual([ + const detected = detectedLogins([], runtimes); + expect(detected.map(({ service, auth }) => [service.id, auth.email])).toEqual([ ['claude-sub', 'x@y.z'], ]); - // An existing oauth account for the agent absorbs the suggestion; unprobed runtimes yield none. + // An existing oauth account for the agent absorbs it; unprobed runtimes yield none. const claudeSub = account({ credential: { type: 'oauth', agent: 'claude-code' } }); - expect(detectedLoginSuggestions([claudeSub], runtimes)).toEqual([]); - expect(detectedLoginSuggestions([], undefined)).toEqual([]); + expect(detectedLogins([claudeSub], runtimes)).toEqual([]); + expect(detectedLogins([], undefined)).toEqual([]); }); }); diff --git a/packages/foundation/providers/src/detected-logins.ts b/packages/foundation/providers/src/detected-logins.ts index 1ece4200..12ba1d40 100644 --- a/packages/foundation/providers/src/detected-logins.ts +++ b/packages/foundation/providers/src/detected-logins.ts @@ -2,21 +2,21 @@ import type { Accounts, AgentAuthStatus, AgentRuntimes } from '@linkcode/schema' import type { ServiceDescriptor } from './catalog'; import { SERVICE_CATALOG } from './catalog'; -export interface DetectedLoginSuggestion { +export interface DetectedLogin { service: Extract; auth: AgentAuthStatus; } /** - * CLI logins the runtime probe sees that the pool does not represent yet, offered as one-click - * "detected" cards: `loggedIn: true` with no oauth account for that agent. The pool stays - * explicit user state — this is a suggestion, not an implicit member. + * CLI logins the runtime probe sees that the pool does not represent yet: `loggedIn: true` with no + * oauth account for that agent. The host adopts each one into the pool, so a delegated subscription + * reaches the model pickers on the same footing as a key the user typed. */ -export function detectedLoginSuggestions( +export function detectedLogins( accounts: Accounts, runtimes: AgentRuntimes | undefined, -): DetectedLoginSuggestion[] { - const suggestions: DetectedLoginSuggestion[] = []; +): DetectedLogin[] { + const suggestions: DetectedLogin[] = []; for (const service of SERVICE_CATALOG) { if (service.kind !== 'oauth') continue; const auth = runtimes?.[service.agent]?.auth; diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index 9af0dbc3..1be20943 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -11,8 +11,8 @@ export { SERVICE_CATALOG, serviceById, } from './catalog'; -export type { DetectedLoginSuggestion } from './detected-logins'; -export { detectedLoginSuggestions } from './detected-logins'; +export type { DetectedLogin } from './detected-logins'; +export { detectedLogins } from './detected-logins'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; export { pinnedEndpoint, resolveBinding, serviceProtocols } from './resolve'; export { fillTemplate, isTemplateFilled, templatePlaceholders } from './template'; diff --git a/packages/host/engine/src/__tests__/engine-detected-logins.test.ts b/packages/host/engine/src/__tests__/engine-detected-logins.test.ts new file mode 100644 index 00000000..65328787 --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-detected-logins.test.ts @@ -0,0 +1,83 @@ +import type { AgentRuntimes } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { Effect } from 'effect'; +import { noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import { adoptDetectedLogins } from '../agent/detected-logins'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; +import { createTestEngine } from './fixtures/test-engine'; + +const LOGGED_IN: AgentRuntimes = { + 'claude-code': { + status: 'available', + source: 'detected', + auth: { loggedIn: true, method: 'claude.ai', subscriptionType: 'max', email: 'x@y.z' }, + }, + codex: { status: 'available', source: 'detected', auth: { loggedIn: false } }, +}; + +const ACCOUNT_ID_RE = /^acc_/; + +const silentTransport: Transport = { + connect: () => Promise.resolve(), + send: noop, + onMessage: () => noop, + onClose: () => noop, + close: noop, +}; + +describe('detected-login adoption', () => { + it('adopts a probed CLI login into the pool without binding it', async () => { + const providerStore = new InMemoryProviderConfigStore(); + const engine = createTestEngine(silentTransport, { + providerStore, + agentRuntimesReady: Promise.resolve(LOGGED_IN), + }); + await engine.start(); + await vi.waitFor(() => expect(providerStore.getAccounts()).toHaveLength(1)); + + // The pool grew and nothing else did: no binding, no model, so no session changes what it runs on. + expect(providerStore.getAccounts()[0]).toEqual({ + id: expect.stringMatching(ACCOUNT_ID_RE), + label: 'Claude', + service: 'claude-sub', + credential: { type: 'oauth', agent: 'claude-code' }, + createdAt: expect.any(Number), + }); + expect(providerStore.get()).toEqual({}); + await engine.stop(); + }); + + it('adopts once, and skips a signed-out runtime', async () => { + const providerStore = new InMemoryProviderConfigStore(); + await Effect.runPromise(adoptDetectedLogins(providerStore, LOGGED_IN)); + await Effect.runPromise(adoptDetectedLogins(providerStore, LOGGED_IN)); + expect(providerStore.getAccounts()).toHaveLength(1); + + const signedOut = new InMemoryProviderConfigStore(); + await Effect.runPromise( + adoptDetectedLogins(signedOut, { 'claude-code': { status: 'available' } }), + ); + expect(signedOut.getAccounts()).toEqual([]); + }); + + it('keeps the accounts a concurrent write added', async () => { + const providerStore = new InMemoryProviderConfigStore(); + providerStore.update({ + accounts: [ + { + id: 'acc_key', + label: 'DeepSeek', + service: 'deepseek', + credential: { type: 'api-key', key: 'k' }, + createdAt: 1, + }, + ], + }); + await Effect.runPromise(adoptDetectedLogins(providerStore, LOGGED_IN)); + expect(providerStore.getAccounts().map((account) => account.id)).toEqual([ + 'acc_key', + expect.stringMatching(ACCOUNT_ID_RE), + ]); + }); +}); diff --git a/packages/host/engine/src/agent/detected-logins.ts b/packages/host/engine/src/agent/detected-logins.ts new file mode 100644 index 00000000..aaa30225 --- /dev/null +++ b/packages/host/engine/src/agent/detected-logins.ts @@ -0,0 +1,59 @@ +import { detectedLogins } from '@linkcode/providers'; +import type { Account, AgentRuntimes } from '@linkcode/schema'; +import { Clock, Effect } from 'effect'; +import { OperationError } from '../failure'; +import type { ProviderConfigStore } from './provider-config'; + +/** + * Adopt every probed CLI login the pool does not represent yet. A delegated subscription is an + * account like any other — same picker, same resolution at session start — so detection alone is + * enough to create it; leaving it to an explicit import meant a signed-in agent had no account, and + * therefore nothing pickable, until the user visited Settings. + * + * Only the pool grows. Nothing is bound and no model is picked, so no session changes what it runs + * on, and a user who deletes the account gets it back on the next probe — which is correct: the CLI + * login is still there, and the pool describes what exists. + */ +export function adoptDetectedLogins( + providers: ProviderConfigStore, + runtimes: AgentRuntimes, +): Effect.Effect { + return Clock.currentTimeMillis.pipe( + Effect.flatMap((createdAt) => + Effect.tryPromise({ + // The pool is read inside the write path so a concurrent `config.set` cannot land between + // the two and lose either side's accounts. + async try() { + const accounts = providers.getAccounts(); + const adopted = detectedLogins(accounts, runtimes).map( + ({ service }): Account => ({ + id: `acc_${crypto.randomUUID()}`, + label: service.label, + service: service.id, + credential: { type: 'oauth', agent: service.agent }, + createdAt, + }), + ); + if (adopted.length === 0) return []; + await providers.update({ accounts: [...accounts, ...adopted] }); + return adopted; + }, + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'config.adopt-detected-logins', + publicMessage: 'Failed to adopt detected agent logins', + cause, + }), + }), + ), + Effect.flatMap((adopted) => + adopted.length === 0 + ? Effect.void + : Effect.logInfo('Adopted detected agent CLI logins', { + operation: 'config.adopt-detected-logins', + services: adopted.map((account) => account.service), + }), + ), + ); +} diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index b10921e6..857cffce 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -6,6 +6,7 @@ import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; import { Cause, Effect, FiberSet } from 'effect'; import { CustomMcpServerService } from './agent/custom-mcp-service'; +import { adoptDetectedLogins } from './agent/detected-logins'; import { AgentLoginService } from './agent/login-service'; import { InMemoryProviderConfigStore } from './agent/provider-config'; import { AgentRequestHandler } from './agent/request-handler'; @@ -109,6 +110,19 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( collect: deps.collectAgentRuntimes, onChanged(next) { transport.send(createWireMessage({ kind: 'agent-runtime.changed', runtimes: next })); + // A probe is the only thing that sees a CLI login, so adoption rides the same signal — the + // push clients already revalidate on is what tells them the pool grew. + runTask( + adoptDetectedLogins(providerStore, next).pipe( + Effect.catch((error) => + Effect.logError( + error.publicMessage, + { operation: error.operation, subsystem: error.subsystem }, + error.cause, + ), + ), + ), + ); }, }, runTask, diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 184eb6d1..8309918a 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1011,7 +1011,6 @@ export const en = { noMatches: 'No matching accounts.', emptyTitle: 'No accounts yet', emptyHint: 'Add a subscription, API key, AI gateway, or custom endpoint.', - detected: 'Detected', accountMenu: 'Account actions', edit: 'Edit account', backToAccount: 'Back to account details', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index a6002ec6..4cd38b6c 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -985,7 +985,6 @@ export const zhCN = { noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', emptyHint: '添加订阅、API 密钥、AI 网关或自定义端点。', - detected: '检测到', accountMenu: '账号操作', edit: '编辑账号', backToAccount: '返回账号详情', diff --git a/packages/presentation/ui/src/shell/providers/account-master-list.tsx b/packages/presentation/ui/src/shell/providers/account-master-list.tsx index aadef561..b61e1509 100644 --- a/packages/presentation/ui/src/shell/providers/account-master-list.tsx +++ b/packages/presentation/ui/src/shell/providers/account-master-list.tsx @@ -21,15 +21,8 @@ export interface ProviderAccountListItem { boundAgents: AgentKind[]; } -export interface DetectedProviderLoginItem { - service: string; - label: string; - email?: string; -} - export interface ProviderAccountListViewModel { accounts: ProviderAccountListItem[]; - detectedLogins: DetectedProviderLoginItem[]; bindingCount: number; agentCount: number; } @@ -37,19 +30,15 @@ export interface ProviderAccountListViewModel { /** The Providers page's single account list; account management opens outside the list. */ export function AccountList({ accounts, - detectedLogins, bindingCount, agentCount, loading, onSelect, onAdd, - onAdoptDetected, }: ProviderAccountListViewModel & { loading: boolean; onSelect: (id: string) => void; onAdd: () => void; - /** One-click adopt a detected CLI login into the pool (a suggestion card, not a pool member). */ - onAdoptDetected: (serviceId: string) => void; }): React.ReactNode { const t = useTranslations('settings.providers'); const tAgent = useTranslations('workbench.agentKind'); @@ -176,47 +165,12 @@ export function AccountList({ {t('noMatches')} ) : null} - {!loading && needle === '' && accounts.length === 0 && detectedLogins.length === 0 ? ( + {!loading && needle === '' && accounts.length === 0 ? (
  • {t('emptyTitle')} {t('emptyHint')}
  • ) : null} - {!loading && needle === '' - ? detectedLogins.map((login) => ( -
  • - -
  • - )) - : null}
    From 68f79ad4f2bcf29a4cc7bc3174dac34b062de4be Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 10 Aug 2026 14:06:02 +0800 Subject: [PATCH 26/32] refactor(ui,i18n): stop calling an account without a default agent 'not connected' The badge read as a connection state while it only meant no agent falls back to this account, so a signed-in subscription and a working key both reported not connected. --- packages/presentation/i18n/src/locales/en.ts | 3 +-- .../presentation/i18n/src/locales/zh-cn.ts | 3 +-- .../shell/providers/account-master-list.tsx | 20 ++++++------------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 8309918a..3ec6f6c8 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1004,10 +1004,9 @@ export const en = { hint: 'Connect subscriptions, AI gateways, or custom endpoints to your agents; one account can back several agents, and each agent uses one account at a time.', searchPlaceholder: 'Search accounts…', accountCount: '{count, plural, one {# account} other {# accounts}}', - boundCount: '{bound} / {total} agents connected', + boundCount: '{bound} / {total} agents run through an account', addAccount: 'Add account', customService: 'Custom endpoint', - unbound: 'Not connected', noMatches: 'No matching accounts.', emptyTitle: 'No accounts yet', emptyHint: 'Add a subscription, API key, AI gateway, or custom endpoint.', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 4cd38b6c..c26070f1 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -978,10 +978,9 @@ export const zhCN = { hint: '把订阅、AI 网关或自定义端点接入你的智能体;一个账号可接入多个智能体,每个智能体同一时刻使用一个账号。', searchPlaceholder: '搜索账号…', accountCount: '{count} 个账号', - boundCount: '{bound} / {total} 智能体已接入', + boundCount: '{bound} / {total} 智能体经账号运行', addAccount: '添加账号', customService: '自定义端点', - unbound: '未接入', noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', emptyHint: '添加订阅、API 密钥、AI 网关或自定义端点。', diff --git a/packages/presentation/ui/src/shell/providers/account-master-list.tsx b/packages/presentation/ui/src/shell/providers/account-master-list.tsx index b61e1509..09552498 100644 --- a/packages/presentation/ui/src/shell/providers/account-master-list.tsx +++ b/packages/presentation/ui/src/shell/providers/account-master-list.tsx @@ -138,22 +138,14 @@ export function AccountList({
    ) : null}
    + {/* Naming no agent is not a defect — the account's models still reach every + picker it is enabled for — so the row says nothing rather than "not connected". */} - {account.boundAgents.length === 0 ? ( - - {t('unbound')} + {account.boundAgents.map((kind) => ( + + {tAgent(kind)} - ) : ( - account.boundAgents.map((kind) => ( - - {tAgent(kind)} - - )) - )} + ))} From 3a322233d7de8d201b4c322e465dff4e30e2e0c5 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 10 Aug 2026 14:14:10 +0800 Subject: [PATCH 27/32] refactor(ui,workbench,i18n): drop the account and binding counts from the list header Neither answered a question the list itself does not: the rows are the account count, and the binding tally described a fallback the user never reads at that altitude. --- .../src/settings/providers/__tests__/view.test.ts | 2 -- .../workbench/src/settings/providers/view.ts | 3 --- packages/presentation/i18n/src/locales/en.ts | 2 -- packages/presentation/i18n/src/locales/zh-cn.ts | 2 -- .../ui/src/shell/providers/account-master-list.tsx | 14 +------------- 5 files changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index 9458fe75..b4a7f574 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -199,8 +199,6 @@ describe('view helpers', () => { boundAgents: [], }, ], - bindingCount: 2, - agentCount: 5, }); }); diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 5f843259..69567b31 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -204,9 +204,6 @@ export function providerAccountListViewModel( ): ProviderAccountListViewModel { return { accounts: accounts.map((account) => providerAccountListItem(account, providers, runtimes)), - bindingCount: AGENT_KINDS.filter((kind) => providers?.[kind]?.activeAccountId !== undefined) - .length, - agentCount: AGENT_KINDS.length, }; } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 3ec6f6c8..203e4929 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1003,8 +1003,6 @@ export const en = { title: 'Accounts & providers', hint: 'Connect subscriptions, AI gateways, or custom endpoints to your agents; one account can back several agents, and each agent uses one account at a time.', searchPlaceholder: 'Search accounts…', - accountCount: '{count, plural, one {# account} other {# accounts}}', - boundCount: '{bound} / {total} agents run through an account', addAccount: 'Add account', customService: 'Custom endpoint', noMatches: 'No matching accounts.', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index c26070f1..ab83973b 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -977,8 +977,6 @@ export const zhCN = { title: '账号与 Provider', hint: '把订阅、AI 网关或自定义端点接入你的智能体;一个账号可接入多个智能体,每个智能体同一时刻使用一个账号。', searchPlaceholder: '搜索账号…', - accountCount: '{count} 个账号', - boundCount: '{bound} / {total} 智能体经账号运行', addAccount: '添加账号', customService: '自定义端点', noMatches: '没有匹配的账号。', diff --git a/packages/presentation/ui/src/shell/providers/account-master-list.tsx b/packages/presentation/ui/src/shell/providers/account-master-list.tsx index 09552498..2a736803 100644 --- a/packages/presentation/ui/src/shell/providers/account-master-list.tsx +++ b/packages/presentation/ui/src/shell/providers/account-master-list.tsx @@ -23,15 +23,11 @@ export interface ProviderAccountListItem { export interface ProviderAccountListViewModel { accounts: ProviderAccountListItem[]; - bindingCount: number; - agentCount: number; } /** The Providers page's single account list; account management opens outside the list. */ export function AccountList({ accounts, - bindingCount, - agentCount, loading, onSelect, onAdd, @@ -78,15 +74,7 @@ export function AccountList({ return (
    -
    -
    - - {t('accountCount', { count: accounts.length })} - - - {t('boundCount', { bound: bindingCount, total: agentCount })} - -
    +
    Date: Mon, 10 Aug 2026 17:14:15 +0800 Subject: [PATCH 28/32] refactor(providers,workbench): own the agent's offered model list in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sides need the same ordered list — the composer to show its head, the daemon to start on it — so it cannot be derived independently per side. --- .../src/settings/providers/default-models.ts | 41 +++------- .../workbench/src/settings/providers/view.ts | 9 ++- .../src/__tests__/enabled-models.test.ts | 80 +++++++++++++++++++ .../providers/src/enabled-models.ts | 45 +++++++++++ packages/foundation/providers/src/index.ts | 2 + 5 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 packages/foundation/providers/src/__tests__/enabled-models.test.ts create mode 100644 packages/foundation/providers/src/enabled-models.ts diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts index f9826add..2b669f92 100644 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ b/packages/client/workbench/src/settings/providers/default-models.ts @@ -1,5 +1,5 @@ -import { resolveBinding } from '@linkcode/providers'; -import type { Account, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; +import { enabledAccountModels, resolveBinding } from '@linkcode/providers'; +import type { Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; import { AgentKindSchema } from '@linkcode/schema'; import { getAccounts, getProviderConfig } from '@linkcode/sdk'; import type { ModelOption } from '@linkcode/ui'; @@ -67,40 +67,21 @@ export function accountModelOptions( accounts: Accounts | undefined, providers?: ProvidersConfig, ): Partial> { + const pool = accounts ?? []; const options: Partial> = {}; for (const kind of AgentKindSchema.options) { - const bindable = (accounts ?? []).filter( - (account) => resolveBinding(account, kind).tier !== 'unavailable', - ); - if (bindable.length === 0) continue; - const enabled = providers?.[kind]?.enabledAccountIds; - const offered = - enabled === undefined ? bindable : bindable.filter((account) => enabled.includes(account.id)); - options[kind] = offered.flatMap((account) => modelOptionsOf(account)); + const bindable = pool.some((account) => resolveBinding(account, kind).tier !== 'unavailable'); + if (!bindable) continue; + options[kind] = enabledAccountModels(pool, providers, kind).map(({ account, model }) => ({ + id: model.id, + label: model.label ?? model.id, + description: account.label, + accountId: account.id, + })); } return options; } -/** Whether this account's models are offered for this agent. Absent list means every bindable one, - * so a newly added account is offered without a visit to Settings. */ -export function accountEnabledFor( - providers: ProvidersConfig | undefined, - kind: AgentKind, - accountId: string, -): boolean { - const enabled = providers?.[kind]?.enabledAccountIds; - return enabled === undefined || enabled.includes(accountId); -} - -function modelOptionsOf(account: Account): ModelOption[] { - return (account.models ?? []).map(({ id, label }) => ({ - id, - label: label ?? id, - description: account.label, - accountId: account.id, - })); -} - /** `null` until both daemon-owned sources have loaded, so a picker never briefly offers a set that * is not actually available — or one the enabled list would have narrowed. */ export function useAccountModelOptions(): Partial> | null { diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 69567b31..fdd21b8f 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -1,4 +1,10 @@ -import { pinnedEndpoint, resolveBinding, serviceById, serviceProtocols } from '@linkcode/providers'; +import { + accountEnabledFor, + pinnedEndpoint, + resolveBinding, + serviceById, + serviceProtocols, +} from '@linkcode/providers'; import type { Account, Accounts, @@ -16,7 +22,6 @@ import type { ProviderAgentViewModel, ProviderCredentialViewModel, } from '@linkcode/ui'; -import { accountEnabledFor } from './default-models'; /** Pure view helpers for the Providers page — no hooks, unit-testable. */ diff --git a/packages/foundation/providers/src/__tests__/enabled-models.test.ts b/packages/foundation/providers/src/__tests__/enabled-models.test.ts new file mode 100644 index 00000000..00099e7f --- /dev/null +++ b/packages/foundation/providers/src/__tests__/enabled-models.test.ts @@ -0,0 +1,80 @@ +import type { Account, Accounts } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { accountEnabledFor, enabledAccountModels } from '../enabled-models'; + +function account(id: string, overrides: Partial = {}): Account { + return { + id, + label: id, + service: 'deepseek', + credential: { type: 'api-key', key: 'sk-test' }, + createdAt: 0, + ...overrides, + }; +} + +const POOL: Accounts = [ + account('acc_a', { models: [{ id: 'a-1' }, { id: 'a-2', label: 'A Two' }] }), + account('acc_b', { models: [{ id: 'b-1' }] }), +]; + +describe('enabledAccountModels', () => { + it('follows pool order then model order, so the head is a stable default', () => { + expect( + enabledAccountModels(POOL, {}, 'opencode').map(({ account: a, model }) => [a.id, model.id]), + ).toEqual([ + ['acc_a', 'a-1'], + ['acc_a', 'a-2'], + ['acc_b', 'b-1'], + ]); + // Reversing the pool moves the head: the order is the pool's, not a sort of its own. + expect(enabledAccountModels([...POOL].reverse(), {}, 'opencode')[0]?.model.id).toBe('b-1'); + }); + + it('narrows to the enabled list, and offers every bindable account when it is absent', () => { + const narrowed = enabledAccountModels( + POOL, + { opencode: { enabled: true, enabledAccountIds: ['acc_b'] } }, + 'opencode', + ); + expect(narrowed.map(({ model }) => model.id)).toEqual(['b-1']); + expect(enabledAccountModels(POOL, undefined, 'opencode')).toHaveLength(3); + expect( + enabledAccountModels( + POOL, + { opencode: { enabled: true, enabledAccountIds: [] } }, + 'opencode', + ), + ).toEqual([]); + }); + + it('drops an account that cannot back the agent even when it is enabled', () => { + // Cloudflare's Anthropic leg serves that protocol alone, and codex speaks only responses. + const anthropicOnly = account('acc_cf', { + service: 'cloudflare-anthropic', + endpointParams: { account_id: '8f3a', gateway_id: 'prod' }, + models: [{ id: 'claude-opus-5' }], + }); + expect(enabledAccountModels([anthropicOnly], {}, 'codex')).toEqual([]); + expect(enabledAccountModels([anthropicOnly], {}, 'claude-code')).toHaveLength(1); + const sub = account('acc_sub', { + service: 'claude-sub', + credential: { type: 'oauth', agent: 'claude-code' }, + models: [{ id: 'claude-opus-5' }], + }); + expect(enabledAccountModels([sub], {}, 'codex')).toEqual([]); + expect(enabledAccountModels([sub], {}, 'claude-code')).toHaveLength(1); + }); + + it('reports an account with no picked model as offering nothing, not as unavailable', () => { + expect(enabledAccountModels([account('acc_empty')], {}, 'opencode')).toEqual([]); + expect(accountEnabledFor({}, 'opencode', 'acc_empty')).toBe(true); + expect( + accountEnabledFor( + { opencode: { enabled: true, enabledAccountIds: [] } }, + 'opencode', + 'acc_a', + ), + ).toBe(false); + }); +}); diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts new file mode 100644 index 00000000..32ad4812 --- /dev/null +++ b/packages/foundation/providers/src/enabled-models.ts @@ -0,0 +1,45 @@ +import type { Account, AccountModel, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; +import { resolveBinding } from './resolve'; + +/** One model an agent may run on, paired with the account that serves it — the pair is the unit, + * because two accounts legitimately serve the same model id. */ +export interface EnabledAccountModel { + account: Account; + model: AccountModel; +} + +/** Whether this account's models are offered for this agent. An absent list means every bindable + * account, so an account added later is offered without a visit to Settings; an explicit list is the + * user narrowing it. */ +export function accountEnabledFor( + providers: ProvidersConfig | undefined, + kind: AgentKind, + accountId: string, +): boolean { + const enabled = providers?.[kind]?.enabledAccountIds; + return enabled === undefined || enabled.includes(accountId); +} + +/** + * Every model this agent may run on, in the order its pickers offer them: each enabled account in + * pool order, contributing its picked set in its own order. Availability still gates it — an enabled + * account that cannot back this agent contributes nothing. + * + * **The first entry is the agent's default.** There is no stored default account or default model: + * the client shows this list's head and the daemon starts on it for a request that names no model, so + * the two cannot disagree about what "unpicked" means. That is the whole reason this list has one + * implementation instead of one per side. + */ +export function enabledAccountModels( + accounts: Accounts, + providers: ProvidersConfig | undefined, + kind: AgentKind, +): EnabledAccountModel[] { + const offered: EnabledAccountModel[] = []; + for (const account of accounts) { + if (resolveBinding(account, kind).tier === 'unavailable') continue; + if (!accountEnabledFor(providers, kind, account.id)) continue; + for (const model of account.models ?? []) offered.push({ account, model }); + } + return offered; +} diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index 1be20943..5f7429c1 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -13,6 +13,8 @@ export { } from './catalog'; export type { DetectedLogin } from './detected-logins'; export { detectedLogins } from './detected-logins'; +export type { EnabledAccountModel } from './enabled-models'; +export { accountEnabledFor, enabledAccountModels } from './enabled-models'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; export { pinnedEndpoint, resolveBinding, serviceProtocols } from './resolve'; export { fillTemplate, isTemplateFilled, templatePlaceholders } from './template'; From cb87b14f0aaf3414d6a774e01215fda65da60f44 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 10 Aug 2026 18:00:45 +0800 Subject: [PATCH 29/32] refactor(ui,workbench): default a draft to the head of its model list The composer read two daemon-configured defaults to decide what an unpicked draft runs on and whether the agent's own catalog was a candidate at all. It now reads the list: the accounts' models lead it, the agent's own follow, and with no account the agent's advertised default is the only honest thing to show. --- .../src/renderer/src/shell/desktop-shell.tsx | 4 - ...t-models.test.ts => model-options.test.ts} | 29 +-- .../src/settings/providers/default-models.ts | 92 --------- .../src/settings/providers/model-options.ts | 48 +++++ .../workbench/src/surface/workbench.tsx | 10 +- .../ui/src/__tests__/agent-models.test.ts | 20 -- .../__tests__/new-session-surface.test.tsx | 193 +++++++----------- .../presentation/ui/src/shell/agent-models.ts | 18 -- .../ui/src/shell/conversation-surface.tsx | 10 +- .../ui/src/shell/new-session-surface.tsx | 66 +++--- .../presentation/ui/src/shell/shell-frame.tsx | 12 +- 11 files changed, 153 insertions(+), 349 deletions(-) rename packages/client/workbench/src/settings/providers/__tests__/{default-models.test.ts => model-options.test.ts} (82%) delete mode 100644 packages/client/workbench/src/settings/providers/default-models.ts create mode 100644 packages/client/workbench/src/settings/providers/model-options.ts diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index c0684084..5f42c24f 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -79,8 +79,6 @@ export function DesktopShell({ runtimeCues, attachmentSupport, agentCatalogs, - newSessionDefaultModels, - newSessionDefaultAccounts, accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -429,8 +427,6 @@ export function DesktopShell({ runtimeCues={runtimeCues} attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} - defaultModels={newSessionDefaultModels} - defaultAccounts={newSessionDefaultAccounts} accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} diff --git a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts similarity index 82% rename from packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts rename to packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts index eea19086..542f8aa2 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts @@ -5,12 +5,7 @@ import { getProviderConfig } from '@linkcode/sdk'; import { modelChoiceKey } from '@linkcode/ui'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - accountModelOptions, - configuredDefaultModels, - useAccountModelOptions, - useConfiguredDefaultModels, -} from '../default-models'; +import { accountModelOptions, useAccountModelOptions } from '../model-options'; const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); @@ -32,28 +27,6 @@ afterEach(() => { useDataMock.mockReset(); }); -describe('configuredDefaultModels', () => { - it('reads the per-agent pick and reports nothing for an agent that has none', () => { - const providers = { - codex: { enabled: true, activeAccountId: 'account-1', model: 'gpt-5.6-sol' }, - // Bound but unpicked: no model to report, so a session start refuses rather than guessing. - 'claude-code': { enabled: true, activeAccountId: 'account-1' }, - } satisfies ProvidersConfig; - - expect(configuredDefaultModels(providers)).toEqual({ codex: 'gpt-5.6-sol' }); - }); - - it('keeps the pick unresolved until the provider config has loaded', () => { - const { result, rerender } = renderHook(() => useConfiguredDefaultModels()); - - expect(result.current).toBeNull(); - - providersData = {}; - rerender(); - expect(result.current).toEqual({}); - }); -}); - const anthropicAccount = { id: 'acc_anthropic', label: 'Anthropic', diff --git a/packages/client/workbench/src/settings/providers/default-models.ts b/packages/client/workbench/src/settings/providers/default-models.ts deleted file mode 100644 index 2b669f92..00000000 --- a/packages/client/workbench/src/settings/providers/default-models.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { enabledAccountModels, resolveBinding } from '@linkcode/providers'; -import type { Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; -import { AgentKindSchema } from '@linkcode/schema'; -import { getAccounts, getProviderConfig } from '@linkcode/sdk'; -import type { ModelOption } from '@linkcode/ui'; -import { useData } from '../../runtime/tayori'; - -/** Each agent's configured default model — what a session starts on when nothing picked one, and - * what automation and scheduled runs always use. A running thread keeps its own pick instead. */ -export function configuredDefaultModels( - providers: ProvidersConfig | undefined, -): Partial> { - const picked: Partial> = {}; - for (const kind of AgentKindSchema.options) { - const model = providers?.[kind]?.model; - if (model !== undefined) picked[kind] = model; - } - return picked; -} - -/** Configured defaults for new-session controls. `null` means one of the daemon-owned sources is - * still unresolved; consumers must not replace that unknown value with a guessed provider model. */ -export function useConfiguredDefaultModels(): Partial> | null { - const { data: providers } = useData(getProviderConfig, {}); - if (providers === undefined) return null; - return configuredDefaultModels(providers); -} - -/** The account each agent falls back to when a session names none. An agent absent here resolves - * its own credentials, so nothing about it is the account world's business. */ -export function configuredDefaultAccounts( - providers: ProvidersConfig | undefined, -): Partial> { - const defaults: Partial> = {}; - for (const kind of AgentKindSchema.options) { - const accountId = providers?.[kind]?.activeAccountId; - if (accountId !== undefined) defaults[kind] = accountId; - } - return defaults; -} - -/** Undefined until the config has loaded, so a draft never briefly treats an agent as running on - * its own login when it actually resolves through an account. */ -export function useConfiguredDefaultAccounts(): Partial> | undefined { - const { data: providers } = useData(getProviderConfig, {}); - if (providers === undefined) return undefined; - return configuredDefaultAccounts(providers); -} - -/** - * Every model this agent offers: the picked sets of each account that can back it *and* is enabled - * for it. Choosing a model therefore also chooses its account, which is what lets one agent reach - * several providers without a trip through Settings. Live sessions read the same list — a - * cross-account pick there restarts the thread on that account rather than rebinding in place. - * - * `description` carries the account label so `groupModelsByProvider` renders one submenu per - * account, and `accountId` rides along so the pick names the account it came from — two accounts - * legitimately serve the same model id. - * - * Present-but-empty and absent still differ — `[]` is "an account could back this, nothing picked - * yet", absent is "no account can back it at all" — but neither decides on its own whether a send is - * allowed. That question is the agent's default account (`activeAccountId`), which is what the - * composer and the daemon both key on, and what decides whether this set replaces the agent's own - * catalog or merely adds to it. - */ -export function accountModelOptions( - accounts: Accounts | undefined, - providers?: ProvidersConfig, -): Partial> { - const pool = accounts ?? []; - const options: Partial> = {}; - for (const kind of AgentKindSchema.options) { - const bindable = pool.some((account) => resolveBinding(account, kind).tier !== 'unavailable'); - if (!bindable) continue; - options[kind] = enabledAccountModels(pool, providers, kind).map(({ account, model }) => ({ - id: model.id, - label: model.label ?? model.id, - description: account.label, - accountId: account.id, - })); - } - return options; -} - -/** `null` until both daemon-owned sources have loaded, so a picker never briefly offers a set that - * is not actually available — or one the enabled list would have narrowed. */ -export function useAccountModelOptions(): Partial> | null { - const { data: accounts } = useData(getAccounts, {}); - const { data: providers } = useData(getProviderConfig, {}); - if (accounts === undefined || providers === undefined) return null; - return accountModelOptions(accounts, providers); -} diff --git a/packages/client/workbench/src/settings/providers/model-options.ts b/packages/client/workbench/src/settings/providers/model-options.ts new file mode 100644 index 00000000..d243dd10 --- /dev/null +++ b/packages/client/workbench/src/settings/providers/model-options.ts @@ -0,0 +1,48 @@ +import { enabledAccountModels, resolveBinding } from '@linkcode/providers'; +import type { Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; +import { AgentKindSchema } from '@linkcode/schema'; +import { getAccounts, getProviderConfig } from '@linkcode/sdk'; +import type { ModelOption } from '@linkcode/ui'; +import { useData } from '../../runtime/tayori'; + +/** + * `enabledAccountModels` dressed for the pickers. Choosing a model therefore also chooses its + * account, which is what lets one agent reach several providers without a trip through Settings, and + * the head is what an untouched draft runs on. Live sessions read the same list — a cross-account + * pick there restarts the thread on that account rather than rebinding in place. + * + * `description` carries the account label so `groupModelsByProvider` renders one submenu per + * account, and `accountId` rides along so the pick names the account it came from — two accounts + * legitimately serve the same model id. + * + * Present-but-empty and absent differ only as description: `[]` is "an account could back this, + * nothing picked yet", absent is "no account can back it at all". Neither refuses a send — the agent + * simply resolves its own model, exactly as it does with no account at all. + */ +export function accountModelOptions( + accounts: Accounts | undefined, + providers?: ProvidersConfig, +): Partial> { + const pool = accounts ?? []; + const options: Partial> = {}; + for (const kind of AgentKindSchema.options) { + const bindable = pool.some((account) => resolveBinding(account, kind).tier !== 'unavailable'); + if (!bindable) continue; + options[kind] = enabledAccountModels(pool, providers, kind).map(({ account, model }) => ({ + id: model.id, + label: model.label ?? model.id, + description: account.label, + accountId: account.id, + })); + } + return options; +} + +/** `null` until both daemon-owned sources have loaded, so a picker never briefly offers a set that + * is not actually available — or one the enabled list would have narrowed. */ +export function useAccountModelOptions(): Partial> | null { + const { data: accounts } = useData(getAccounts, {}); + const { data: providers } = useData(getProviderConfig, {}); + if (accounts === undefined || providers === undefined) return null; + return accountModelOptions(accounts, providers); +} diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index d52d681c..31347d12 100644 --- a/packages/client/workbench/src/surface/workbench.tsx +++ b/packages/client/workbench/src/surface/workbench.tsx @@ -58,11 +58,7 @@ import { WorkbenchCommandPalette } from '../palette/command-palette'; import { openCommandPalette } from '../palette/store'; import { useWorkbenchSdkClient } from '../runtime/provider'; import { useMutation } from '../runtime/tayori'; -import { - useAccountModelOptions, - useConfiguredDefaultAccounts, - useConfiguredDefaultModels, -} from '../settings/providers/default-models'; +import { useAccountModelOptions } from '../settings/providers/model-options'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; import { useSidebarGroupCollapseStore } from '../sidebar/collapse-store'; import { useSidebarOrderStore } from '../sidebar/order-store'; @@ -245,8 +241,6 @@ function WorkbenchSessionSurface({ const active = sessions.active; const currentPlan: CurrentPlan | null = selectCurrentPlan(conversation); const { mentionItems, onMentionQueryChange } = useFileMentionSource(); - const newSessionDefaultModels = useConfiguredDefaultModels(); - const newSessionDefaultAccounts = useConfiguredDefaultAccounts(); const accountModels = useAccountModelOptions(); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; @@ -665,8 +659,6 @@ function WorkbenchSessionSurface({ draft={draft} newSessionWorkspaceId={newSessionWorkspaceId} onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} - newSessionDefaultModels={newSessionDefaultModels} - newSessionDefaultAccounts={newSessionDefaultAccounts} accountModels={accountModels} agentCatalogs={agentCatalogs} newSessionPreferredEfforts={newSessionPreferredEfforts} diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index cb69d947..68d1d1df 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -3,7 +3,6 @@ import { effortOptionsForModel } from '../shell/agent-efforts'; import { AGENT_MODEL_OPTIONS, groupModelsByProvider, - pickableModels, resolveModel, switchesAccount, } from '../shell/agent-models'; @@ -32,25 +31,6 @@ describe('resolveModel', () => { }); }); -describe('pickableModels', () => { - const account = [{ id: 'deepseek-v4-pro', label: 'DeepSeek', accountId: 'acc_x' }]; - const own = [{ id: 'opencode/native', label: 'Native' }]; - - it('keeps an agent’s own catalog when it does not resolve through an account', () => { - // opencode and pi accept any endpoint, so a key added for another agent is "bindable" to them; - // replacing here would hand an unrelated vendor's models to a thread on its own CLI login. - expect(pickableModels(account, own, { throughAccount: false })).toEqual([...account, ...own]); - expect(pickableModels(undefined, own, { throughAccount: false })).toEqual(own); - expect(pickableModels(account, null, { throughAccount: false })).toEqual(account); - }); - - it('lets the account world stand alone once one resolves', () => { - expect(pickableModels(account, own, { throughAccount: true })).toEqual(account); - // Present-and-empty is a real answer — "this account offers nothing" — not a missing one. - expect(pickableModels([], own, { throughAccount: true })).toEqual([]); - }); -}); - describe('switchesAccount', () => { const onSecond = { id: 'model-a', label: 'A', accountId: 'acc_second' }; diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index e5032cd4..1b4fa60d 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -83,6 +83,13 @@ const PI_CONFIGURED_CATALOG: AgentStartCatalog = { defaultEffort: 'high', }; +/** An account offering `pi/wide`: it heads the list, so it is displayed while the catalog still + * defaults to `pi/sonnet` — the shape that separates "what is shown" from "what the agent would + * resolve for itself". */ +const PI_WIDE_ACCOUNT: NewSessionSurfaceProps['accountModels'] = { + pi: [{ id: 'pi/wide', label: 'Pi Wide', effortLevels: ['low', 'high'], defaultEffort: 'low' }], +}; + type StandaloneProps = Omit & Partial>; @@ -287,9 +294,8 @@ describe('NewSessionSurface', () => { it('names the model selector with its agent, model, and effort', () => { render( { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { ); }); - it('shows a configured model without turning the default into an explicit override', async () => { + it("shows the list's head without turning it into an explicit override", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { ); }); - it('does not show a guessed model while configured defaults are loading', async () => { + it('does not show a guessed model while the account models are loading', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); const props = { chatWorkspace: CHAT_WORKSPACE, @@ -677,8 +686,9 @@ describe('NewSessionSurface', () => { onSubmit, workspaces: [], }; - const { rerender } = render(); + const { rerender } = render(); + // The curated table would supply a head here, and it would flip the moment the accounts land. expect(screen.getByRole('button', { name: RE_MODEL_DEFAULT })).toBeTruthy(); expect(screen.queryByRole('button', { name: RE_SONNET_5 })).toBeNull(); @@ -689,39 +699,16 @@ describe('NewSessionSurface', () => { ); rerender( - , - ); - expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); - }); - - it('shows and explicitly submits the configured model without reselection', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); - render( , ); - - expect(screen.getByRole('button', { name: RE_OPUS_4_8 })).toBeTruthy(); - typeInComposer('use my last model'); - await pressInComposer('Enter'); - - // Shown but not re-sent: the daemon resolves the configured model, so specifying it again would - // only risk the two disagreeing. - await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); - expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); + expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); }); it('drops remembered Codex ultra when the fallback model switches to Luna', async () => { @@ -729,9 +716,10 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('effort'); }); - it('shows a configured dynamic-provider model even without a draft catalog', async () => { + it('shows an account model for an agent with no curated table and no draft catalog', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); - it('can return remembered model and effort choices to the configured ones', async () => { + it("can return remembered model and effort choices to the list's head", async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( { await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); const submitted = onSubmit.mock.calls[0]?.[0]; expect(submitted).toEqual(expect.objectContaining({ effort: null })); - // No "back to the provider's own model" tier any more: an absent model defers to the - // agent's persisted pick, which the daemon resolves. + // Reset means "no pick", and the daemon derives the same head, so nothing has to travel. expect(submitted?.model).toBeUndefined(); }); - it('starts on the account the picked model belongs to, not the one bound', async () => { + it('starts on the account the picked model belongs to', async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( @@ -839,7 +838,6 @@ describe('NewSessionSurface', () => { ], }} chatWorkspace={CHAT_WORKSPACE} - defaultModels={{ 'claude-code': 'claude-opus-5' }} draft={{ initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, @@ -869,14 +867,12 @@ describe('NewSessionSurface', () => { ); }); - it("offers only the account world's picked models once an agent resolves through one", async () => { + it("offers the accounts' models ahead of the agent's own catalog, and heads the list with one", async () => { const user = userEvent.setup(); render( { />, ); + // The account model heads the list, so it is what an untouched draft shows — ahead of the + // curated Anthropic table, which stays on offer for a run on the agent's own login. await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); - await user.click(await screen.findByRole('menuitem', { name: RE_DEEPSEEK_PRO })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); - // The curated Anthropic table would otherwise supply these for claude-code. - expect(screen.queryByRole('menuitemradio', { name: 'Opus 5' })).toBeNull(); + expect(screen.getByRole('menuitemradio', { name: 'Opus 5' })).toBeTruthy(); }); - it('refuses to send when an agent resolves through an account but no model is picked', async () => { + it('sends with no model when an account offers none, leaving the agent to resolve its own', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { typeInComposer('hello'); await pressInComposer('Enter'); - await wait(0); - expect(onSubmit).not.toHaveBeenCalled(); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); - it('keeps an agent’s own catalog on offer when no account resolves for it', async () => { + it("keeps an agent’s own catalog on offer alongside the accounts'", async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( @@ -941,12 +935,11 @@ describe('NewSessionSurface', () => { />, ); - await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); + await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); expect(screen.getByRole('menuitemradio', { name: RE_PI_SONNET })).toBeTruthy(); - // And it stays sendable: with no account resolving, the agent picks its own model. await user.keyboard('{Escape}'); typeInComposer('hello'); await pressInComposer('Enter'); @@ -954,42 +947,15 @@ describe('NewSessionSurface', () => { expect(onSubmit).toHaveBeenCalledTimes(1); }); - it('does not let an adapter default unblock a send the daemon would refuse', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); - render( - , - ); - - typeInComposer('hello'); - await pressInComposer('Enter'); - await wait(0); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('pins no account on an untouched draft, leaving the agent’s default authoritative', async () => { + it('pins no account on an untouched draft, leaving the daemon to derive the same head', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); const shared = (accountId: string) => ({ id: 'shared-model', label: 'Shared', accountId }); render( { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { it('does not lend the catalog effort to a model the catalog did not default to', () => { render( { const { defaultModel: _unset, ...modelless } = PI_CONFIGURED_CATALOG; render( { expect(screen.getByRole('button', { name: RE_HIGH_EFFORT })).toBeTruthy(); }); - it("lets a LinkCode-configured default outrank the agent's own", () => { - render( - , - ); - - expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); - }); - - it("lets the configured model outrank the agent's own default", async () => { + it("lets an account model outrank the agent's own default", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} onSubmit={onSubmit} - defaultModels={{ pi: 'pi/basic' }} workspaces={[]} />, ); - // The configured model wins the display over `catalog.defaultModel`; neither travels, since the - // daemon resolves the configured one itself. - expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); - typeInComposer('use my last model'); + // The account model heads the list and so wins the display over `catalog.defaultModel`; neither + // travels, since the daemon derives the same head. + expect(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })).toBeTruthy(); + typeInComposer('use the account model'); await pressInComposer('Enter'); await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/shell/agent-models.ts index 0705b3a3..dd0b7af1 100644 --- a/packages/presentation/ui/src/shell/agent-models.ts +++ b/packages/presentation/ui/src/shell/agent-models.ts @@ -30,24 +30,6 @@ export function modelChoiceKey(option: ModelOption): string { return `${option.accountId ?? ''}:${option.id}`; } -/** - * The models a surface may offer. An agent resolving *through* an account offers that account world - * alone. One running on its own login keeps its own catalog and merely *gains* the accounts as extra - * options — replacing it would let a key added for one agent hijack another's menu, since agents - * that accept any endpoint (opencode, pi) treat every account as bindable. - */ -export function pickableModels( - accountSet: ModelOption[] | null | undefined, - ownCatalog: ModelOption[] | null | undefined, - { throughAccount }: { throughAccount: boolean }, -): ModelOption[] | undefined { - // Null and absent both mean "this source offers nothing"; only present-and-empty is a real set. - if (throughAccount) return accountSet ?? undefined; - if (accountSet === null || accountSet === undefined) return ownCatalog ?? undefined; - if (ownCatalog === null || ownCatalog === undefined) return accountSet; - return [...accountSet, ...ownCatalog]; -} - /** Whether picking this entry leaves the account a session is currently running on. Credentials and * base URL are injected once at spawn, so such a pick relaunches the agent rather than rebinding it * in place. Unknown accounts on either side mean the question doesn't apply. */ diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 30d7715f..e0a177c8 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -8,7 +8,6 @@ import { ConversationView } from '../chat/conversation-view'; import type { ConversationViewModel, PromptEditState } from '../chat/types'; import { cn } from '../lib/cn'; import type { ModelOption } from './agent-models'; -import { pickableModels } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './composer'; @@ -192,12 +191,9 @@ export function ConversationSurface({ approvalPolicy={conversation.approvalPolicy} currentModel={conversation.currentModel} currentEffort={conversation.currentEffort} - // The session account's picked set is the user's own answer to "which models may this run - // on", so it outranks the adapter catalog — but only for a thread that resolved through an - // account at all. One on its own CLI login keeps its own catalog on offer. - agentModels={pickableModels(accountModels, conversation.availableModels, { - throughAccount: accountId !== undefined, - })} + // Same list as a draft: the accounts' picked models lead, and whatever the agent resolves + // on its own login follows. A cross-account pick here relaunches the thread. + agentModels={[...(accountModels ?? []), ...(conversation.availableModels ?? [])]} currentAccountId={accountId} // Live thread: leaving its account means relaunching the agent, which the menu says out // loud rather than letting a process restart happen invisibly. diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 27fedb29..535a4d04 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -36,7 +36,7 @@ import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; import type { ModelOption } from './agent-models'; -import { AGENT_MODEL_OPTIONS, pickableModels, resolveModel } from './agent-models'; +import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; @@ -89,15 +89,8 @@ export interface NewSessionSurfaceProps { /** Frontend capability stub used until attachment support is advertised by sessions. */ attachmentSupport?: AttachmentSupportByAgent; agentCatalogs?: AgentStartCatalogs; - /** Effective user-configured model defaults. `null` means they are still loading; when omitted, - * built-in harness defaults fill missing kinds for standalone consumers. */ - defaultModels?: Readonly>> | null; - /** The account each agent falls back to when a session names none. Its presence is what makes an - * agent resolve *through* an account: absent, the agent runs on its own CLI login and keeps its - * own catalog on offer. */ - defaultAccounts?: Readonly>>; - /** The models each agent offers, from every account enabled for it. An agent absent here has no - * account that can back it and falls back to its adapter catalog or the curated table. */ + /** The models each agent offers, from every account enabled for it. They lead the picker and their + * head is the agent's default; the agent's own catalog follows for a run on its own login. */ accountModels?: Readonly>> | null; /** Last accepted effort per harness. Missing kinds retain the harness default. */ preferredEfforts?: Readonly>>; @@ -151,8 +144,6 @@ export function NewSessionSurface({ runtimeCues, attachmentSupport, agentCatalogs, - defaultModels, - defaultAccounts, accountModels, preferredEfforts, preferredBranches, @@ -194,30 +185,29 @@ export function NewSessionSurface({ const catalog = agentCatalogs?.[harness]; const localModel = selectedModels[harness]; const selectedModel = localModel === undefined ? null : localModel; - // A default account means the agent resolves through one, and then its enabled accounts' picked - // sets are the only model source — the adapter's own default is not a candidate at all, and - // offering it would show a model the daemon then refuses. Without one the agent runs on its own - // login and keeps deciding for itself. - const defaultAccountId = defaultAccounts?.[harness]; - const throughAccount = defaultAccountId !== undefined; - const displayedModel = - selectedModel ?? - (defaultModels === null - ? null - : (defaultModels?.[harness] ?? (throughAccount ? null : (catalog?.defaultModel ?? null)))); const localEffort = selectedEfforts[harness]; const effort = localEffort === undefined ? (preferredEfforts?.[harness] ?? null) : localEffort; const dynamicModels = catalog && catalog.models.length > 0 ? catalog.models : null; - const accountSet = accountModels?.[harness]; - const pickable = pickableModels(accountSet, dynamicModels ?? AGENT_MODEL_OPTIONS[harness], { - throughAccount, - }); + // The accounts' picked models first, then whatever the agent offers on its own login. There is no + // configured default: an untouched draft shows the head of the account part, which is the same + // entry the daemon derives, so the two agree on what "unpicked" means. With no account the agent + // resolves for itself, and only its own advertised default is an honest thing to show — the + // curated table's first row would name a model the session may well not start on. + const ownCatalog = dynamicModels ?? AGENT_MODEL_OPTIONS[harness] ?? []; + const accountSet = accountModels?.[harness] ?? []; + const pickable: ModelOption[] = [...accountSet, ...ownCatalog]; const localAccount = selectedAccounts[harness]; - // Untouched, the draft reads against the agent's own default account, so a model id two accounts - // both serve resolves to the right entry instead of whichever comes first in the pool. - const selectedAccountId = - localAccount === undefined ? defaultAccountId : (localAccount ?? undefined); - const modelOption = resolveModel(pickable, displayedModel, selectedAccountId); + // `null` is "the accounts have not loaded", so there is no head yet: showing the agent's own + // default would flip to an account model the moment they arrive. + const head = + accountModels === null + ? undefined + : (accountSet[0] ?? resolveModel(ownCatalog, catalog?.defaultModel ?? null)); + const modelOption = + selectedModel === null + ? head + : resolveModel(pickable, selectedModel, localAccount ?? undefined); + const displayedModel = selectedModel ?? modelOption?.id ?? catalog?.defaultModel ?? null; const effortLevels = modelOption?.effortLevels; const constrainedEffort = effortLevels === undefined || effortLevels.includes(effort ?? 'low') ? effort : null; @@ -388,15 +378,15 @@ export function NewSessionSurface({ mentionItems={mentionItems} onMentionQueryChange={(query) => onMentionQueryChange(selected?.cwd, query)} runtimeCues={runtimeCues} - // Mirrors the daemon: once an account resolves, its set is the only model source, so an - // unresolved model would be refused anyway — refuse here instead of after a round trip. - // An agent with no account resolving still picks its own model, and must not be blocked. - sendBlocked={cue !== undefined || (throughAccount && displayedModel === null)} + // Only the runtime gates sending. An empty list is not a refusal: an agent with no + // account still resolves its own model, and the one case the daemon does refuse — an + // account pinned with nothing picked — reports itself rather than greying the button. + sendBlocked={cue !== undefined} currentModeId={modeId} currentModel={displayedModel} currentEffort={displayedEffort} - agentModels={pickable ?? null} - currentAccountId={selectedAccountId} + agentModels={pickable.length > 0 ? pickable : null} + currentAccountId={modelOption?.accountId} approvalPolicy={approvalPolicy} approvalPolicyPlaceholder={t('permissionMode')} selectableHarnesses={SELECTABLE_HARNESSES} diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 6e2d3036..054e6fe7 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -56,12 +56,8 @@ export interface ShellFrameProps /** Frontend capability stub used until attachment support is advertised by sessions. */ attachmentSupport?: AttachmentSupportByAgent; agentCatalogs?: AgentStartCatalogs; - /** Effective daemon-configured default models for new sessions; null while unresolved. */ - newSessionDefaultModels: Readonly>> | null; - /** The account each agent falls back to when a session names none. */ - newSessionDefaultAccounts?: Readonly>>; - /** The models each agent may run on, pooled from the accounts enabled for it. An agent absent here - * has no account that can back it and falls back to its adapter or the curated table. */ + /** The models each agent may run on, pooled from the accounts enabled for it, in the order the + * pickers offer them — the head is the agent's default. */ accountModels: Readonly>> | null; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; @@ -134,8 +130,6 @@ export function ShellFrame({ runtimeCues, attachmentSupport, agentCatalogs, - newSessionDefaultModels, - newSessionDefaultAccounts, accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, @@ -226,8 +220,6 @@ export function ShellFrame({ runtimeCues={runtimeCues} attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} - defaultModels={newSessionDefaultModels} - defaultAccounts={newSessionDefaultAccounts} accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} From 328f1872ce2a7ef9cd2289886b0d081f20b24ec5 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 10 Aug 2026 18:30:45 +0800 Subject: [PATCH 30/32] refactor(ui,workbench,i18n,providers): make enabled an account's only per-agent state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account dialog carried a second axis — a star naming the agent's fallback, and a model select on that one pairing — whose whole job was to fill defaults nothing reads any more. What is left is one switch, and the rows that read it now answer 'which agents offer this account'. --- .../__tests__/onboarding.test.ts | 6 +- .../workbench/src/agent-runtime/onboarding.ts | 12 +- .../settings/providers/__tests__/view.test.ts | 101 ++++---------- .../settings/providers/providers-settings.tsx | 36 +---- .../workbench/src/settings/providers/view.ts | 124 +++++------------ .../providers/src/enabled-models.ts | 25 +++- packages/foundation/providers/src/index.ts | 2 +- packages/presentation/i18n/src/locales/en.ts | 6 - .../presentation/i18n/src/locales/zh-cn.ts | 6 - .../ui/src/shell/providers/account-detail.tsx | 126 ++---------------- 10 files changed, 101 insertions(+), 343 deletions(-) diff --git a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts index ac8de7d8..4ed99648 100644 --- a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts @@ -244,12 +244,12 @@ describe('deriveAgentRuntimeCues', () => { ).toEqual({}); }); - it('suppresses the login cue for a bound key account, but not for a bound oauth one', () => { + it('suppresses the login cue for an enabled key account, but not for an oauth one', () => { const runtimes: AgentRuntimes = { 'claude-code': { status: 'available', source: 'detected', auth: { loggedIn: false } }, }; const providers: ProvidersConfig = { - 'claude-code': { enabled: true, activeAccountId: 'acc_1' }, + 'claude-code': { enabled: true, enabledAccountIds: ['acc_1'] }, }; const relay: Accounts = [ { @@ -273,7 +273,7 @@ describe('deriveAgentRuntimeCues', () => { expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, delegated)).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' }, }); - // A stale binding (account deleted) leaves nothing injected either. + // An enabled list naming an account that no longer exists leaves nothing injected either. expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, [])).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' }, }); diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index 1a7fa451..ffb8cec0 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -1,4 +1,5 @@ import { useLinkCodeClient } from '@linkcode/client-core'; +import { enabledAccounts } from '@linkcode/providers'; import type { Accounts, AgentKind, @@ -125,8 +126,8 @@ export function deriveAgentRuntimeCues( /** * Whether LinkCode injects a secret for this agent at spawn, which makes a signed-out CLI runnable - * (`applyProviderDefaults`): the bound account's own key/token, or the legacy per-agent api key. An - * `oauth` account delegates back to the CLI's login store, so it does not count. + * (`applyProviderDefaults`): any enabled account's own key/token, or the legacy per-agent api key. + * An `oauth` account delegates back to the CLI's login store, so it does not count. */ export function hasInjectedCredential( kind: AgentKind, @@ -134,10 +135,9 @@ export function hasInjectedCredential( accounts: Accounts, ): boolean { if (providers[kind]?.apiKey?.trim()) return true; - const boundId = providers[kind]?.activeAccountId; - if (boundId === undefined) return false; - const bound = accounts.find((account) => account.id === boundId); - return bound?.credential.type === 'api-key' || bound?.credential.type === 'auth-token'; + return enabledAccounts(accounts, providers, kind).some( + ({ credential }) => credential.type === 'api-key' || credential.type === 'auth-token', + ); } /** The login cue for a signed-out runtime, its phase driven by any in-flight login activity. */ diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index b4a7f574..bbeb0154 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -7,67 +7,16 @@ import { maskSecret, providerAccountListViewModel, withAccountEnabled, - withDefaultAccount, - withModel, withoutAccount, } from '../view'; const providers: ProvidersConfig = { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', model: 'claude-opus-4-8' }, - codex: { enabled: false, activeAccountId: 'acc_b' }, + 'claude-code': { enabled: true, enabledAccountIds: ['acc_a'] }, + codex: { enabled: false, enabledAccountIds: ['acc_b'] }, opencode: { enabled: true }, }; describe('provider config transforms', () => { - it('sets the default while preserving the entry and defaults enabled for a fresh kind', () => { - const next = withDefaultAccount(providers, 'codex', 'acc_a'); - expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_a' }); - expect(withDefaultAccount(providers, 'pi', 'acc_a').pi).toEqual({ - enabled: true, - activeAccountId: 'acc_a', - }); - }); - - it('clears the default by dropping only activeAccountId', () => { - const next = withDefaultAccount(providers, 'claude-code', undefined); - expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); - }); - - it('drops a pick the newly bound account does not offer, and keeps one it does', () => { - const offers = (id: string, models: string[]): Accounts[number] => ({ - id, - label: id, - credential: { type: 'api-key', key: 'k' }, - models: models.map((model) => ({ id: model })), - createdAt: 0, - }); - const pool = [offers('acc_keep', ['claude-opus-4-8']), offers('acc_drop', ['deepseek-v4-pro'])]; - - // Moving the default to an account that lists the pick leaves it alone. - expect(withDefaultAccount(providers, 'claude-code', 'acc_keep', pool)['claude-code']).toEqual({ - enabled: true, - activeAccountId: 'acc_keep', - model: 'claude-opus-4-8', - }); - // One that does not would otherwise start the next session on a model it never listed. - expect(withDefaultAccount(providers, 'claude-code', 'acc_drop', pool)['claude-code']).toEqual({ - enabled: true, - activeAccountId: 'acc_drop', - }); - }); - - it('sets and clears the default model without touching the binding', () => { - expect(withModel(providers, 'claude-code', 'claude-sonnet-5')['claude-code']).toEqual({ - enabled: true, - activeAccountId: 'acc_a', - model: 'claude-sonnet-5', - }); - expect(withModel(providers, 'claude-code', undefined)['claude-code']).toEqual({ - enabled: true, - activeAccountId: 'acc_a', - }); - }); - it('materializes the enabled list from what is bindable on the first disable', () => { const pool: Accounts = [ { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, @@ -82,40 +31,39 @@ describe('provider config transforms', () => { expect(reEnabled.opencode?.enabledAccountIds).toEqual(['acc_b', 'acc_a']); }); - it('clears the default when the account serving it is disabled', () => { + it('empties the enabled list rather than dropping it, which would re-offer everything', () => { const pool: Accounts = [ { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, ]; - // Leaving it would keep resolving unpinned sessions onto an account just removed from the menu. const next = withAccountEnabled(providers, 'claude-code', 'acc_a', false, pool); - expect(next['claude-code']?.activeAccountId).toBeUndefined(); expect(next['claude-code']?.enabledAccountIds).toEqual([]); }); - it('leaves the default alone when a non-default account is disabled', () => { - const pool: Accounts = [ - { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, - { id: 'acc_b', label: 'B', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, - ]; - const next = withAccountEnabled(providers, 'claude-code', 'acc_b', false, pool); - expect(next['claude-code']?.activeAccountId).toBe('acc_a'); - }); - - it('clears every binding of a removed account, identity-stable when none matched', () => { + it('drops a removed account from every enabled list, identity-stable when none named it', () => { const next = withoutAccount(providers, 'acc_a'); - expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); - expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_b' }); + expect(next['claude-code']).toEqual({ enabled: true, enabledAccountIds: [] }); + expect(next.codex).toEqual({ enabled: false, enabledAccountIds: ['acc_b'] }); expect(withoutAccount(providers, 'acc_missing')).toBe(providers); }); }); describe('view helpers', () => { - it('lists bound agents in stable order and renders the config snippet from them', () => { - expect(boundAgentKinds(providers, 'acc_a')).toEqual(['claude-code']); - const snippet = accountConfigSnippet(providers, 'acc_a'); + it('lists the agents offering this account in stable order, and snippets them', () => { + const anthropic: Accounts[number] = { + id: 'acc_a', + label: 'Anthropic', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'k' }, + createdAt: 0, + }; + // `opencode` and `pi` name no list, which means every bindable account — including this one. + // `codex` lists only `acc_b`, and `grok-build` takes no endpoint at all. + expect(boundAgentKinds(anthropic, providers)).toEqual(['claude-code', 'opencode', 'pi']); + const snippet = accountConfigSnippet(anthropic, providers); expect(JSON.parse(snippet)).toEqual({ providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', model: 'claude-opus-4-8' }, + 'claude-code': { enabled: true, enabledAccountIds: ['acc_a'] }, + opencode: { enabled: true }, }, }); }); @@ -172,7 +120,9 @@ describe('view helpers', () => { // the same answer the resolver gives, rather than a pin it will ignore. routing: { kind: 'catalog', protocols: ['openai-chat', 'openai-responses'] }, credentialType: 'api-key', - boundAgents: ['claude-code'], + // Enabled for claude-code by name, and for the two endpoint-agnostic agents by an absent + // list; codex lists only acc_b, and grok-build takes no endpoint at all. + boundAgents: ['claude-code', 'opencode', 'pi'], }, { id: 'acc_b', @@ -181,7 +131,8 @@ describe('view helpers', () => { serviceLabel: 'Claude', credentialType: 'oauth', auth: { loggedIn: true, email: 'claude@example.com' }, - boundAgents: ['codex'], + // An oauth login serves only its own agent, and claude-code's list does not name it. + boundAgents: [], }, { id: 'acc_c', @@ -196,7 +147,7 @@ describe('view helpers', () => { protocol: 'openai-chat', }, credentialType: 'auth-token', - boundAgents: [], + boundAgents: ['opencode', 'pi'], }, ], }); diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 9f528f2f..adea174d 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -1,12 +1,6 @@ import { serviceById } from '@linkcode/providers'; import type { Account, AgentKind, ProvidersConfig } from '@linkcode/schema'; -import { - createAndBindAccount, - getAccounts, - getProviderConfig, - setAccounts, - setProviderConfig, -} from '@linkcode/sdk'; +import { getAccounts, getProviderConfig, setAccounts, setProviderConfig } from '@linkcode/sdk'; import { AccountDetail, AccountList } from '@linkcode/ui'; import { Dialog, @@ -27,8 +21,6 @@ import { providerAccountDetailViewModel, providerAccountListViewModel, withAccountEnabled, - withDefaultAccount, - withModel, withoutAccount, } from './view'; @@ -47,7 +39,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); const { data: runtimes } = useAgentRuntimes(); const onboarding = useAgentRuntimeOnboarding(); - const bindAccount = useMutation(createAndBindAccount); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); // The forms are presentation; only this page sits inside the data-plane provider tree. @@ -65,11 +56,11 @@ export function ProvidersSettingsPanel(): React.ReactNode { const pool = accounts ?? []; const accountsById = new Map(pool.map((account) => [account.id, account])); const selected = view.kind === 'account' ? accountsById.get(view.accountId) : undefined; - const busy = bindAccount.isMutating || saveAccounts.isMutating || saveProviders.isMutating; + const busy = saveAccounts.isMutating || saveProviders.isMutating; const selectedDetail = selected === undefined ? undefined - : providerAccountDetailViewModel(selected, pool, providers, runtimes); + : providerAccountDetailViewModel(selected, providers, runtimes); const accountList = providerAccountListViewModel(pool, providers, runtimes); const applyProviders = async (next: ProvidersConfig): Promise => { @@ -77,27 +68,16 @@ export function ProvidersSettingsPanel(): React.ReactNode { void mutateProviders(); }; - const handleSetDefaultAccount = (kind: AgentKind, accountId: string | undefined): void => { - void applyProviders(withDefaultAccount(providers ?? {}, kind, accountId, pool)); - }; - const handleSetAccountEnabled = (kind: AgentKind, enabled: boolean): void => { if (!selected) return; void applyProviders(withAccountEnabled(providers ?? {}, kind, selected.id, enabled, pool)); }; - const handleSetModel = (kind: AgentKind, model: string | undefined): void => { - void applyProviders(withModel(providers ?? {}, kind, model)); - }; - + // Every account joins the pool the same way. A subscription used to bind itself to its agent on + // the way in; with no default to claim, adding one is adding one. const handleAdd = async (account: Account): Promise => { - if (account.credential.type === 'oauth') { - await bindAccount.trigger({ agent: account.credential.agent, account }); - await Promise.all([mutateAccounts(), mutateProviders()]); - } else { - await saveAccounts.trigger({ accounts: [...pool, account] }); - await mutateAccounts(); - } + await saveAccounts.trigger({ accounts: [...pool, account] }); + await mutateAccounts(); closeDialog(); }; @@ -193,8 +173,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { account={selectedDetail} busy={busy} onSetAccountEnabled={handleSetAccountEnabled} - onSetDefaultAccount={handleSetDefaultAccount} - onSetModel={handleSetModel} onEdit={startEdit} onRemove={() => { void handleRemove(); diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index fdd21b8f..0019f2d3 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -33,21 +33,26 @@ export function maskSecret(secret: string): string { return `${secret.slice(0, 6)}…${secret.slice(-4)}`; } -/** Agents that fall back to this account when nothing names one, in stable agent order. */ +/** Agents whose pickers offer this account's models, in stable agent order. Enablement alone is not + * enough — an absent list enables every agent, including the ones this account cannot back. */ export function boundAgentKinds( + account: Account, providers: ProvidersConfig | undefined, - accountId: string, ): AgentKind[] { - return AGENT_KINDS.filter((kind) => providers?.[kind]?.activeAccountId === accountId); + return AGENT_KINDS.filter( + (kind) => + resolveBinding(account, kind).tier !== 'unavailable' && + accountEnabledFor(providers, kind, account.id), + ); } /** The `providers` slice this account writes into `~/.linkcode/config.json`, pretty-printed for * the detail pane preview. Contains no secret (the account itself holds the credential). */ export function accountConfigSnippet( + account: Account, providers: ProvidersConfig | undefined, - accountId: string, ): string { - const bound = boundAgentKinds(providers, accountId); + const bound = boundAgentKinds(account, providers); const slice: Record = {}; for (const kind of bound) slice[kind] = providers?.[kind]; return JSON.stringify({ providers: slice }, null, 2); @@ -88,14 +93,10 @@ function credentialViewModel( function agentStatus( account: Account, - accountLabels: ReadonlyMap, kind: AgentKind, providers: ProvidersConfig | undefined, -): Omit { +): Omit { const availability = resolveBinding(account, kind); - const defaultId = providers?.[kind]?.activeAccountId; - const isDefault = defaultId === account.id; - const enabled = accountEnabledFor(providers, kind, account.id); if (availability.tier === 'unavailable') { const status: ProviderAgentStatus = availability.reason === 'oauth-other-agent' && account.credential.type === 'oauth' @@ -106,40 +107,27 @@ function agentStatus( ? 'unavailable-endpoint-incomplete' : 'unavailable-protocol', }; - return { tier: availability.tier, enabled: false, isDefault: false, status }; + return { tier: availability.tier, enabled: false, status }; } + // Enabled is the whole state, and the switch already shows it — only a reason to be off earns text. + const enabled = accountEnabledFor(providers, kind, account.id); return { tier: availability.tier, enabled, - isDefault, - status: !enabled - ? { kind: 'disabled' } - : isDefault - ? { kind: 'default' } - : defaultId === undefined - ? { kind: 'enabled-no-default' } - : { kind: 'enabled', defaultLabel: accountLabels.get(defaultId) ?? defaultId }, + ...(!enabled && { status: { kind: 'disabled' } }), }; } /** Selected account plus precomputed binding rows; UI owns only rendering and local interaction. */ export function providerAccountDetailViewModel( account: Account, - accounts: Accounts, providers: ProvidersConfig | undefined, runtimes: AgentRuntimes | undefined, ): ProviderAccountDetailViewModel { - const accountLabels = new Map(accounts.map((candidate) => [candidate.id, candidate.label])); - const agents = AGENT_KINDS.map((kind): ProviderAgentViewModel => { - const status = agentStatus(account, accountLabels, kind, providers); - // Only the default account's row edits the default model — the pick belongs to that pairing. - return { - kind, - ...status, - ...(status.isDefault && { defaultModel: providers?.[kind]?.model ?? '' }), - }; - }); - const boundAgents = boundAgentKinds(providers, account.id); + const agents = AGENT_KINDS.map( + (kind): ProviderAgentViewModel => ({ kind, ...agentStatus(account, kind, providers) }), + ); + const boundAgents = boundAgentKinds(account, providers); const serviceLabel = serviceById(account.service)?.label; const routing = accountRouting(account); return { @@ -157,7 +145,7 @@ export function providerAccountDetailViewModel( accountModels: account.models.map(({ id, label }) => ({ id, label: label ?? id })), }), ...(!(boundAgents.length === 0) && { - configPreview: accountConfigSnippet(providers, account.id), + configPreview: accountConfigSnippet(account, providers), }), }; } @@ -188,7 +176,7 @@ function providerAccountListItem( id: account.id, label: account.label, credentialType: account.credential.type, - boundAgents: boundAgentKinds(providers, account.id), + boundAgents: boundAgentKinds(account, providers), ...(account.service !== undefined && { service: account.service }), ...(serviceLabel !== undefined && { serviceLabel }), ...(routing !== undefined && { routing }), @@ -212,42 +200,11 @@ export function providerAccountListViewModel( }; } -/** - * Set (or, with undefined, clear) the account an agent falls back to when nothing names one. Other - * fields survive untouched — with one exception. The default model lives per agent while the set it - * came from lives on the account, so moving the default can orphan it. Dropping a model the new - * account does not offer leaves the agent unpicked, which blocks its unpinned sends until the user - * chooses again; keeping it would run the next one on a model that account never listed. - */ -export function withDefaultAccount( - providers: ProvidersConfig, - kind: AgentKind, - accountId: string | undefined, - accounts: Accounts = [], -): ProvidersConfig { - const entry = providers[kind] ?? { enabled: true }; - if (accountId === undefined) { - const { activeAccountId: _cleared, ...rest } = entry; - return { ...providers, [kind]: rest }; - } - const offered = accounts.find((candidate) => candidate.id === accountId)?.models; - const orphaned = - entry.model !== undefined && !(offered ?? []).some(({ id }) => id === entry.model); - const { model: _dropped, ...kept } = entry; - return { - ...providers, - [kind]: { ...(orphaned ? kept : entry), activeAccountId: accountId }, - }; -} - /** * Show or hide one account's models in an agent's pickers. Absent `enabledAccountIds` means every * bindable account, so the first disable has to materialize the list from what is bindable *now* — * otherwise hiding one account would read as "only this one", hiding every other account too. Once * the list exists it is authoritative, so an account added later stays out until enabled. - * - * Disabling the agent's default account also clears the default: leaving it would keep resolving - * unpinned sessions onto an account the user just removed from the menu. */ export function withAccountEnabled( providers: ProvidersConfig, @@ -266,10 +223,7 @@ export function withAccountEnabled( const next = enabled ? [...new Set([...current, accountId])] : current.filter((id) => id !== accountId); - const withList: ProvidersConfig = { ...providers, [kind]: { ...entry, enabledAccountIds: next } }; - return enabled || entry.activeAccountId !== accountId - ? withList - : withDefaultAccount(withList, kind, undefined); + return { ...providers, [kind]: { ...entry, enabledAccountIds: next } }; } /** Toggle whether the agent is offered in the client's agent picker. */ @@ -281,38 +235,20 @@ export function withEnabled( return { ...providers, [kind]: { ...providers[kind], enabled } }; } -/** - * Set (or, with undefined, clear) the model an agent runs on. Passing the account the model came - * from rebinds the agent to it, because a model and the account serving it are one choice — leaving - * the old binding in place would run the next session on an account that never listed this model. - */ -export function withModel( - providers: ProvidersConfig, - kind: AgentKind, - model: string | undefined, - accountId?: string, -): ProvidersConfig { - const entry = providers[kind] ?? { enabled: true }; - if (model === undefined) { - const { model: _cleared, ...rest } = entry; - return { ...providers, [kind]: rest }; - } - return { - ...providers, - [kind]: { ...entry, model, ...(accountId !== undefined && { activeAccountId: accountId }) }, - }; -} - -/** Drop every binding referencing a removed account; returns the input unchanged when none did. */ +/** Drop a removed account from every enabled list; returns the input unchanged when none named it. + * An agent left with an absent list would silently re-offer every bindable account, so a list that + * loses its last entry stays present and empty. */ export function withoutAccount(providers: ProvidersConfig, accountId: string): ProvidersConfig { let changed = false; const next: ProvidersConfig = {}; for (const kind of AGENT_KINDS) { const entry = providers[kind]; if (entry === undefined) continue; - if (entry.activeAccountId === accountId) { - const { activeAccountId: _cleared, ...rest } = entry; - next[kind] = rest; + if (entry.enabledAccountIds?.includes(accountId)) { + next[kind] = { + ...entry, + enabledAccountIds: entry.enabledAccountIds.filter((id) => id !== accountId), + }; changed = true; } else { next[kind] = entry; diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts index 32ad4812..cc54b1f7 100644 --- a/packages/foundation/providers/src/enabled-models.ts +++ b/packages/foundation/providers/src/enabled-models.ts @@ -35,11 +35,22 @@ export function enabledAccountModels( providers: ProvidersConfig | undefined, kind: AgentKind, ): EnabledAccountModel[] { - const offered: EnabledAccountModel[] = []; - for (const account of accounts) { - if (resolveBinding(account, kind).tier === 'unavailable') continue; - if (!accountEnabledFor(providers, kind, account.id)) continue; - for (const model of account.models ?? []) offered.push({ account, model }); - } - return offered; + return enabledAccounts(accounts, providers, kind).flatMap((account) => + (account.models ?? []).map((model) => ({ account, model })), + ); +} + +/** The accounts this agent may resolve to, in pool order. An account with no picked model is still + * one of them: it contributes nothing to the pickers, but it can still back a session pinned to it, + * and its credential is still what a signed-out CLI would run on. */ +export function enabledAccounts( + accounts: Accounts, + providers: ProvidersConfig | undefined, + kind: AgentKind, +): Account[] { + return accounts.filter( + (account) => + resolveBinding(account, kind).tier !== 'unavailable' && + accountEnabledFor(providers, kind, account.id), + ); } diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index 5f7429c1..f8c38251 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -14,7 +14,7 @@ export { export type { DetectedLogin } from './detected-logins'; export { detectedLogins } from './detected-logins'; export type { EnabledAccountModel } from './enabled-models'; -export { accountEnabledFor, enabledAccountModels } from './enabled-models'; +export { accountEnabledFor, enabledAccountModels, enabledAccounts } from './enabled-models'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; export { pinnedEndpoint, resolveBinding, serviceProtocols } from './resolve'; export { fillTemplate, isTemplateFilled, templatePlaceholders } from './template'; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 203e4929..88fa8eb0 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1039,18 +1039,12 @@ export const en = { oauthDelegate: 'Follows the {agent} CLI login', connections: 'Connected agents', connectionsEnabled: '{bound} / {available} enabled', - accountDefault: 'Shown in the model menu · used when no account is named', - accountEnabled: 'Shown in the model menu · “{label}” used when none is named', - accountEnabledNoDefault: 'Shown in the model menu · follows the CLI login when none is named', accountDisabled: 'Hidden from this agent’s model menu', - setDefaultAccount: 'Use as default account', - clearDefaultAccount: 'Stop using as default account', translateBadge: 'Translated', translateNote: 'A local gateway translates Anthropic wire to OpenAI Chat', unavailableOauth: 'Only connects to {agent}', unavailableProtocol: 'The endpoint protocol is incompatible with this agent', unavailableEndpointIncomplete: 'Endpoint details are incomplete — finish the account setup', - modelDefault: 'Default', configPreview: 'config.json snippet · what this account writes', configPreviewEmpty: '// not connected to any agent yet', remove: 'Remove account', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index ab83973b..85adeac7 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1013,18 +1013,12 @@ export const zhCN = { oauthDelegate: '跟随 {agent} CLI 登录', connections: '接入的智能体', connectionsEnabled: '{bound} / {available} 已启用', - accountDefault: '在模型菜单中显示 · 未指定账号时使用此账号', - accountEnabled: '在模型菜单中显示 · 未指定时使用「{label}」', - accountEnabledNoDefault: '在模型菜单中显示 · 未指定账号时跟随 CLI 登录', accountDisabled: '不在此智能体的模型菜单中显示', - setDefaultAccount: '设为默认账号', - clearDefaultAccount: '取消默认账号', translateBadge: '经转换', translateNote: '本地网关将 Anthropic 协议转为 OpenAI Chat', unavailableOauth: '仅可接入 {agent}', unavailableProtocol: '端点协议与此智能体不兼容', unavailableEndpointIncomplete: '端点信息不完整,请补全账号设置', - modelDefault: '默认', configPreview: 'config.json 片段 · 此账号写入的内容', configPreviewEmpty: '// 尚未接入任何智能体', remove: '移除账号', diff --git a/packages/presentation/ui/src/shell/providers/account-detail.tsx b/packages/presentation/ui/src/shell/providers/account-detail.tsx index e89947ef..aa7f08dd 100644 --- a/packages/presentation/ui/src/shell/providers/account-detail.tsx +++ b/packages/presentation/ui/src/shell/providers/account-detail.tsx @@ -12,13 +12,6 @@ import { Badge } from 'coss-ui/components/badge'; import { Button } from 'coss-ui/components/button'; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from 'coss-ui/components/collapsible'; import { Menu, MenuItem, MenuPopup, MenuTrigger } from 'coss-ui/components/menu'; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from 'coss-ui/components/select'; import { Switch } from 'coss-ui/components/switch'; import { useClipboard } from 'foxact/use-clipboard'; import { @@ -29,13 +22,11 @@ import { EyeOffIcon, MoreHorizontalIcon, PencilIcon, - StarIcon, Trash2Icon, } from 'lucide-react'; import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { AgentIcon } from '../../chat/agent-icon'; -import { AGENT_MODEL_OPTIONS } from '../agent-models'; import { ServiceIcon } from '../service-icon'; import type { ProviderAccountRouting } from './routing'; @@ -43,21 +34,16 @@ export type ProviderAgentStatus = | { kind: 'unavailable-oauth'; agent: AgentKind } | { kind: 'unavailable-endpoint-incomplete' } | { kind: 'unavailable-protocol' } - | { kind: 'disabled' } - | { kind: 'default' } - | { kind: 'enabled-no-default' } - | { kind: 'enabled'; defaultLabel: string }; + | { kind: 'disabled' }; -/** One agent row in an account's dialog: whether this account's models are offered to that agent, - * and whether it is also the agent's fallback for sessions that name no account. */ +/** One agent row in an account's dialog: whether this account's models are offered to that agent. + * That is the whole state — nothing here is a default, and the switch says it without help. */ export interface ProviderAgentViewModel { kind: AgentKind; tier: 'native' | 'translate' | 'unavailable'; - status: ProviderAgentStatus; + /** Only a reason the row cannot be, or is not, on. Absent means enabled and available. */ + status?: ProviderAgentStatus; enabled: boolean; - isDefault: boolean; - /** The agent's default model, present only on the row of the account that serves it. */ - defaultModel?: string; } export type ProviderCredentialViewModel = @@ -110,8 +96,6 @@ export function AccountDetail({ account, busy, onSetAccountEnabled, - onSetDefaultAccount, - onSetModel, onEdit, onRemove, }: { @@ -120,9 +104,6 @@ export function AccountDetail({ busy: boolean; /** Show or hide this account's models in that agent's pickers. */ onSetAccountEnabled: (kind: AgentKind, enabled: boolean) => void; - /** Make this account the agent's fallback, or clear it with undefined. */ - onSetDefaultAccount: (kind: AgentKind, accountId: string | undefined) => void; - onSetModel: (kind: AgentKind, model: string | undefined) => void; onEdit: () => void; onRemove: () => void; }): React.ReactNode { @@ -237,13 +218,9 @@ export function AccountDetail({ {account.agents.map((agent) => ( ))}
    @@ -339,50 +316,28 @@ function agentStatusLabel( return t('unavailableProtocol'); case 'disabled': return t('accountDisabled'); - case 'default': - return t('accountDefault'); - case 'enabled-no-default': - return t('accountEnabledNoDefault'); - case 'enabled': - return t('accountEnabled', { label: status.defaultLabel }); default: return status satisfies never; } } -/** - * One agent's row. The switch decides whether this account's models appear in that agent's pickers; - * the star makes it the fallback for sessions that name no account (automation, schedules, IM), and - * only that pairing edits the default model. - */ +/** One agent's row: whether this account's models appear in that agent's pickers. */ function AgentRow({ - accountId, - accountModels, agent, busy, onSetAccountEnabled, - onSetDefaultAccount, - onSetModel, }: { - accountId: string; - accountModels: Array<{ id: string; label: string }>; agent: ProviderAgentViewModel; busy: boolean; onSetAccountEnabled: (kind: AgentKind, enabled: boolean) => void; - onSetDefaultAccount: (kind: AgentKind, accountId: string | undefined) => void; - onSetModel: (kind: AgentKind, model: string | undefined) => void; }): React.ReactNode { const t = useTranslations('settings.providers'); const tAgent = useTranslations('workbench.agentKind'); const unavailable = agent.tier === 'unavailable'; - const status = agentStatusLabel(t, tAgent, agent.status); - const note = agent.tier === 'translate' ? `${t('translateNote')} · ${status}` : status; - // An account with no listed models still serves the agent's own catalog, so fall back to the - // curated table rather than offering an empty select. - const modelOptions = - accountModels.length > 0 ? accountModels : (AGENT_MODEL_OPTIONS[agent.kind] ?? []); - const defaultModel = agent.defaultModel; + const status = agent.status && agentStatusLabel(t, tAgent, agent.status); + const note = + agent.tier === 'translate' ? [t('translateNote'), status].filter(Boolean).join(' · ') : status; return (
    {t('translateBadge')} ) : null}
    -

    {note}

    + {note ?

    {note}

    : null}
    - {defaultModel !== undefined && modelOptions.length > 0 ? ( - option.id === defaultModel) - ? modelOptions - : [...modelOptions, { id: defaultModel, label: defaultModel }] - } - value={defaultModel} - disabled={busy} - onChange={(model) => onSetModel(agent.kind, model === '' ? undefined : model)} - /> - ) : null} - ); } - -function ModelSelect({ - options, - value, - disabled, - onChange, -}: { - options: Array<{ id: string; label: string }>; - value: string; - disabled: boolean; - onChange: (model: string) => void; -}): React.ReactNode { - const t = useTranslations('settings.providers'); - const items = [ - { value: '', label: t('modelDefault') }, - ...options.map((option) => ({ value: option.id, label: option.label })), - ]; - return ( - - ); -} From 1765bb029d8a3d2865060916d27c29ec14b7bba0 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 10 Aug 2026 19:12:41 +0800 Subject: [PATCH 31/32] feat(schema,engine,daemon)!: derive an agent's default instead of storing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: `ProviderConfig` loses `activeAccountId` and `model`, and `config.account.create-and-bind` is gone, so both wire versions move to 76. A request that names no account or model resolves to the head of the agent's enabled-account model list — the entry the composer already shows — so neither side stores an answer the other can contradict. `loadConfig` folds an old default account into the enabled list rather than dropping the access it granted. --- .../src/__tests__/config-persistence.test.ts | 6 +- apps/daemon/src/__tests__/config.test.ts | 26 ++- apps/daemon/src/config.ts | 30 +++- apps/daemon/src/provider-store.ts | 7 - packages/client/core/src/client.ts | 5 - .../client/core/src/client/control-channel.ts | 10 -- packages/client/sdk/src/client.ts | 5 - packages/client/sdk/src/operations.ts | 7 - .../workbench/src/mock/dev-mock-host.ts | 17 -- .../src/settings/agents-settings.tsx | 11 +- .../integration/dev-mock-transport.test.ts | 17 +- .../schema/src/model/provider-config.ts | 20 +-- packages/foundation/schema/src/wire/config.ts | 14 +- .../foundation/schema/src/wire/message.ts | 4 +- packages/host/agent-adapter/AGENTS.md | 2 +- .../__tests__/engine-agent-catalog.test.ts | 2 +- .../__tests__/engine-session-records.test.ts | 42 ++--- .../src/__tests__/provider-config.test.ts | 154 +++++++----------- .../src/__tests__/start-options-mcp.test.ts | 24 ++- .../host/engine/src/agent/provider-config.ts | 64 +++----- .../host/engine/src/agent/request-handler.ts | 10 -- packages/host/engine/src/index.ts | 2 +- .../host/engine/src/wire/request-router.ts | 1 - 23 files changed, 191 insertions(+), 289 deletions(-) diff --git a/apps/daemon/src/__tests__/config-persistence.test.ts b/apps/daemon/src/__tests__/config-persistence.test.ts index 1bd590ae..82a3bf07 100644 --- a/apps/daemon/src/__tests__/config-persistence.test.ts +++ b/apps/daemon/src/__tests__/config-persistence.test.ts @@ -100,17 +100,17 @@ describe('provider config persistence', () => { const store = createProviderConfigStore(createInMemoryVault(), {}, []); store.update({ - providers: { codex: { enabled: true, activeAccountId: oauthAccount.id } }, + providers: { codex: { enabled: true, enabledAccountIds: [oauthAccount.id] } }, accounts: [oauthAccount], }); expect(readConfig()).toEqual({ hostname: '127.0.0.2', - providers: { codex: { enabled: true, activeAccountId: oauthAccount.id } }, + providers: { codex: { enabled: true, enabledAccountIds: [oauthAccount.id] } }, accounts: [oauthAccount], }); expect(store.get()).toEqual({ - codex: { enabled: true, activeAccountId: oauthAccount.id }, + codex: { enabled: true, enabledAccountIds: [oauthAccount.id] }, }); expect(store.getAccounts()).toEqual([oauthAccount]); expect(statSync(config).mode & 0o777).toBe(0o600); diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 399b3b69..25f906fe 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -76,13 +76,27 @@ describe('loadConfig providers', () => { const config = loadConfig(vault); - // `defaultModel` carries over as the persisted pick; without that it would be silently stripped. - expect(config.providers).toEqual({ - 'claude-code': { enabled: true, model: 'sonnet' }, - }); + // Neither default survives: an agent's only per-account state is which accounts it offers. + expect(config.providers).toEqual({ 'claude-code': { enabled: true } }); expect(errorSpy).toHaveBeenCalled(); }); + it('keeps the old default account by enabling it, and drops both default models', () => { + writeConfig({ + // A narrowed list that omits the default would silently take that account away on upgrade. + 'claude-code': { enabled: true, activeAccountId: 'acc_a', enabledAccountIds: ['acc_b'] }, + codex: { enabled: true, activeAccountId: 'acc_a', model: 'gpt-5.6-sol' }, + opencode: { enabled: true, activeAccountId: 'acc_a', enabledAccountIds: ['acc_a'] }, + }); + + expect(loadConfig(vault).providers).toEqual({ + 'claude-code': { enabled: true, enabledAccountIds: ['acc_b', 'acc_a'] }, + // No list to join: absent already means every bindable account, this one included. + codex: { enabled: true }, + opencode: { enabled: true, enabledAccountIds: ['acc_a'] }, + }); + }); + it('drops an entry keyed by an unknown agent kind, logging the error', () => { const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); writeConfig({ @@ -287,13 +301,13 @@ describe('saveProviderConfiguration', () => { saveProviderConfiguration( vault, - { codex: { enabled: true, activeAccountId: 'acc_1', apiKey: 'sk-provider' } }, + { codex: { enabled: true, enabledAccountIds: ['acc_1'], apiKey: 'sk-provider' } }, [validAccount], ); expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ hostname: '127.0.0.1', - providers: { codex: { enabled: true, activeAccountId: 'acc_1' } }, + providers: { codex: { enabled: true, enabledAccountIds: ['acc_1'] } }, accounts: [{ ...validAccount, credential: { type: 'api-key' } }], }); expect(vault.refs.get('provider:codex')).toBe('sk-provider'); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 690e1cce..75221459 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -230,14 +230,28 @@ function withPickedModels(value: unknown): unknown { return { ...rest, models: [{ id: model }] }; } -/** Same carry-over for the per-agent default, which is now the persisted pick. */ -function withPickedModel(value: unknown): unknown { +/** + * An agent's only per-account state is now which accounts it offers, so the default account and + * default model are dropped on read — zod would strip them anyway. The default account is folded + * into the enabled list first: it was necessarily an account the user meant this agent to use, and + * an explicit list that omitted it would silently take it away. + */ +function withEnabledAccounts(value: unknown): unknown { if (typeof value !== 'object' || value === null) return value; - const { defaultModel, ...rest } = value as { defaultModel?: unknown; model?: unknown }; - if (typeof defaultModel !== 'string' || defaultModel === '' || rest.model !== undefined) { - return rest; - } - return { ...rest, model: defaultModel }; + const { + activeAccountId, + defaultModel: _model, + model: _pick, + ...rest + } = value as { activeAccountId?: unknown; defaultModel?: unknown; model?: unknown } & { + enabledAccountIds?: unknown; + }; + if (typeof activeAccountId !== 'string' || activeAccountId === '') return rest; + const enabled = rest.enabledAccountIds; + if (!Array.isArray(enabled)) return rest; + return enabled.includes(activeAccountId) + ? rest + : { ...rest, enabledAccountIds: [...enabled, activeAccountId] }; } /** @@ -289,7 +303,7 @@ function parseProviders(store: SecretStore, raw: unknown): Parsed { - return this.control.createAndBindAccount(agent, account); - } - setAccounts(accounts: Accounts): Promise { return this.control.setAccounts(accounts); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 15f795e6..73458efe 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,5 +1,4 @@ import type { - Account, AccountModel, AccountSecret, Accounts, @@ -575,15 +574,6 @@ export class ControlChannel { })); } - createAndBindAccount(agent: AgentKind, account: Account): Promise { - return this.sendCorrelated('ack', (clientReqId) => ({ - kind: 'config.account.create-and-bind', - clientReqId, - agent, - account, - })); - } - /** Ask the daemon which models a service serves, so the account forms can offer a real list to * pick from. The daemon must do it — the renderer's CSP blocks the fetch, and it resolves the list * URL from the service catalog itself. Pass a secret the add form has not saved yet, or the id of diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index e2798f0a..61d59728 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -9,7 +9,6 @@ import type { } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { - Account, AccountModel, AccountSecret, Accounts, @@ -276,10 +275,6 @@ export class LinkCodeSdkClient { return toResult(this.raw.setProviderConfig(providers)); } - createAndBindAccount(agent: AgentKind, account: Account): RequestResult<{ ok: true }> { - return toResult(this.raw.createAndBindAccount(agent, account)); - } - /** Read the daemon-owned global account pool (data plane). */ getAccounts(): RequestResult { return toResult(this.raw.getAccounts()); diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index 5b8e0d45..b9117f96 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -6,7 +6,6 @@ import type { SessionStartResult, } from '@linkcode/client-core'; import type { - Account, AccountModel, AccountSecret, Accounts, @@ -262,12 +261,6 @@ export function setProviderConfig( return resolveClient(options).setProviderConfig(options.providers); } -export function createAndBindAccount( - options: Options<{ agent: AgentKind; account: Account }>, -): RequestResult<{ ok: true }> { - return resolveClient(options).createAndBindAccount(options.agent, options.account); -} - export function getAccounts(options?: Options): RequestResult { return resolveClient(options).getAccounts(); } diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index d5d5f9ae..5d9e7111 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -411,23 +411,6 @@ export class DevMockHost { } this.sendSuccess(p.clientReqId); break; - case 'config.account.create-and-bind': { - await wait(CONTROL_LATENCY_MS); - const account = structuredClone(p.account); - const exists = this.accounts.some((candidate) => candidate.id === account.id); - this.accounts = exists - ? this.accounts.map((candidate) => (candidate.id === account.id ? account : candidate)) - : [...this.accounts, account]; - this.providers = { - ...this.providers, - [p.agent]: { - ...(this.providers[p.agent] ?? { enabled: true }), - activeAccountId: account.id, - }, - }; - this.sendSuccess(p.clientReqId); - break; - } case 'plugin.list.get': await wait(CONTROL_LATENCY_MS); this.send({ diff --git a/packages/client/workbench/src/settings/agents-settings.tsx b/packages/client/workbench/src/settings/agents-settings.tsx index a5c1ec21..eb2249ac 100644 --- a/packages/client/workbench/src/settings/agents-settings.tsx +++ b/packages/client/workbench/src/settings/agents-settings.tsx @@ -1,4 +1,4 @@ -import { resolveBinding } from '@linkcode/providers'; +import { enabledAccounts, resolveBinding } from '@linkcode/providers'; import type { AgentKind, AgentRuntimeAvailability } from '@linkcode/schema'; import { getAccounts, getProviderConfig, setProviderConfig } from '@linkcode/sdk'; import { AgentIcon, AgentOnboardingCard, SettingsCard } from '@linkcode/ui'; @@ -44,14 +44,15 @@ export function AgentsSettingsPanel({ {AGENT_KINDS.map((kind) => { const runtime = runtimes?.[kind]; - const boundId = providers?.[kind]?.activeAccountId; - const boundAccount = accounts?.find((account) => account.id === boundId); + // The first enabled account is what a start that names none resolves to, so it is the one + // worth naming here; the rest are alternatives its model menu offers. + const boundAccount = enabledAccounts(accounts ?? [], providers, kind)[0]; const enabled = providers?.[kind]?.enabled ?? true; // A disabled agent's runtime gaps don't matter — no card, just the badge. const cue = enabled ? onboarding.cues[kind] : undefined; const translated = boundAccount !== undefined && resolveBinding(boundAccount, kind).tier === 'translate'; - // With no bound account the agent follows the CLI login — show who that is when probed. + // With no enabled account the agent follows the CLI login — show who that is when probed. const cliIdentity = boundAccount === undefined && runtime?.auth?.loggedIn === true ? runtime.auth.email @@ -70,7 +71,7 @@ export function AgentsSettingsPanel({ variant="ghost" size="sm" className="-mx-2 h-auto px-2 py-0.5 font-normal text-muted-foreground text-xs" - onClick={() => onOpenProviders(boundId)} + onClick={() => onOpenProviders(boundAccount?.id)} > {boundAccount ? boundAccount.label : t('followCli')} {translated ? ` · ${t('translated')}` : ''} diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index bce1c99d..6c1090de 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -121,7 +121,7 @@ describe('dev mock transport', () => { expect(replyText).toContain('Hello mocked daemon'); const providers = { - codex: { enabled: true, model: 'mock-model' }, + codex: { enabled: true, enabledAccountIds: ['acc_1'] }, } satisfies ProvidersConfig; await client.setProviderConfig(providers); expect(await client.getProviderConfig()).toEqual(providers); @@ -134,21 +134,16 @@ describe('dev mock transport', () => { // Independent fields: writing accounts preserved the provider config. expect(await client.getProviderConfig()).toEqual(providers); - const boundAccount = { + // Adding an account is one write to the pool; nothing about the agent's config moves with it. + const relay = { id: 'acc_2', label: 'Relay', credential: { type: 'api-key', key: 'sk-relay' }, createdAt: 1, } satisfies Accounts[number]; - await client.createAndBindAccount('codex', boundAccount); - await client.createAndBindAccount('codex', { ...boundAccount, label: 'Updated relay' }); - expect(await client.getAccounts()).toEqual([ - accounts[0], - { ...boundAccount, label: 'Updated relay' }, - ]); - expect(await client.getProviderConfig()).toEqual({ - codex: { enabled: true, model: 'mock-model', activeAccountId: 'acc_2' }, - }); + await client.setAccounts([...accounts, relay]); + expect(await client.getAccounts()).toEqual([accounts[0], relay]); + expect(await client.getProviderConfig()).toEqual(providers); client.dispose(); }); diff --git a/packages/foundation/schema/src/model/provider-config.ts b/packages/foundation/schema/src/model/provider-config.ts index 34b8be5c..1467fd98 100644 --- a/packages/foundation/schema/src/model/provider-config.ts +++ b/packages/foundation/schema/src/model/provider-config.ts @@ -7,21 +7,19 @@ import { AgentKindSchema } from './primitives'; export const ProviderConfigSchema = z.object({ /** Whether the agent is offered in the client's agent picker. */ enabled: z.boolean().default(true), - /** The model this agent currently runs on, picked by the user from the bound account's set and - * persisted so it survives across sessions. Not a fallback default: unset means no session can - * start, because nothing else resolves a model. */ - model: z.string().optional(), /** Legacy provider API key, superseded by the global account pool (`account.ts`) but kept so - * pre-account configs still load; the resolver falls back to it when `activeAccountId` is unset. */ + * pre-account configs still load; the resolver falls back to it when no account resolves. */ apiKey: z.string().optional(), - /** Id of the pooled `Account` this agent falls back to when nothing names one: automation, - * schedules, and IM-created threads, plus a new session started without picking a model. - * Sessions started from a picker carry their own account and never consult this. */ - activeAccountId: z.string().optional(), - /** The accounts whose models this agent offers in its pickers. **Absent means every bindable + /** + * The accounts whose models this agent offers in its pickers. **Absent means every bindable * account**, so an added account is offered without a trip through Settings; an explicit list is * the user narrowing it. Availability still gates it — listing an account that cannot back this - * agent offers nothing. */ + * agent offers nothing. + * + * This is an agent's only per-account state. There is no default account and no default model: + * a request that names neither resolves to the head of `enabledAccountModels`, which the client + * shows and the daemon starts on, so neither side can invent an answer the other disagrees with. + */ enabledAccountIds: z.array(z.string().min(1)).optional(), }); export type ProviderConfig = z.infer; diff --git a/packages/foundation/schema/src/wire/config.ts b/packages/foundation/schema/src/wire/config.ts index 00694dac..a5fb1c3b 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,12 +1,6 @@ import { z } from 'zod'; -import { - AccountModelSchema, - AccountSchema, - AccountSecretSchema, - AccountsSchema, -} from '../model/account'; +import { AccountModelSchema, AccountSecretSchema, AccountsSchema } from '../model/account'; import { CustomMcpServerPatchOpSchema, CustomMcpServerPublicSchema } from '../model/custom-mcp'; -import { AgentKindSchema } from '../model/primitives'; import { ProvidersConfigSchema } from '../model/provider-config'; import { WireRequestIdSchema } from './request'; @@ -31,12 +25,6 @@ export const configWireVariants = [ /** Patch ops against the stored custom MCP servers; omitted when untouched. */ customMcpServers: z.array(CustomMcpServerPatchOpSchema).optional(), }), - z.object({ - kind: z.literal('config.account.create-and-bind'), - clientReqId: WireRequestIdSchema, - agent: AgentKindSchema, - account: AccountSchema, - }), /** Enumerate the ids a service serves. The daemon resolves the list URL from the service catalog * and makes the call itself — the renderer's CSP blocks remote fetches, and the secret belongs on * that side. `inline` carries a secret the add form has not saved yet; `account` names a saved one diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 108ccf86..fad85188 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,11 +9,11 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 75 as const; +export const WIRE_PROTOCOL_VERSION = 76 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ -export const MIN_COMPATIBLE_WIRE_VERSION = 75 as const; +export const MIN_COMPATIBLE_WIRE_VERSION = 76 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index 485a07e0..7690ec64 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -113,7 +113,7 @@ Product code must branch on `historyCapabilities` — never assume an op is supp levels (Claude `max`) and live-switchable levels share validation and reflection behavior. The engine caches the emitted effort and replays it when the newly created session attaches. -- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one); which it *falls back to* when a session names none is `activeAccountId`, used by automation, schedules, and IM threads. Sessions started from a picker carry `StartOptions.accountId` and ignore the fallback; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to the agent's default instead of starting a session with no credential. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the fallback moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and the agent's configured default could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. +- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one), and that is its **only** per-account state: there is no default account and no default model. A session that names neither — automation, schedules, IM threads, mobile — resolves to the head of `enabledAccountModels` (pool order × the account's own model order), the same entry the composer displays for an untouched draft, so the two sides cannot disagree about what "unpicked" means. Sessions started from a picker carry `StartOptions.accountId`; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to that same head instead of starting a session with no credential. An enabled account the agent cannot speak to is skipped rather than fatal (it never reaches the model menu either); only an account the request *names* fails the start loudly. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the head of the agent's list moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and a change to the agent's list could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env. - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** Precedence: model-ref (`providerID/modelID`, which decides routing) → for pi, the resumed session's own last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in the agent's own catalog, from `@linkcode/providers`) → for pi, its first available provider. Before `knownProvider` existed a bare model id left the credential uninjected entirely; putting it ahead of the resumed provider instead strands a resumed session on a provider that never got the key. - **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built). diff --git a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts index d587fdec..eb19b0eb 100644 --- a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts +++ b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts @@ -37,7 +37,7 @@ describe('engine agent catalog', () => { }; providers.update({ providers: { - 'claude-code': { enabled: true, activeAccountId: account.id, model: 'provider/model' }, + 'claude-code': { enabled: true, enabledAccountIds: [account.id] }, }, accounts: [account], }); diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 63113959..58ff21b9 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -939,7 +939,7 @@ describe('session account attribution', () => { function storeBoundTo(accountId: string, model: string): InMemoryProviderConfigStore { const providers = new InMemoryProviderConfigStore(); providers.update({ - providers: { 'claude-code': { enabled: true, activeAccountId: accountId, model } }, + providers: { 'claude-code': { enabled: true, enabledAccountIds: [accountId] } }, accounts: [ { id: accountId, @@ -1019,9 +1019,7 @@ describe('a session keeps its own pick', () => { it('replays a model picked mid-run, not the one the run launched with', async () => { const providers = new InMemoryProviderConfigStore(); providers.update({ - providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_one', model: 'model-a' }, - }, + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_one'] } }, accounts: [ { id: 'acc_one', @@ -1077,9 +1075,20 @@ describe('a session keeps its own pick', () => { await tick(); await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); - // Recording that reflection would pin the thread to its first launch, and the agent's default - // could never reach it again. - providers.update({ providers: { 'claude-code': { enabled: true, model: 'configured' } } }); + // Recording that reflection would pin the thread to its first launch, and the head of the + // agent's list could never reach it again. + providers.update({ + accounts: [ + { + id: 'acc_one', + label: 'One', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-one' }, + models: [{ id: 'configured' }], + createdAt: 0, + }, + ], + }); await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('configured'); @@ -1152,9 +1161,7 @@ describe('a session keeps its own pick', () => { createdAt: 0, }; providers.update({ - providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_default', model: 'model-default' }, - }, + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_default'] } }, accounts: [ surviving, { @@ -1206,9 +1213,7 @@ describe('a session keeps its own pick', () => { it('resumes on the run’s account and model after the daemon default moved', async () => { const providers = new InMemoryProviderConfigStore(); providers.update({ - providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_first', model: 'model-first' }, - }, + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_first'] } }, accounts: [ { id: 'acc_first', @@ -1241,11 +1246,10 @@ describe('a session keeps its own pick', () => { await tick(); await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); - // Settings moves the agent's default to the other account while the thread sleeps. + // Settings narrows the agent to the other account while the thread sleeps, moving the head of + // its list — the only thing an unpinned start would resolve to. providers.update({ - providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_second', model: 'model-second' }, - }, + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_second'] } }, }); await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); @@ -1269,9 +1273,7 @@ describe('live account switching', () => { function twoAccountStore(): InMemoryProviderConfigStore { const providers = new InMemoryProviderConfigStore(); providers.update({ - providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_first', model: 'model-first' }, - }, + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_first'] } }, accounts: [ { id: 'acc_first', diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index b82264a9..4725eb6f 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -1,23 +1,15 @@ import type { Account, ProvidersConfig, StartOptions } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { accountBinding, applyProviderDefaults } from '../agent/provider-config'; +import { applyProviderDefaults } from '../agent/provider-config'; const baseOpts: StartOptions = { kind: 'codex', cwd: '/repo' }; describe('applyProviderDefaults', () => { - it('returns the input untouched when no config exists for the kind', () => { + it('returns the input untouched when nothing is configured for the kind', () => { const providers: ProvidersConfig = { 'claude-code': { enabled: true, apiKey: 'sk-x' } }; expect(applyProviderDefaults(baseOpts, providers).options).toEqual(baseOpts); }); - it('fills the persisted pick only when the client did not specify one', () => { - const providers: ProvidersConfig = { codex: { enabled: true, model: 'o4-mini' } }; - expect(applyProviderDefaults(baseOpts, providers).options.model).toBe('o4-mini'); - expect(applyProviderDefaults({ ...baseOpts, model: 'gpt-4o' }, providers).options.model).toBe( - 'gpt-4o', - ); - }); - it('injects the api key into config, preserving existing config keys', () => { const providers: ProvidersConfig = { codex: { enabled: true, apiKey: 'sk-live' } }; const merged = applyProviderDefaults({ ...baseOpts, config: { tools: ['a'] } }, providers); @@ -25,9 +17,7 @@ describe('applyProviderDefaults', () => { }); it('does not mutate the input options', () => { - const providers: ProvidersConfig = { - codex: { enabled: true, model: 'o4-mini', apiKey: 'sk' }, - }; + const providers: ProvidersConfig = { codex: { enabled: true, apiKey: 'sk' } }; const opts: StartOptions = { kind: 'codex', cwd: '/repo' }; applyProviderDefaults(opts, providers); expect(opts).toEqual({ kind: 'codex', cwd: '/repo' }); @@ -42,41 +32,62 @@ describe('applyProviderDefaults account pool', () => { createdAt: 0, }; - it('injects the credential from the account bound via activeAccountId', () => { - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; - const merged = applyProviderDefaults(baseOpts, providers, [account]); + it('injects the credential from the first account enabled for the agent', () => { + const merged = applyProviderDefaults(baseOpts, {}, [account]); expect(merged.options.config).toEqual({ apiKey: 'sk-acc' }); // Reported, not echoed into the adapter-facing config: the caller records what actually backed // the run, and nothing downstream can mistake a request for a resolution. expect(merged.accountId).toBe('acc_1'); }); - it('lets an explicit opts.accountId override activeAccountId, and consumes it', () => { - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; + it('takes the enabled list in pool order, and skips an account left out of it', () => { + const other: Account = { + ...account, + id: 'acc_2', + credential: { type: 'api-key', key: 'sk-2' }, + }; + // Pool order decides, not the order of `enabledAccountIds`. + expect( + applyProviderDefaults(baseOpts, { codex: { enabled: true, enabledAccountIds: ['acc_2'] } }, [ + account, + other, + ]).accountId, + ).toBe('acc_2'); + expect(applyProviderDefaults(baseOpts, {}, [account, other]).accountId).toBe('acc_1'); + expect( + applyProviderDefaults(baseOpts, { codex: { enabled: true, enabledAccountIds: [] } }, [ + account, + ]).accountId, + ).toBeUndefined(); + }); + + it('lets an explicit opts.accountId outrank the first enabled one, and consumes it', () => { const other: Account = { id: 'acc_2', label: 'Other', credential: { type: 'api-key', key: 'sk-other' }, createdAt: 0, }; - const merged = applyProviderDefaults({ ...baseOpts, accountId: 'acc_2' }, providers, [ - account, - other, - ]); + const merged = applyProviderDefaults({ ...baseOpts, accountId: 'acc_2' }, {}, [account, other]); expect(merged.options.config).toMatchObject({ apiKey: 'sk-other' }); expect(merged.accountId).toBe('acc_2'); expect(merged.options.accountId).toBeUndefined(); }); - it('reports no account for a requested id that no longer resolves, whatever the agent has', () => { + it('falls back to the first enabled account for a requested id that no longer resolves', () => { + // A relaunch replays a pin recorded on the run, and that account can be deleted in between. const stale: StartOptions = { ...baseOpts, accountId: 'deleted' }; - // No entry for the kind at all: the request's own id is the only account-shaped thing in play, - // and it must not survive as one. - for (const providers of [{}, { codex: { enabled: true } }] satisfies ProvidersConfig[]) { - const merged = applyProviderDefaults(stale, providers, [account]); - expect(merged.accountId).toBeUndefined(); - expect(merged.options.accountId).toBeUndefined(); - } + const merged = applyProviderDefaults(stale, {}, [account]); + expect(merged.accountId).toBe('acc_1'); + expect(merged.options.accountId).toBeUndefined(); + // With nothing enabled either, the request's own id must not survive as an account. + const empty = applyProviderDefaults( + stale, + { codex: { enabled: true, enabledAccountIds: [] } }, + [account], + ); + expect(empty.accountId).toBeUndefined(); + expect(empty.options.accountId).toBeUndefined(); }); it('injects authToken, baseUrl and protocol for an auth-token account with an endpoint', () => { @@ -87,9 +98,7 @@ describe('applyProviderDefaults account pool', () => { endpoint: { baseUrl: 'https://relay.example.com/v1', protocol: 'openai-responses' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'gw' } }; - const merged = applyProviderDefaults(baseOpts, providers, [gateway]); - expect(merged.options.config).toEqual({ + expect(applyProviderDefaults(baseOpts, {}, [gateway]).options.config).toEqual({ authToken: 'or-tok', baseUrl: 'https://relay.example.com/v1', protocol: 'openai-responses', @@ -104,13 +113,14 @@ describe('applyProviderDefaults account pool', () => { endpoint: { baseUrl: 'https://openrouter.ai/api', protocol: 'anthropic' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'gw' } }; - const merged = applyProviderDefaults(baseOpts, providers, [anthropicOnly]); + // Named explicitly, so it resolves and then fails — an unusable account is never silently + // skipped in favour of the next one. + const merged = applyProviderDefaults({ ...baseOpts, accountId: 'gw' }, {}, [anthropicOnly]); expect(merged.unavailable).toBe('protocol-unsupported'); expect(merged.options.config?.baseUrl).toBeUndefined(); }); - it('resolves a catalog service to the endpoint the bound agent speaks', () => { + it('resolves a catalog service to the endpoint the agent speaks', () => { const openai: Account = { id: 'oa', label: 'OpenAI', @@ -118,36 +128,32 @@ describe('applyProviderDefaults account pool', () => { credential: { type: 'api-key', key: 'sk-oa' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oa' } }; // Codex overrides the base URL of its own Responses provider, so it carries no knownProvider. - expect(applyProviderDefaults(baseOpts, providers, [openai]).options.config).toEqual({ + expect(applyProviderDefaults(baseOpts, {}, [openai]).options.config).toEqual({ apiKey: 'sk-oa', baseUrl: 'https://api.openai.com/v1', protocol: 'openai-responses', }); - const forOpencode = applyProviderDefaults( - { ...baseOpts, kind: 'opencode' }, - { opencode: { enabled: true, activeAccountId: 'oa' } }, - [openai], - ); + const forOpencode = applyProviderDefaults({ ...baseOpts, kind: 'opencode' }, {}, [openai]); expect(forOpencode.options.config).toMatchObject({ knownProvider: 'openai' }); }); - it('takes the model from the agent, never from the bound account', () => { - // The account holds the set the pick came from; only `providers[kind].model` names the pick. - const providers: ProvidersConfig = { - codex: { enabled: true, model: 'o4-mini', activeAccountId: 'acc_1' }, - }; + it("fills the resolved account's first model, and never overrides the request's", () => { + // Nothing stores an agent default: the head of the account's picked set is it, which is also + // what the composer shows for an untouched draft. + const picked = { ...account, models: [{ id: 'gpt-5' }, { id: 'o4-mini' }] }; + expect(applyProviderDefaults(baseOpts, {}, [picked]).options.model).toBe('gpt-5'); expect( - applyProviderDefaults(baseOpts, providers, [ - { ...account, models: [{ id: 'gpt-5' }, { id: 'o4-mini' }] }, - ]).options.model, + applyProviderDefaults({ ...baseOpts, model: 'o4-mini' }, {}, [picked]).options.model, ).toBe('o4-mini'); + // An account with nothing picked names no model, and the session start refuses rather than + // guessing one the endpoint may not serve. + expect(applyProviderDefaults(baseOpts, {}, [account]).options.model).toBeUndefined(); }); - it('falls back to the legacy apiKey when the bound account id is stale', () => { + it('falls back to the legacy apiKey when no account is enabled', () => { const providers: ProvidersConfig = { - codex: { enabled: true, apiKey: 'sk-legacy', activeAccountId: 'deleted' }, + codex: { enabled: true, apiKey: 'sk-legacy', enabledAccountIds: [] }, }; expect(applyProviderDefaults(baseOpts, providers, [account]).options.config).toEqual({ apiKey: 'sk-legacy', @@ -161,49 +167,9 @@ describe('applyProviderDefaults account pool', () => { credential: { type: 'oauth', agent: 'codex' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oauth_1' } }; // The account still resolves — it just contributes nothing for the adapter to read. - const merged = applyProviderDefaults(baseOpts, providers, [oauth]); + const merged = applyProviderDefaults(baseOpts, {}, [oauth]); expect(merged.options.config).toEqual({}); expect(merged.accountId).toBe('oauth_1'); }); }); - -describe('accountBinding', () => { - const account: Account = { - id: 'acc_1', - label: 'Relay', - credential: { type: 'api-key', key: 'sk-new' }, - createdAt: 1, - }; - - it('preserves unrelated providers and accounts while binding the selected agent', () => { - const providers: ProvidersConfig = { - codex: { enabled: true, model: 'gpt-5' }, - opencode: { enabled: false, activeAccountId: 'acc_2' }, - }; - const other: Account = { - id: 'acc_2', - label: 'Other', - credential: { type: 'api-key', key: 'sk-other' }, - createdAt: 0, - }; - - expect(accountBinding(providers, [other], 'codex', account)).toEqual({ - providers: { - codex: { enabled: true, model: 'gpt-5', activeAccountId: 'acc_1' }, - opencode: { enabled: false, activeAccountId: 'acc_2' }, - }, - accounts: [other, account], - }); - }); - - it('upserts by account id so retrying the same request is idempotent', () => { - const first = accountBinding({}, [], 'codex', account); - const updated = { ...account, label: 'Updated relay' }; - const retry = accountBinding(first.providers, first.accounts, 'codex', updated); - - expect(retry.accounts).toEqual([updated]); - expect(retry.providers.codex?.activeAccountId).toBe(account.id); - }); -}); diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index e4639024..f872998e 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -159,11 +159,10 @@ describe('simulator MCP injection at session start', () => { describe('account binding at session start', () => { function storeWith(account: Account, agent: AgentKind): InMemoryProviderConfigStore { const store = new InMemoryProviderConfigStore(); - store.createAndBindAccount(agent, account); - // A bound agent without a picked model refuses to start; these cases are about endpoints. - const providers = store.get(); + // An account with nothing picked refuses to start; these cases are about endpoints. store.update({ - providers: { ...providers, [agent]: { ...providers[agent], model: 'picked-model' } }, + providers: { [agent]: { enabled: true, enabledAccountIds: [account.id] } }, + accounts: [{ ...account, models: [{ id: 'picked-model' }] }], }); return store; } @@ -176,9 +175,9 @@ describe('account binding at session start', () => { ...overrides, }); - it('refuses a bound agent with no picked model, but lets an unbound one resolve its own', async () => { + it('refuses an agent whose account picked no model, but lets one with none resolve its own', async () => { const store = new InMemoryProviderConfigStore(); - store.createAndBindAccount('codex', account({ service: 'openai-api' })); + store.update({ accounts: [account({ service: 'openai-api' })] }); const bound = new SessionStartOptionsResolver(store, undefined); await expect( Effect.runPromise(bound.resolve({ kind: 'codex', cwd: '/repo' }, SESSION)), @@ -192,7 +191,7 @@ describe('account binding at session start', () => { expect(options.model).toBeUndefined(); }); - it('refuses the session when the bound account has no endpoint the agent speaks', async () => { + it('refuses an account named by the request that has no endpoint the agent speaks', async () => { const anthropicOnly = account({ endpoint: { baseUrl: 'https://api.anthropic.com', protocol: 'anthropic' }, }); @@ -200,8 +199,17 @@ describe('account binding at session start', () => { // Starting anyway would point codex at an endpoint that answers 404 on /responses. await expect( - Effect.runPromise(resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION)), + Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo', accountId: anthropicOnly.id }, SESSION), + ), ).rejects.toThrow('cannot back codex'); + + // Merely sitting enabled in the pool is not a request: it never reaches codex's model menu, so + // it is skipped rather than made to break every session the agent starts. + const { options } = await Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + expect(options.config?.baseUrl).toBeUndefined(); }); it('starts an account the pre-variant add flow pinned to one endpoint', async () => { diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 6ecf5bb6..768c7d49 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -1,11 +1,10 @@ import type { BindingUnavailableReason } from '@linkcode/providers'; -import { resolveBinding } from '@linkcode/providers'; +import { enabledAccounts, resolveBinding } from '@linkcode/providers'; import type { Account, Accounts, AgentKind, CustomMcpServer, - ProviderConfig, ProvidersConfig, StartOptions, } from '@linkcode/schema'; @@ -17,13 +16,12 @@ import type { */ export interface ProviderConfigStore { get(): ProvidersConfig; - /** The global account pool bound by `providers[kind].activeAccountId`. */ + /** The global account pool an agent draws on through `providers[kind].enabledAccountIds`. */ getAccounts(): Accounts; update(next: { providers?: ProvidersConfig; accounts?: Accounts }): void | Promise; /** LinkCode-owned custom MCP servers (full plaintext — masking is the data plane's job). */ getCustomMcpServers(): CustomMcpServer[]; setCustomMcpServers(next: CustomMcpServer[]): void | Promise; - createAndBindAccount(agent: AgentKind, account: Account): void | Promise; } export class InMemoryProviderConfigStore implements ProviderConfigStore { @@ -51,48 +49,26 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { setCustomMcpServers(next: CustomMcpServer[]): void { this.customMcpServers = next; } - - createAndBindAccount(agent: AgentKind, account: Account): void { - const next = accountBinding(this.providers, this.accounts, agent, account); - this.providers = next.providers; - this.accounts = next.accounts; - } -} - -export function accountBinding( - providers: ProvidersConfig, - accounts: Accounts, - agent: AgentKind, - account: Account, -): { providers: ProvidersConfig; accounts: Accounts } { - const entry = providers[agent] ?? { enabled: true }; - const exists = accounts.some((candidate) => candidate.id === account.id); - return { - providers: { ...providers, [agent]: { ...entry, activeAccountId: account.id } }, - accounts: exists - ? accounts.map((candidate) => (candidate.id === account.id ? account : candidate)) - : [...accounts, account], - }; } /** - * Resolve the session's account: explicit `opts.accountId`, else the agent's `activeAccountId`. A - * requested id that no longer resolves falls through to that default rather than stranding the - * session — a relaunch replays a pin recorded on the run, and the account it names can be deleted in - * between. Undefined when neither resolves, which leaves the caller on the legacy - * `providers[kind].apiKey`. + * Resolve the session's account: explicit `opts.accountId`, else the first account enabled for the + * agent. A requested id that no longer resolves falls through to that first one rather than + * stranding the session — a relaunch replays a pin recorded on the run, and the account it names can + * be deleted in between. Undefined when the agent has no enabled account at all, which leaves the + * caller on the legacy `providers[kind].apiKey` and then on the agent's own login. */ function resolveAccount( opts: StartOptions, - config: ProviderConfig | undefined, + providers: ProvidersConfig, + kind: AgentKind, accounts: Accounts, ): Account | undefined { - for (const id of [opts.accountId, config?.activeAccountId]) { - if (id === undefined) continue; - const account = accounts.find((candidate) => candidate.id === id); - if (account) return account; - } - return undefined; + const requested = + opts.accountId === undefined + ? undefined + : accounts.find((candidate) => candidate.id === opts.accountId); + return requested ?? enabledAccounts(accounts, providers, kind)[0]; } /** The adapter-facing bundle an account contributes to `StartOptions.config`; each adapter maps @@ -128,22 +104,24 @@ export interface AppliedProviderDefaults { } /** Apply the stored config to a session's StartOptions: resolve the account (or legacy per-agent api - * key), inject the credential/endpoint bundle into `config`, and fall back to the agent's persisted - * model pick. Returns a new object; never mutates the input. */ + * key), inject the credential/endpoint bundle into `config`, and fall back to that account's first + * picked model. Returns a new object; never mutates the input. */ export function applyProviderDefaults( opts: StartOptions, providers: ProvidersConfig, accounts: Accounts = [], ): AppliedProviderDefaults { const config = providers[opts.kind]; - const account = resolveAccount(opts, config, accounts); + const account = resolveAccount(opts, providers, opts.kind, accounts); // The request's pick never survives resolution: `config` carries what the adapter reads, and the // account that actually resolved is this function's answer. A stale id therefore cannot travel // downstream and read back as an account the session does not have. const { accountId: _requested, ...next } = { ...opts }; - // The account holds the models the user may pick from; the pick itself is per agent. - if (next.model === undefined && config?.model !== undefined) next.model = config.model; if (account) { + // Nothing is stored as this agent's default model — the account's first pick *is* it, which is + // the entry the client shows for an untouched draft. Deriving it on both sides keeps a request + // that names no model starting on what the user was looking at. + if (next.model === undefined) next.model = account.models?.[0]?.id; const resolved = accountConfigBundle(account, opts.kind); if ('unavailable' in resolved) return { options: next, unavailable: resolved.unavailable }; return { diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 8e673ca6..7f652b73 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -23,7 +23,6 @@ type AgentRequest = Extract< | 'agent.catalog' | 'config.get' | 'config.set' - | 'config.account.create-and-bind' | 'config.probe-models' | 'agent-login.start' | 'agent-login.submit-code' @@ -137,15 +136,6 @@ export class AgentRequestHandler { ), ); } - case 'config.account.create-and-bind': - return this.responder.reply( - payload.clientReqId, - updateProviderConfig('config.account.create-and-bind', () => - this.providers.createAndBindAccount(payload.agent, payload.account), - ).pipe( - Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), - ), - ); case 'config.probe-models': return this.responder.reply( payload.clientReqId, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 599f7511..7d4e4f7c 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -4,7 +4,7 @@ * feature implementations stay package-internal. */ -export { accountBinding, type ProviderConfigStore } from './agent/provider-config'; +export type { ProviderConfigStore } from './agent/provider-config'; export type { TranslatorService, TranslatorUpstream } from './agent/translator'; export type { AssetService } from './asset/service'; export type { LoopStore, ScheduleStore } from './automation'; diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index 4f11c22e..cede0f6e 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -79,7 +79,6 @@ export class WireRequestRouter { case 'agent.catalog': case 'config.get': case 'config.set': - case 'config.account.create-and-bind': case 'config.probe-models': { return this.handlers.agent.handle(p); } From 842d5c9bb521b229d74d739cd9f249f12fa66e52 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 10 Aug 2026 21:17:23 +0800 Subject: [PATCH 32/32] feat(ui,providers,engine)!: offer only the models an enabled account carries The menu drew from three sources and the account switches governed one of them, so turning a subscription off left its models in place. The agent's own catalog is no longer a source at all: the curated table moves to the provider layer, where an adopted CLI login is seeded with it, and every row in the picker now belongs to an account a switch can take away. --- .../settings/providers/model-selection.tsx | 7 +- .../providers/src/curated-models.ts | 46 ++++++++++ packages/foundation/providers/src/index.ts | 1 + .../__tests__/engine-detected-logins.test.ts | 6 +- .../host/engine/src/agent/detected-logins.ts | 15 ++-- .../ui/src/__tests__/agent-models.test.ts | 32 +++++-- .../__tests__/conversation-surface.test.tsx | 10 ++- .../__tests__/new-session-surface.test.tsx | 84 ++++++++++++++----- .../ui/src/shell/agent-efforts.ts | 2 +- .../presentation/ui/src/shell/agent-models.ts | 55 +----------- .../presentation/ui/src/shell/composer.tsx | 19 ++--- .../ui/src/shell/conversation-surface.tsx | 7 +- .../ui/src/shell/new-session-surface.tsx | 38 ++++----- 13 files changed, 191 insertions(+), 131 deletions(-) create mode 100644 packages/foundation/providers/src/curated-models.ts diff --git a/packages/client/workbench/src/settings/providers/model-selection.tsx b/packages/client/workbench/src/settings/providers/model-selection.tsx index 35d12daf..1f57a60f 100644 --- a/packages/client/workbench/src/settings/providers/model-selection.tsx +++ b/packages/client/workbench/src/settings/providers/model-selection.tsx @@ -1,6 +1,6 @@ +import { CURATED_AGENT_MODELS } from '@linkcode/providers'; import type { AccountModel, AccountSecret, AgentKind } from '@linkcode/schema'; import { getAgentCatalog, probeAccountModels } from '@linkcode/sdk'; -import { AGENT_MODEL_OPTIONS } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; import { Checkbox } from 'coss-ui/components/checkbox'; import { Input } from 'coss-ui/components/input'; @@ -49,7 +49,10 @@ export function useModelSources(): ModelSources { probe.trigger({ service, credential: { type: 'account', accountId } }), async oauth(agent) { if (agent === 'claude-code') { - return (AGENT_MODEL_OPTIONS[agent] ?? []).map(({ id, label }) => ({ id, label })); + return (CURATED_AGENT_MODELS[agent] ?? []).map(({ id, label }) => ({ + id, + label: label ?? id, + })); } const { models } = await fetchCatalog.trigger({ agentKind: agent }); return models.map(({ id, label }) => ({ id, label })); diff --git a/packages/foundation/providers/src/curated-models.ts b/packages/foundation/providers/src/curated-models.ts new file mode 100644 index 00000000..91b810a9 --- /dev/null +++ b/packages/foundation/providers/src/curated-models.ts @@ -0,0 +1,46 @@ +import type { AccountModel, AgentKind } from '@linkcode/schema'; + +/** + * Curated model lists for the agents whose subscription serves no enumeration API, used to seed a + * delegated account's picked set — a subscription reaches the pickers the same way every other + * account does, through `Account.models`, so it needs those ids from somewhere. + * + * Only adapters with a *verified* live model switch get an entry, and every id was confirmed by + * reading the served model back off a live stream (source reading is not enough: claude-code's + * first design silently ignored the override). Legacy models are included deliberately — the choice + * belongs to the user. Anthropic ids and lifecycle come from + * https://platform.claude.com/docs/en/about-claude/models/overview. + * claude-opus-4-1 is deliberately excluded: setModel() accepts it but claude-opus-5 is silently + * served instead. Offering claude-fable-5 to everyone is safe: accounts without access get a hard + * CLI error and the picker keeps the previous model (confirm-then-reflect). + * `[1m]` ids (`claude-opus-5[1m]`) are a claude-code-side context tier, not Anthropic model ids; + * none are listed, and `resolveModel()` cannot fold one back onto its base entry. + * Keeping this table static is a deliberate CODE-104 decision (the dynamic reference + * implementation lives in closed PR #52); refresh it by hand under the discipline above. + * codex ids/labels are the app-server's `model/list` verbatim, kept as the seed a headless adoption + * can use — Settings still refreshes an account from the live catalog, which also carries the + * per-model effort levels this table cannot (`AccountModel` is ids and labels). + * opencode and pi have no entry — see their adapters' comments for why. + */ +export const CURATED_AGENT_MODELS: Partial> = { + 'claude-code': [ + { id: 'claude-fable-5', label: 'Fable 5' }, + { id: 'claude-opus-5', label: 'Opus 5' }, + { id: 'claude-opus-4-8', label: 'Opus 4.8' }, + { id: 'claude-opus-4-7', label: 'Opus 4.7 (Legacy)' }, + { id: 'claude-opus-4-6', label: 'Opus 4.6 (Legacy)' }, + { id: 'claude-sonnet-5', label: 'Sonnet 5' }, + { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (Legacy)' }, + { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, + ], + codex: [ + { id: 'gpt-5.6-sol', label: 'GPT-5.6-Sol' }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6-Terra' }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6-Luna' }, + { id: 'gpt-5.5', label: 'GPT-5.5' }, + { id: 'gpt-5.4', label: 'GPT-5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini' }, + ], + // Grok Build headless: model is a spawn-time `-m` flag (verified 0.2.102: grok-4.5). + 'grok-build': [{ id: 'grok-4.5', label: 'Grok 4.5' }], +}; diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index f8c38251..bdf02fe9 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -11,6 +11,7 @@ export { SERVICE_CATALOG, serviceById, } from './catalog'; +export { CURATED_AGENT_MODELS } from './curated-models'; export type { DetectedLogin } from './detected-logins'; export { detectedLogins } from './detected-logins'; export type { EnabledAccountModel } from './enabled-models'; diff --git a/packages/host/engine/src/__tests__/engine-detected-logins.test.ts b/packages/host/engine/src/__tests__/engine-detected-logins.test.ts index 65328787..cf579298 100644 --- a/packages/host/engine/src/__tests__/engine-detected-logins.test.ts +++ b/packages/host/engine/src/__tests__/engine-detected-logins.test.ts @@ -1,3 +1,4 @@ +import { CURATED_AGENT_MODELS } from '@linkcode/providers'; import type { AgentRuntimes } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { Effect } from 'effect'; @@ -36,14 +37,17 @@ describe('detected-login adoption', () => { await engine.start(); await vi.waitFor(() => expect(providerStore.getAccounts()).toHaveLength(1)); - // The pool grew and nothing else did: no binding, no model, so no session changes what it runs on. + // Seeded with the curated list, because the pickers offer `Account.models` and nothing else — + // an account with none is a switch that reveals nothing. expect(providerStore.getAccounts()[0]).toEqual({ id: expect.stringMatching(ACCOUNT_ID_RE), label: 'Claude', service: 'claude-sub', credential: { type: 'oauth', agent: 'claude-code' }, + models: CURATED_AGENT_MODELS['claude-code'], createdAt: expect.any(Number), }); + // Nothing else grew: no enabled list narrowed, so no session changes what it runs on. expect(providerStore.get()).toEqual({}); await engine.stop(); }); diff --git a/packages/host/engine/src/agent/detected-logins.ts b/packages/host/engine/src/agent/detected-logins.ts index aaa30225..0c39e850 100644 --- a/packages/host/engine/src/agent/detected-logins.ts +++ b/packages/host/engine/src/agent/detected-logins.ts @@ -1,4 +1,4 @@ -import { detectedLogins } from '@linkcode/providers'; +import { CURATED_AGENT_MODELS, detectedLogins } from '@linkcode/providers'; import type { Account, AgentRuntimes } from '@linkcode/schema'; import { Clock, Effect } from 'effect'; import { OperationError } from '../failure'; @@ -25,15 +25,20 @@ export function adoptDetectedLogins( // the two and lose either side's accounts. async try() { const accounts = providers.getAccounts(); - const adopted = detectedLogins(accounts, runtimes).map( - ({ service }): Account => ({ + const adopted = detectedLogins(accounts, runtimes).map(({ service }): Account => { + // Seeded with the curated list, because an account with no models is an account whose + // switch reveals nothing — the pickers offer `Account.models` and nothing else. Settings + // can refresh it from the live catalog where the service serves one. + const models = CURATED_AGENT_MODELS[service.agent]; + return { id: `acc_${crypto.randomUUID()}`, label: service.label, service: service.id, credential: { type: 'oauth', agent: service.agent }, + ...(models !== undefined && { models }), createdAt, - }), - ); + }; + }); if (adopted.length === 0) return []; await providers.update({ accounts: [...accounts, ...adopted] }); return adopted; diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index 68d1d1df..c37c6c94 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -1,14 +1,30 @@ import { describe, expect, it } from 'vitest'; import { effortOptionsForModel } from '../shell/agent-efforts'; -import { - AGENT_MODEL_OPTIONS, - groupModelsByProvider, - resolveModel, - switchesAccount, -} from '../shell/agent-models'; +import type { ModelOption } from '../shell/agent-models'; +import { groupModelsByProvider, resolveModel, switchesAccount } from '../shell/agent-models'; -const claude = AGENT_MODEL_OPTIONS['claude-code']; -const codex = AGENT_MODEL_OPTIONS.codex; +// Ids and aliases straight from `CURATED_AGENT_MODELS`; the prefix rules under test are about the +// shape of the ids a provider serves, not about where the list came from. +const claude: ModelOption[] = [ + { id: 'claude-opus-5', label: 'Opus 5' }, + { id: 'claude-opus-4-8', label: 'Opus 4.8' }, + { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, +]; +// Codex advertises per-model effort levels on its live catalog; these mirror `model/list`. +const codex: ModelOption[] = [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + { id: 'gpt-5.4', label: 'GPT-5.4' }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6-Luna', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini' }, +]; describe('resolveModel', () => { it('resolves an exact catalog id', () => { diff --git a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx index 638ac6be..873c41b0 100644 --- a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx @@ -62,7 +62,7 @@ const PERMISSION_ITEM: PermissionConversationItem = { }; const RE_MODEL_DEFAULT = /modelDefault/; -const RE_OPUS_4_8 = /Opus 4.8/; +const RE_OPUS_4_8_ID = /claude-opus-4-8/; const RE_MAX_EFFORT = /Max/; function surface( @@ -126,11 +126,15 @@ describe('ConversationSurface prompt card', () => { }); it('keeps the model unresolved until the adapter reports its concrete value', () => { + // No account is enabled for this thread's agent, so there is nothing to pick from and no + // placeholder to promise one. const { rerender } = render(surface()); - expect(screen.getByRole('button', { name: RE_MODEL_DEFAULT })).toBeTruthy(); + expect(screen.queryByRole('button', { name: RE_MODEL_DEFAULT })).toBeNull(); + // What the session actually runs on is still reported, unlabelled: no account listed this id, + // and withholding it would leave the thread's own model invisible. rerender(surface(undefined, { ...EMPTY_CONVERSATION, currentModel: 'claude-opus-4-8' })); - expect(screen.getByRole('button', { name: RE_OPUS_4_8 })).toBeTruthy(); + expect(screen.getByRole('button', { name: RE_OPUS_4_8_ID })).toBeTruthy(); }); it('shows any reflected normalized effort even when the adapter does not offer it', () => { diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 1b4fa60d..1fba8111 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -61,7 +61,6 @@ const RE_MODEL_SONNET_5_MENU = /model.*Sonnet 5/; const RE_OPUS_5 = /Opus 5/; const RE_MODEL_MENU = /^model/; const RE_MODEL_GPT_56_SOL_MENU = /model.*GPT-5\.6-Sol/; -const RE_MODEL_DEFAULT_MENU = /model.*modelDefault/; const RE_MODEL_PI_SONNET_MENU = /model.*Pi Sonnet/; const RE_EFFORT_DEFAULT_MENU = /effort.*effortDefault/; const RE_APPROVAL_DEFAULT = /Default/; @@ -83,6 +82,25 @@ const PI_CONFIGURED_CATALOG: AgentStartCatalog = { defaultEffort: 'high', }; +/** Codex's per-model effort levels, as `model/list` advertises them: Sol takes `ultra`, Luna does + * not. The account carries the ids; only the catalog knows what each can run at. */ +const CODEX_EFFORT_CATALOG: AgentStartCatalog = { + models: [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6-Luna', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + ], + policies: [], + defaultModel: 'gpt-5.6-sol', +}; + /** An account offering `pi/wide`: it heads the list, so it is displayed while the catalog still * defaults to `pi/sonnet` — the shape that separates "what is shown" from "what the agent would * resolve for itself". */ @@ -716,8 +734,14 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { accountModels={{ 'claude-code': [ { id: 'configured/claude-model', label: 'configured/claude-model', accountId: 'acc_x' }, + { id: 'claude-opus-4-8', label: 'Opus 4.8', accountId: 'acc_x' }, ], }} chatWorkspace={CHAT_WORKSPACE} @@ -867,7 +892,7 @@ describe('NewSessionSurface', () => { ); }); - it("offers the accounts' models ahead of the agent's own catalog, and heads the list with one", async () => { + it("offers the accounts' models and nothing else, heading the list with one", async () => { const user = userEvent.setup(); render( { />, ); - // The account model heads the list, so it is what an untouched draft shows — ahead of the - // curated Anthropic table, which stays on offer for a run on the agent's own login. + // The account's model heads the list, so it is what an untouched draft shows. The curated + // Anthropic table is not on offer beside it: a model no enabled account carries would be a row + // the account switches cannot take away. await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); - expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); - expect(screen.getByRole('menuitemradio', { name: 'Opus 5' })).toBeTruthy(); + expect(await screen.findByRole('menuitemradio', { name: RE_DEEPSEEK_PRO })).toBeTruthy(); + expect(screen.queryByRole('menuitemradio', { name: RE_OPUS_5 })).toBeNull(); }); it('sends with no model when an account offers none, leaving the agent to resolve its own', async () => { @@ -916,13 +942,13 @@ describe('NewSessionSurface', () => { expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); - it("keeps an agent’s own catalog on offer alongside the accounts'", async () => { + it("leaves an agent's own catalog off the menu, and still sends without one", async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( { await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); - expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); - expect(screen.getByRole('menuitemradio', { name: RE_PI_SONNET })).toBeTruthy(); + expect(await screen.findByRole('menuitemradio', { name: RE_DEEPSEEK_PRO })).toBeTruthy(); + expect(screen.queryByRole('menuitemradio', { name: RE_PI_SONNET })).toBeNull(); await user.keyboard('{Escape}'); typeInComposer('hello'); @@ -980,7 +1006,12 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { await user.click(screen.getByRole('button', { name: RE_SONNET_5 })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_SONNET_5_MENU })); - fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Opus 5' })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: RE_OPUS_5 })); typeInComposer('hello'); await pressInComposer('Enter'); @@ -1089,7 +1120,9 @@ describe('NewSessionSurface', () => { />, ); - expect(screen.getByRole('button', { name: RE_PI_SONNET })).toBeTruthy(); + // No account is enabled for pi, so it offers no model to show — but the effort axis still + // reports what the agent's own default model will run at. + expect(screen.queryByRole('button', { name: RE_PI_SONNET })).toBeNull(); expect(screen.getByRole('button', { name: RE_HIGH_EFFORT })).toBeTruthy(); typeInComposer('untouched pickers'); @@ -1116,7 +1149,7 @@ describe('NewSessionSurface', () => { />, ); - expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); + // `pi/basic` takes no effort at all, so the catalog's own default cannot apply to it. expect(screen.queryByRole('button', { name: RE_HIGH_EFFORT })).toBeNull(); }); @@ -1193,6 +1226,13 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { />, ); - await user.click(screen.getByRole('button', { name: RE_MODEL_DEFAULT })); - await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_DEFAULT_MENU })); - fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Pi Sonnet' })); + // `pi/sonnet` heads the account's list, so it is already displayed; picking it makes it an + // explicit choice that travels with the submission. + await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_PI_SONNET_MENU })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: RE_PI_SONNET })); await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); await user.click(await screen.findByRole('menuitem', { name: RE_EFFORT_DEFAULT_MENU })); fireEvent.click(await screen.findByRole('menuitemradio', { name: 'High' })); @@ -1239,7 +1281,7 @@ describe('NewSessionSurface', () => { onSubmit.mockClear(); await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_PI_SONNET_MENU })); - fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Pi Basic' })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: RE_PI_BASIC })); typeInComposer('no stale effort'); await pressInComposer('Enter'); await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); diff --git a/packages/presentation/ui/src/shell/agent-efforts.ts b/packages/presentation/ui/src/shell/agent-efforts.ts index f98881ca..7c32f832 100644 --- a/packages/presentation/ui/src/shell/agent-efforts.ts +++ b/packages/presentation/ui/src/shell/agent-efforts.ts @@ -20,7 +20,7 @@ export const EFFORT_OPTIONS_BY_ID: Readonly> = }; /** - * Reasoning-effort choices, keyed by adapter — same discipline as `AGENT_MODEL_OPTIONS`: only + * Reasoning-effort choices, keyed by adapter — same discipline as `CURATED_AGENT_MODELS`: only * adapters with a verified live effort switch get an entry. * claude-code: `max` can't ride the live flag-settings channel, so the adapter restarts the * process and resumes in place (entering and leaving — the startup flag outranks flag-settings); diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/shell/agent-models.ts index dd0b7af1..4bf30a20 100644 --- a/packages/presentation/ui/src/shell/agent-models.ts +++ b/packages/presentation/ui/src/shell/agent-models.ts @@ -1,4 +1,4 @@ -import type { AgentKind, EffortLevel } from '@linkcode/schema'; +import type { EffortLevel } from '@linkcode/schema'; export interface ModelOption { id: string; @@ -86,56 +86,3 @@ export function resolveModel( candidates?.find((option) => id.startsWith(`${option.id}-`)) ); } - -const CODEX_BASE_EFFORTS = ['low', 'medium', 'high', 'xhigh'] satisfies EffortLevel[]; - -/** - * Curated model choices, keyed by adapter — only adapters with a *verified* live model switch get - * an entry, and every id was confirmed by reading the served model back off a live stream (source - * reading is not enough: claude-code's first design silently ignored the override). Legacy models - * are included deliberately — the choice belongs to the user. Anthropic ids and lifecycle come from - * https://platform.claude.com/docs/en/about-claude/models/overview. - * claude-opus-4-1 is deliberately excluded: setModel() accepts it but claude-opus-5 is silently - * served instead. Offering claude-fable-5 to everyone is safe: accounts without access get a hard - * CLI error and the picker keeps the previous model (confirm-then-reflect). - * `[1m]` ids (`claude-opus-5[1m]`) are a claude-code-side context tier, not Anthropic model ids; - * none are listed, and resolveModel() cannot fold one back onto its base entry. - * Keeping this table static is a deliberate CODE-104 decision (the dynamic reference - * implementation lives in closed PR #52); refresh it by hand under the discipline above. - * codex ids/labels are the app-server's `model/list` verbatim; switches apply from the next turn, - * not mid-turn. opencode and pi have no entry — see their adapters' comments for why. - */ -export const AGENT_MODEL_OPTIONS: Partial> = { - 'claude-code': [ - { id: 'claude-fable-5', label: 'Fable 5' }, - { id: 'claude-opus-5', label: 'Opus 5' }, - { id: 'claude-opus-4-8', label: 'Opus 4.8' }, - { id: 'claude-opus-4-7', label: 'Opus 4.7 (Legacy)' }, - { id: 'claude-opus-4-6', label: 'Opus 4.6 (Legacy)' }, - { id: 'claude-sonnet-5', label: 'Sonnet 5' }, - { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (Legacy)' }, - { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, - ], - codex: [ - { - id: 'gpt-5.6-sol', - label: 'GPT-5.6-Sol', - effortLevels: [...CODEX_BASE_EFFORTS, 'max', 'ultra'], - }, - { - id: 'gpt-5.6-terra', - label: 'GPT-5.6-Terra', - effortLevels: [...CODEX_BASE_EFFORTS, 'max', 'ultra'], - }, - { - id: 'gpt-5.6-luna', - label: 'GPT-5.6-Luna', - effortLevels: [...CODEX_BASE_EFFORTS, 'max'], - }, - { id: 'gpt-5.5', label: 'GPT-5.5', effortLevels: [...CODEX_BASE_EFFORTS] }, - { id: 'gpt-5.4', label: 'GPT-5.4', effortLevels: [...CODEX_BASE_EFFORTS] }, - { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini', effortLevels: [...CODEX_BASE_EFFORTS] }, - ], - // Grok Build headless: model is a spawn-time `-m` flag (verified 0.2.102: grok-4.5). - 'grok-build': [{ id: 'grok-4.5', label: 'Grok 4.5' }], -}; diff --git a/packages/presentation/ui/src/shell/composer.tsx b/packages/presentation/ui/src/shell/composer.tsx index 5d3a9d85..6c53e6f6 100644 --- a/packages/presentation/ui/src/shell/composer.tsx +++ b/packages/presentation/ui/src/shell/composer.tsx @@ -33,7 +33,7 @@ import { import { cn } from '../lib/cn'; import { effortOptionsForModel } from './agent-efforts'; import type { ModelOption } from './agent-models'; -import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; +import { resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import type { ComposerAttachment } from './composer-attachments'; import { @@ -158,9 +158,9 @@ export interface ComposerProps { /** Executable directive contract. Loading slash catalogs accept typed commands for host-side * validation; ready catalogs are authoritative, including an empty catalog. */ directiveControls: ComposerDirectiveControls; - /** The session's adapter-advertised model catalog, reflected from `available-models-update` - * (install-dependent agents like opencode). Takes precedence over the static per-kind table; - * empty or absent falls back to that table. */ + /** Every model this composer may offer, already assembled by the caller from the accounts enabled + * for the agent. Absent or empty means no picker: nothing here falls back to a table, or an + * account switch could not take a model off the menu. */ agentModels?: AgentModelOption[] | null; /** A promise defers draft clearing until the caller accepts the submission. */ onSend: (content: ContentBlock[]) => ComposerSubmissionResult; @@ -797,14 +797,9 @@ export function Composer({ } const placeholderAgent = agentLabel ?? 'agent'; - // The adapter-advertised catalog wins over the static table: an install-dependent agent - // (opencode) knows its own reachable models; the table only covers curated vendor lists. - const modelOptions = - agentModels && agentModels.length > 0 - ? agentModels - : agentKind - ? AGENT_MODEL_OPTIONS[agentKind] - : undefined; + // What the caller offers and nothing else: every model reaches this menu through an account + // enabled for the agent, so a fallback table here would put back a row no switch can remove. + const modelOptions = agentModels ?? undefined; const effortOptions = effortOptionsForModel( agentKind, resolveModel(modelOptions, currentModel ?? null), diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index e0a177c8..7958f3ca 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -191,9 +191,10 @@ export function ConversationSurface({ approvalPolicy={conversation.approvalPolicy} currentModel={conversation.currentModel} currentEffort={conversation.currentEffort} - // Same list as a draft: the accounts' picked models lead, and whatever the agent resolves - // on its own login follows. A cross-account pick here relaunches the thread. - agentModels={[...(accountModels ?? []), ...(conversation.availableModels ?? [])]} + // Same list as a draft: only models from accounts enabled for this agent. The adapter's + // own catalog is not offered, so the account switches govern this menu too. A + // cross-account pick here relaunches the thread. + agentModels={accountModels ?? null} currentAccountId={accountId} // Live thread: leaving its account means relaunching the agent, which the menu says out // loud rather than letting a process restart happen invisibly. diff --git a/packages/presentation/ui/src/shell/new-session-surface.tsx b/packages/presentation/ui/src/shell/new-session-surface.tsx index 535a4d04..0d0387e9 100644 --- a/packages/presentation/ui/src/shell/new-session-surface.tsx +++ b/packages/presentation/ui/src/shell/new-session-surface.tsx @@ -36,7 +36,7 @@ import { AGENT_LABELS } from '../chat/agent-icon'; import { cn } from '../lib/cn'; import { repositoryLabel } from '../repository-label'; import type { ModelOption } from './agent-models'; -import { AGENT_MODEL_OPTIONS, resolveModel } from './agent-models'; +import { resolveModel } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import { AgentOnboardingCard } from './agent-onboarding-card'; import type { ComposerDirectiveControls, MentionItem } from './composer'; @@ -187,37 +187,33 @@ export function NewSessionSurface({ const selectedModel = localModel === undefined ? null : localModel; const localEffort = selectedEfforts[harness]; const effort = localEffort === undefined ? (preferredEfforts?.[harness] ?? null) : localEffort; - const dynamicModels = catalog && catalog.models.length > 0 ? catalog.models : null; - // The accounts' picked models first, then whatever the agent offers on its own login. There is no - // configured default: an untouched draft shows the head of the account part, which is the same - // entry the daemon derives, so the two agree on what "unpicked" means. With no account the agent - // resolves for itself, and only its own advertised default is an honest thing to show — the - // curated table's first row would name a model the session may well not start on. - const ownCatalog = dynamicModels ?? AGENT_MODEL_OPTIONS[harness] ?? []; - const accountSet = accountModels?.[harness] ?? []; - const pickable: ModelOption[] = [...accountSet, ...ownCatalog]; + // Every offered model comes from an account enabled for this agent, and its head is what an + // untouched draft runs on — the same entry the daemon derives. The agent's own catalog is + // deliberately absent: a model nobody enabled an account for is not on offer, so the account + // switches govern this menu completely rather than sitting beside a list they cannot reach. + const pickable: ModelOption[] = accountModels?.[harness] ?? []; const localAccount = selectedAccounts[harness]; - // `null` is "the accounts have not loaded", so there is no head yet: showing the agent's own - // default would flip to an account model the moment they arrive. - const head = - accountModels === null - ? undefined - : (accountSet[0] ?? resolveModel(ownCatalog, catalog?.defaultModel ?? null)); + // `null` is "the accounts have not loaded", so there is no head yet. const modelOption = selectedModel === null - ? head + ? accountModels === null + ? undefined + : pickable[0] : resolveModel(pickable, selectedModel, localAccount ?? undefined); - const displayedModel = selectedModel ?? modelOption?.id ?? catalog?.defaultModel ?? null; - const effortLevels = modelOption?.effortLevels; + const displayedModel = selectedModel ?? modelOption?.id ?? null; + // Effort follows the model the session will actually run on, which is the agent's own default + // whenever nothing here is offered — the axis stays truthful even with no model to show. + const effortModel = displayedModel ?? catalog?.defaultModel ?? null; + const effortLevels = resolveModel(catalog?.models, effortModel)?.effortLevels; const constrainedEffort = effortLevels === undefined || effortLevels.includes(effort ?? 'low') ? effort : null; // A catalog effort paired with a default model belongs to that model: once something else picks // the model, that model's own advertised default is the honest value. A catalog that names no // default model (claude-code, whose effort setting is model-independent) keeps applying. const catalogEffort = - catalog?.defaultModel === undefined || catalog.defaultModel === displayedModel + catalog?.defaultModel === undefined || catalog.defaultModel === effortModel ? catalog?.defaultEffort - : modelOption?.defaultEffort; + : resolveModel(catalog.models, effortModel)?.defaultEffort; const displayedEffort = constrainedEffort ?? (catalogEffort !== undefined &&