From 3a06864010e04118fa0f6c93e31af0df849239c4 Mon Sep 17 00:00:00 2001 From: Peron Date: Thu, 6 Aug 2026 17:20:08 +0800 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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',