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/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 - {t('panelTitle', { provider: AGENT_LABELS[kind] })} + {t('panelTitle', { harness: AGENT_LABELS[kind] })} {surface.count > 0 && ( diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index f7722673..545d0594 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -80,7 +80,7 @@ export function DesktopShell({ attachmentSupport, agentCatalogs, newSessionDefaultModels, - newSessionPreferredModels, + accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -429,7 +429,7 @@ export function DesktopShell({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} - preferredModels={newSessionPreferredModels} + accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} @@ -453,6 +453,8 @@ export function DesktopShell({ composer={conversationComposer} agentKind={active?.kind} agentLabel={agentLabel} + accountModels={active ? accountModels?.[active.kind] : undefined} + accountId={active?.accountId} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index 12adad85..10bfb975 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -15,6 +15,7 @@ import { chromium } from 'playwright-core'; const webviewDir = fileURLToPath(new URL('..', import.meta.url)); const daemonDir = fileURLToPath(new URL('../../daemon', import.meta.url)); const viteCli = fileURLToPath(new URL('../../bin/vite.js', import.meta.resolve('vite'))); +const newSessionDefaultsKey = 'linkcode.workbench.new-session-defaults:v7'; const mockThreadTitle = 'Wire the workbench to the daemon'; const mockChatThreadTitle = 'Prototype without git'; const longThreadTitle = 'Long thread · navigation testbed'; @@ -69,12 +70,11 @@ async function sendPrompt(page: Page, prompt: string, appErrors: string[]): Prom } async function verifyNewChatIsolation(page: Page, appErrors: string[]): Promise { - await page.evaluate(() => { - localStorage.setItem( - 'linkcode.workbench.new-session-defaults:v5', - JSON.stringify({ state: { lastProvider: 'pi' }, version: 0 }), - ); - }); + // Must track NEW_SESSION_DEFAULTS_STORAGE_KEY and its schema: a stale blob is discarded silently, + // the new chat falls back to claude-code, and its `missing` mock runtime blocks Send forever. + await page.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ state: { lastHarness: 'pi' }, version: 0 })); + }, newSessionDefaultsKey); await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('[data-thread-title]', { hasText: mockChatThreadTitle }).waitFor(); await page.locator('[data-thread-title]', { hasText: mockChatThreadTitle }).click(); @@ -101,7 +101,14 @@ async function verifyNewChatIsolation(page: Page, appErrors: string[]): Promise< }); }); - await page.getByRole('button', { name: 'Send' }).click(); + const send = page.getByRole('button', { name: 'Send' }); + if (await send.isDisabled()) { + throw new Error( + `New chat cannot send: the ${newSessionDefaultsKey} seed did not resolve a sendable harness. ` + + 'Check the storage key version and the persisted field names against new-session-defaults-store.ts.', + ); + } + await send.click(); await page.getByText(`You said: ${prompt}`, { exact: false }).waitFor({ timeout: 15000 }); const titles = await page.evaluate(() => { const finish = Reflect.get(window, '__newChatIsolationProbe') as diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index c075411e..61694d36 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -1,6 +1,5 @@ import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -793,8 +792,8 @@ export class LinkCodeClient { return this.control.setSubscriptionMode(mode); } - setModel(sessionId: SessionId, model: string): Promise { - return this.control.setModel(sessionId, model); + setModel(sessionId: SessionId, model: string, accountId?: string): Promise { + return this.control.setModel(sessionId, model, accountId); } setEffort(sessionId: SessionId, effort: EffortLevel): Promise { @@ -841,9 +840,12 @@ export class LinkCodeClient { return this.control.getAccounts(); } - /** Model list an endpoint serves, read daemon-side with a not-yet-saved secret. */ - probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { - 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..15f795e6 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, @@ -267,9 +266,15 @@ export class ControlChannel { })); } - /** Switch the session's model, going forward. Rejects if the adapter can't rebind a live session. */ - setModel(sessionId: SessionId, model: string): Promise { - return this.send(sessionId, { type: 'set-model', model }); + /** Switch the session's model, going forward. Rejects if the adapter can't rebind a live session. + * `accountId` names the account the model came from: picking one the session isn't running on + * restarts it on that account and resumes the transcript. */ + setModel(sessionId: SessionId, model: string, accountId?: string): Promise { + return this.send(sessionId, { + type: 'set-model', + model, + ...(accountId !== undefined && { accountId }), + }); } /** Switch the session's reasoning-effort level, going forward. Same acceptance rule as setModel. */ @@ -579,14 +584,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..e2798f0a 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, @@ -243,8 +242,8 @@ export class LinkCodeSdkClient { return toResult(this.raw.cancel(sessionId)); } - setModel(sessionId: SessionId, model: string): RequestResult<{ ok: true }> { - return toResult(this.raw.setModel(sessionId, model)); + setModel(sessionId: SessionId, model: string, accountId?: string): RequestResult<{ ok: true }> { + return toResult(this.raw.setModel(sessionId, model, accountId)); } setEffort(sessionId: SessionId, effort: EffortLevel): RequestResult<{ ok: true }> { @@ -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..5b8e0d45 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, @@ -222,9 +221,9 @@ export function cancelTurn( } export function setModel( - options: Options<{ sessionId: SessionId; model: string }>, + options: Options<{ sessionId: SessionId; model: string; accountId?: string }>, ): RequestResult<{ ok: true }> { - return resolveClient(options).setModel(options.sessionId, options.model); + return resolveClient(options).setModel(options.sessionId, options.model, options.accountId); } export function setEffort( @@ -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..eea19086 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,9 +2,15 @@ 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 { configuredDefaultModels, useConfiguredDefaultModels } from '../default-models'; +import { + accountModelOptions, + configuredDefaultModels, + useAccountModelOptions, + useConfiguredDefaultModels, +} from '../default-models'; const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); @@ -27,46 +33,124 @@ 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).toEqual({}); + }); +}); + +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('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: 'claude-opus-5', + label: 'Opus 5', + description: 'Anthropic', + accountId: 'acc_anthropic', + }, + { + 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. + label: 'deepseek-v4-flash', + description: 'DeepSeek', + accountId: 'acc_deepseek', + }, + ]); + }); + + 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([]); + }); + + it('offers only the accounts enabled for that agent, and every bindable one when unset', () => { + const pool = [anthropicAccount, deepseekAccount]; + // Both can back claude-code, and an absent list means the user has narrowed nothing. + expect(accountModelOptions(pool, {})['claude-code']).toHaveLength( + accountModelOptions(pool)['claude-code']?.length ?? 0, + ); + + const narrowed = accountModelOptions(pool, { + 'claude-code': { enabled: true, enabledAccountIds: ['acc_deepseek'] }, + })['claude-code']; + expect(new Set(narrowed?.map((option) => option.accountId))).toEqual(new Set(['acc_deepseek'])); + + // Disabling every account leaves it present-and-empty, which blocks sending rather than + // silently handing the choice back to the agent. + expect( + accountModelOptions(pool, { 'claude-code': { enabled: true, enabledAccountIds: [] } })[ + 'claude-code' + ], + ).toEqual([]); + }); + + 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('stays unresolved until both the account pool and the enabled lists have loaded', () => { + const { result, rerender } = renderHook(() => useAccountModelOptions()); + expect(result.current).toBeNull(); + // Accounts alone are not enough: the enabled list narrows them, so offering the unnarrowed set + // would briefly show models the user disabled. accountsData = []; rerender(); + expect(result.current).toBeNull(); + + providersData = {}; + rerender(); expect(result.current).toEqual({}); }); }); diff --git a/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx new file mode 100644 index 00000000..6368bc97 --- /dev/null +++ b/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import type { AccountModel } from '@linkcode/schema'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { nullthrow } from 'foxts/guard'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ModelSelection } from '../model-selection'; + +function translateKey(key: string): string { + return key; +} + +vi.mock('use-intl', () => ({ + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +const NONE: AccountModel[] = []; +const RE_REFRESH = /models\.refresh/; +const RE_ADD = /models\.add/; +const RE_BAD_KEY = /invalid api key/; + +/** Renders with the selection held above, the way both account forms do through `Controller`. */ +function Harness({ + initial = NONE, + onFetch, +}: { + initial?: AccountModel[]; + onFetch?: () => Promise; +}): React.ReactNode { + const [selected, setSelected] = useState(initial); + return ; +} + +function rowFor(id: string): HTMLInputElement { + const row = nullthrow(screen.getByText(id).closest('label'), `no row for ${id}`); + return nullthrow(row.querySelector('input'), `no checkbox for ${id}`); +} + +describe('ModelSelection', () => { + it('fetches a list, and only ticked ids become the set', async () => { + const onFetch = vi + .fn() + .mockResolvedValue([ + { id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, + { id: 'deepseek-v4-flash' }, + ]); + render(); + + fireEvent.click(screen.getByRole('button', { name: RE_REFRESH })); + await waitFor(() => expect(screen.getByText('deepseek-v4-pro')).toBeTruthy()); + + expect(rowFor('deepseek-v4-pro').checked).toBe(false); + fireEvent.click(rowFor('deepseek-v4-pro')); + await waitFor(() => expect(rowFor('deepseek-v4-pro').checked).toBe(true)); + expect(rowFor('deepseek-v4-flash').checked).toBe(false); + }); + + it('keeps a picked id the list no longer returns, rather than silently unpicking it', async () => { + // A freeform entry, or one the vendor has retired: dropping it would change the account's set + // behind the user's back on the next refresh. + const onFetch = vi.fn().mockResolvedValue([{ id: 'gpt-5' }]); + render(); + + fireEvent.click(screen.getByRole('button', { name: RE_REFRESH })); + await waitFor(() => expect(screen.getByText('gpt-5')).toBeTruthy()); + + expect(rowFor('retired-model').checked).toBe(true); + }); + + it('adds a hand-typed id and refuses a duplicate', () => { + render(); + + const input = screen.getByPlaceholderText('models.addPlaceholder'); + fireEvent.change(input, { target: { value: 'typed-model' } }); + fireEvent.click(screen.getByRole('button', { name: RE_ADD })); + expect(rowFor('typed-model').checked).toBe(true); + expect((input as HTMLInputElement).value).toBe(''); + + fireEvent.change(input, { target: { value: 'already' } }); + fireEvent.click(screen.getByRole('button', { name: RE_ADD })); + expect(screen.getAllByText('already')).toHaveLength(1); + }); + + it("surfaces the fetch failure's own reason instead of swallowing it", async () => { + const onFetch = vi.fn().mockRejectedValue(new Error('401 Unauthorized — invalid api key')); + render(); + + fireEvent.click(screen.getByRole('button', { name: RE_REFRESH })); + await waitFor(() => expect(screen.getByText(RE_BAD_KEY)).toBeTruthy()); + }); + + it('offers no fetch when nothing can list the endpoint', () => { + render(); + + expect(screen.queryByRole('button', { name: RE_REFRESH })).toBeNull(); + expect(screen.getByText('models.hintUnlistable')).toBeTruthy(); + }); +}); 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..99a199cb 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -6,37 +6,61 @@ import { boundAgentKinds, maskSecret, providerAccountListViewModel, - withBinding, + withAccountEnabled, + withDefaultAccount, withModel, withoutAccount, } 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 }, }; -describe('binding transforms', () => { - it('binds while preserving the entry and defaults enabled for a fresh kind', () => { - const next = withBinding(providers, 'codex', 'acc_a'); +describe('provider config transforms', () => { + it('sets the default while preserving the entry and defaults enabled for a fresh kind', () => { + const next = withDefaultAccount(providers, 'codex', 'acc_a'); expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_a' }); - expect(withBinding(providers, 'pi', 'acc_a').pi).toEqual({ + expect(withDefaultAccount(providers, 'pi', 'acc_a').pi).toEqual({ enabled: true, activeAccountId: 'acc_a', }); }); - 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' }); + it('clears the default by dropping only activeAccountId', () => { + const next = withDefaultAccount(providers, 'claude-code', undefined); + expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); + }); + + it('drops a pick the newly bound account does not offer, and keeps one it does', () => { + const offers = (id: string, models: string[]): Accounts[number] => ({ + id, + label: id, + credential: { type: 'api-key', key: 'k' }, + models: models.map((model) => ({ id: model })), + createdAt: 0, + }); + const pool = [offers('acc_keep', ['claude-opus-4-8']), offers('acc_drop', ['deepseek-v4-pro'])]; + + // Moving the default to an account that lists the pick leaves it alone. + expect(withDefaultAccount(providers, 'claude-code', 'acc_keep', pool)['claude-code']).toEqual({ + enabled: true, + activeAccountId: 'acc_keep', + model: 'claude-opus-4-8', + }); + // One that does not would otherwise start the next session on a model it never listed. + expect(withDefaultAccount(providers, 'claude-code', 'acc_drop', pool)['claude-code']).toEqual({ + enabled: true, + activeAccountId: 'acc_drop', + }); }); it('sets and clears the default model without touching the binding', () => { expect(withModel(providers, 'claude-code', 'claude-sonnet-5')['claude-code']).toEqual({ enabled: true, activeAccountId: 'acc_a', - defaultModel: 'claude-sonnet-5', + model: 'claude-sonnet-5', }); expect(withModel(providers, 'claude-code', undefined)['claude-code']).toEqual({ enabled: true, @@ -44,9 +68,42 @@ describe('binding transforms', () => { }); }); + it('materializes the enabled list from what is bindable on the first disable', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + { id: 'acc_b', label: 'B', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + + // Absent means "all bindable", so disabling one has to write the rest down explicitly. + const disabled = withAccountEnabled(providers, 'opencode', 'acc_a', false, pool); + expect(disabled.opencode?.enabledAccountIds).toEqual(['acc_b']); + // Re-enabling puts it back without duplicating. + const reEnabled = withAccountEnabled(disabled, 'opencode', 'acc_a', true, pool); + expect(reEnabled.opencode?.enabledAccountIds).toEqual(['acc_b', 'acc_a']); + }); + + it('clears the default when the account serving it is disabled', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + // Leaving it would keep resolving unpinned sessions onto an account just removed from the menu. + const next = withAccountEnabled(providers, 'claude-code', 'acc_a', false, pool); + expect(next['claude-code']?.activeAccountId).toBeUndefined(); + expect(next['claude-code']?.enabledAccountIds).toEqual([]); + }); + + it('leaves the default alone when a non-default account is disabled', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + { id: 'acc_b', label: 'B', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + const next = withAccountEnabled(providers, 'claude-code', 'acc_b', false, pool); + expect(next['claude-code']?.activeAccountId).toBe('acc_a'); + }); + it('clears every binding of a removed account, identity-stable when none matched', () => { 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 +115,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 +213,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 +224,7 @@ describe('view helpers', () => { secret: 'new-secret', baseUrl: 'https://new.example.com/v1', protocol: 'anthropic', - model: 'new-model', + models: [{ id: 'new-model' }], }), ).toEqual({ id: 'acc_a', @@ -176,7 +233,7 @@ describe('view helpers', () => { service: 'openrouter', credential: { type: 'auth-token', token: 'new-secret' }, endpoint: { baseUrl: 'https://new.example.com/v1', protocol: 'anthropic' }, - model: 'new-model', + models: [{ id: 'new-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..185a54f1 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -1,13 +1,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { EndpointService, ServiceDescriptor, ServiceGroup } from '@linkcode/providers'; import { + modelListSource, pinnedEndpoint, SERVICE_CATALOG, serviceById, serviceProtocols, templatePlaceholders, } from '@linkcode/providers'; -import type { Account, AccountProtocol, AgentRuntimes } from '@linkcode/schema'; +import type { Account, AccountModel, AccountProtocol, AgentRuntimes } from '@linkcode/schema'; +import { AccountModelSchema } from '@linkcode/schema'; import { AgentOnboardingCard, ServiceIcon } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; import { Field, FieldLabel } from 'coss-ui/components/field'; @@ -27,6 +29,8 @@ import { Controller, useForm } from 'react-hook-form'; import { useTranslations } from 'use-intl'; import { z } from 'zod'; import type { AgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; +import type { ModelSources } from './model-selection'; +import { ModelSelection } from './model-selection'; const GROUPS: ServiceGroup[] = ['subscription', 'direct', 'gateway', 'custom']; @@ -43,11 +47,13 @@ function newAccountBase(label: string): Pick, label: string, + models: AccountModel[] = [], ): Account { return { ...newAccountBase(label), service: service.id, credential: { type: 'oauth', agent: service.agent }, + ...(models.length > 0 && { models }), }; } @@ -67,7 +73,7 @@ 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() }), + ...(draft.models.length > 0 && { models: draft.models }), }; } @@ -98,7 +104,7 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account credential: _credential, endpoint: _endpoint, label: _label, - model: _model, + models: _models, ...rest }) => rest)(account); return { @@ -110,7 +116,7 @@ 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() }), + ...(draft.models.length > 0 && { models: draft.models }), }; } @@ -157,6 +163,7 @@ export function ServiceCatalogView({ /** Step two: the per-service seeded form (or the free-form one for `custom`). */ export function AddAccountForm({ serviceId, + sources, runtimes, onboarding, busy, @@ -164,6 +171,7 @@ export function AddAccountForm({ onSubmit, }: { serviceId: string; + sources?: ModelSources; runtimes: AgentRuntimes | undefined; onboarding: AgentRuntimeOnboarding; busy: boolean; @@ -188,15 +196,16 @@ export function AddAccountForm({ {service.kind === 'oauth' ? ( ) : service.kind === 'endpoint' ? ( - + ) : ( - + )} ); @@ -205,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; @@ -231,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')}

@@ -342,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} @@ -356,8 +403,8 @@ 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()), + models: z.array(AccountModelSchema), }); type CatalogDraft = z.infer; @@ -381,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 { @@ -394,16 +443,34 @@ function CatalogAccountForm({ const { register, + control, + getValues, handleSubmit, formState: { isSubmitting }, } = useForm({ resolver: zodResolver(catalogDraftSchema(service)), - defaultValues: { label: serviceName, secret: '', model: '', 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 ( ))} -
-
- - {secretLabel} - - -
-
- - {t('form.model')} - - -
-
+ + {secretLabel} + + + ( + + )} + />

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

@@ -457,17 +526,19 @@ const CustomDraftSchema = z.object({ secret: z.string().min(1), baseUrl: z.string(), protocol: z.string(), - model: 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 { @@ -495,9 +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) ?? '', - model: account?.model ?? '', + 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') }, @@ -556,10 +637,18 @@ function CustomAccountForm({
- - {t('form.model')} - - + ( + + )} + />
+ ) : 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..612174b5 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -21,11 +21,13 @@ 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, providerAccountListViewModel, - withBinding, + withAccountEnabled, + withDefaultAccount, withModel, withoutAccount, } from './view'; @@ -48,6 +50,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); @@ -73,8 +77,13 @@ export function ProvidersSettingsPanel(): React.ReactNode { void mutateProviders(); }; - const handleSetBinding = (kind: AgentKind, accountId: string | undefined): void => { - void applyProviders(withBinding(providers ?? {}, kind, accountId)); + const handleSetDefaultAccount = (kind: AgentKind, accountId: string | undefined): void => { + void applyProviders(withDefaultAccount(providers ?? {}, kind, accountId, pool)); + }; + + const handleSetAccountEnabled = (kind: AgentKind, enabled: boolean): void => { + if (!selected) return; + void applyProviders(withAccountEnabled(providers ?? {}, kind, selected.id, enabled, pool)); }; const handleSetModel = (kind: AgentKind, model: string | undefined): void => { @@ -169,6 +178,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { {view.kind === 'add-form' ? ( { @@ -194,7 +205,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { { diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 3467dce9..adc54404 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -18,10 +18,11 @@ import type { ProviderAccountListItem, ProviderAccountListViewModel, ProviderAccountRouting, - ProviderBindingStatus, - ProviderBindingViewModel, + ProviderAgentStatus, + ProviderAgentViewModel, ProviderCredentialViewModel, } from '@linkcode/ui'; +import { accountEnabledFor } from './default-models'; /** Pure view helpers for the Providers page — no hooks, unit-testable. */ @@ -33,7 +34,7 @@ export function maskSecret(secret: string): string { return `${secret.slice(0, 6)}…${secret.slice(-4)}`; } -/** Agents whose active provider is this account, in stable agent order. */ +/** Agents that fall back to this account when nothing names one, in stable agent order. */ export function boundAgentKinds( providers: ProvidersConfig | undefined, accountId: string, @@ -86,42 +87,39 @@ function credentialViewModel( }; } -function bindingStatus( +function agentStatus( account: Account, accountLabels: ReadonlyMap, kind: AgentKind, providers: ProvidersConfig | undefined, -): { bound: boolean; status: ProviderBindingStatus; tier: ProviderBindingViewModel['tier'] } { +): Omit { const availability = resolveBinding(account, kind); - const boundId = providers?.[kind]?.activeAccountId; - const bound = boundId === account.id; + const defaultId = providers?.[kind]?.activeAccountId; + const isDefault = defaultId === account.id; + const enabled = accountEnabledFor(providers, kind, account.id); if (availability.tier === 'unavailable') { - if (availability.reason === 'oauth-other-agent' && account.credential.type === 'oauth') { - return { - bound, - tier: availability.tier, - status: { kind: 'unavailable-oauth', agent: account.credential.agent }, - }; - } - return { - bound, - tier: availability.tier, - status: { - kind: - availability.reason === 'endpoint-incomplete' - ? 'unavailable-endpoint-incomplete' - : 'unavailable-protocol', - }, - }; + const status: ProviderAgentStatus = + availability.reason === 'oauth-other-agent' && account.credential.type === 'oauth' + ? { kind: 'unavailable-oauth', agent: account.credential.agent } + : { + kind: + availability.reason === 'endpoint-incomplete' + ? 'unavailable-endpoint-incomplete' + : 'unavailable-protocol', + }; + return { tier: availability.tier, enabled: false, isDefault: false, status }; } return { - bound, tier: availability.tier, - status: bound - ? { kind: 'bound' } - : boundId === undefined - ? { kind: 'no-provider' } - : { kind: 'bound-elsewhere', accountLabel: accountLabels.get(boundId) ?? boundId }, + enabled, + isDefault, + status: !enabled + ? { kind: 'disabled' } + : isDefault + ? { kind: 'default' } + : defaultId === undefined + ? { kind: 'enabled-no-default' } + : { kind: 'enabled', defaultLabel: accountLabels.get(defaultId) ?? defaultId }, }; } @@ -133,12 +131,13 @@ export function providerAccountDetailViewModel( runtimes: AgentRuntimes | undefined, ): ProviderAccountDetailViewModel { const accountLabels = new Map(accounts.map((candidate) => [candidate.id, candidate.label])); - const bindings = AGENT_KINDS.map((kind): ProviderBindingViewModel => { - const binding = bindingStatus(account, accountLabels, kind, providers); + const agents = AGENT_KINDS.map((kind): ProviderAgentViewModel => { + const status = agentStatus(account, accountLabels, kind, providers); + // Only the default account's row edits the default model — the pick belongs to that pairing. return { kind, - ...binding, - currentModel: providers?.[kind]?.defaultModel ?? '', + ...status, + ...(status.isDefault && { defaultModel: providers?.[kind]?.model ?? '' }), }; }); const boundAgents = boundAgentKinds(providers, account.id); @@ -148,13 +147,16 @@ export function providerAccountDetailViewModel( id: account.id, label: account.label, credential: credentialViewModel(account, runtimes), - bindings, + agents, boundAgents, - availableBindingCount: bindings.filter((binding) => binding.tier !== 'unavailable').length, + enabledAgentCount: agents.filter((agent) => agent.enabled).length, + availableAgentCount: agents.filter((agent) => agent.tier !== 'unavailable').length, ...(!(account.service === undefined) && { service: account.service }), ...(!(serviceLabel === undefined) && { serviceLabel }), ...(routing !== undefined && { routing }), - ...(!(account.model === undefined) && { accountModel: account.model }), + ...(account.models !== undefined && { + accountModels: account.models.map(({ id, label }) => ({ id, label: label ?? id })), + }), ...(!(boundAgents.length === 0) && { configPreview: accountConfigSnippet(providers, account.id), }), @@ -219,18 +221,63 @@ export function providerAccountListViewModel( }; } -/** Bind (or, with undefined, unbind) an agent's active account; other fields survive untouched. */ -export function withBinding( +/** + * Set (or, with undefined, clear) the account an agent falls back to when nothing names one. Other + * fields survive untouched — with one exception. The default model lives per agent while the set it + * came from lives on the account, so moving the default can orphan it. Dropping a model the new + * account does not offer leaves the agent unpicked, which blocks its unpinned sends until the user + * chooses again; keeping it would run the next one on a model that account never listed. + */ +export function withDefaultAccount( providers: ProvidersConfig, kind: AgentKind, accountId: string | undefined, + accounts: Accounts = [], ): ProvidersConfig { const entry = providers[kind] ?? { enabled: true }; if (accountId === undefined) { const { activeAccountId: _cleared, ...rest } = entry; return { ...providers, [kind]: rest }; } - 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 }, + }; +} + +/** + * Show or hide one account's models in an agent's pickers. Absent `enabledAccountIds` means every + * bindable account, so the first disable has to materialize the list from what is bindable *now* — + * an account added later then joins the explicit list rather than being silently excluded. + * + * Disabling the agent's default account also clears the default: leaving it would keep resolving + * unpinned sessions onto an account the user just removed from the menu. + */ +export function withAccountEnabled( + providers: ProvidersConfig, + kind: AgentKind, + accountId: string, + enabled: boolean, + accounts: Accounts = [], +): ProvidersConfig { + const entry = providers[kind] ?? { enabled: true }; + const current = + entry.enabledAccountIds ?? + accounts.reduce((ids, account) => { + if (resolveBinding(account, kind).tier !== 'unavailable') ids.push(account.id); + return ids; + }, []); + const next = enabled + ? [...new Set([...current, accountId])] + : current.filter((id) => id !== accountId); + const withList: ProvidersConfig = { ...providers, [kind]: { ...entry, enabledAccountIds: next } }; + return enabled || entry.activeAccountId !== accountId + ? withList + : withDefaultAccount(withList, kind, undefined); } /** Toggle whether the agent is offered in the client's agent picker. */ @@ -242,18 +289,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 { defaultModel: _cleared, ...rest } = entry; + const { model: _cleared, ...rest } = entry; return { ...providers, [kind]: rest }; } - return { ...providers, [kind]: { ...entry, defaultModel: 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..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 = { @@ -25,41 +28,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' }); }); @@ -82,9 +78,8 @@ describe('new-session defaults', () => { STORAGE_KEY, JSON.stringify({ state: { - lastProvider: 'codex', + lastHarness: 'codex', lastWorkspaceId: WORKSPACE_ID, - modelsByProvider: { codex: '' }, effortsByProvider: { codex: 'unsupported' }, }, version: 0, @@ -93,8 +88,7 @@ describe('new-session defaults', () => { const store = await loadStore(); - expect(store.getState().lastProvider).toBeNull(); - expect(store.getState().modelsByProvider).toEqual({}); + 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 48244320..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,11 +9,19 @@ 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(), - modelsByProvider: z.partialRecord(AgentKindSchema, z.string().min(1)), effortsByProvider: z.partialRecord(AgentKindSchema, EffortLevelSchema), branchesByWorkspace: z.record(z.string(), BranchSelectionSchema), }) @@ -21,7 +29,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; @@ -29,11 +38,9 @@ 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 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 +58,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 +66,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. */ @@ -82,15 +79,14 @@ export const useNewSessionDefaultsStore = create()( PersistedNewSessionDefaults >( (set) => ({ - lastProvider: null, + lastHarness: null, lastWorkspaceId: null, - modelsByProvider: {}, effortsByProvider: {}, branchesByWorkspace: {}, remember: (provider, workspaceId, selection, branch) => set((state) => ({ ...selectionPatch(state, provider, selection), - lastProvider: provider, + lastHarness: provider, lastWorkspaceId: workspaceId, branchesByWorkspace: branch === undefined @@ -101,12 +97,11 @@ export const useNewSessionDefaultsStore = create()( set((state) => selectionPatch(state, provider, selection)), }), { - name: 'linkcode.workbench.new-session-defaults:v5', + name: NEW_SESSION_DEFAULTS_STORAGE_KEY, schema: PersistedNewSessionDefaultsSchema, partialize: (state) => ({ - lastProvider: state.lastProvider, + lastHarness: state.lastHarness, lastWorkspaceId: state.lastWorkspaceId, - modelsByProvider: state.modelsByProvider, effortsByProvider: state.effortsByProvider, branchesByWorkspace: state.branchesByWorkspace, }), diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index f36dfbfd..01664162 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -48,7 +48,9 @@ export interface WorkbenchSessions { create: (opts: { kind: AgentKind; cwd: string; - model?: string | null; + model?: string; + /** Pins the session to the account the picked model belongs to. */ + accountId?: string; effort?: EffortLevel; approvalPolicyId?: string; modeId?: SessionModeId; @@ -193,7 +195,9 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench async function create(opts: { kind: AgentKind; cwd: string; - model?: string | null; + 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 fd1e18da..34be5cb7 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, @@ -57,7 +58,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 +245,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 @@ -268,9 +273,8 @@ 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 newSessionPreferredModels = useNewSessionDefaultsStore((state) => state.modelsByProvider); const newSessionPreferredEfforts = useNewSessionDefaultsStore((state) => state.effortsByProvider); const newSessionPreferredBranches = useNewSessionDefaultsStore( (state) => state.branchesByWorkspace, @@ -374,6 +378,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, @@ -402,8 +407,11 @@ function WorkbenchSessionSurface({ startupSelection, sdkClient.raw.eventsSnapshot(sessionId), ); - if (newlyConfirmed.model === undefined && newlyConfirmed.effort === undefined) return; - rememberSelection(submission.kind, newlyConfirmed); + // The model is not remembered here: the session carries its own pick, and the agent's + // default is a deliberate Settings choice that starting one thread must not overwrite. + if (newlyConfirmed.effort !== undefined) { + rememberSelection(submission.kind, { effort: newlyConfirmed.effort }); + } }) .catch(noop); } @@ -444,15 +452,20 @@ 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 }); - }); + // The engine records the accepted pick on the session's own run, so it survives a relaunch + // without touching the agent's configured default. + return modelMutation + .trigger({ + sessionId: sessions.activeId, + model: model.id, + ...(model.accountId !== undefined && { accountId: model.accountId }), + }) + .then(noop); } function handleEffortChange(effort: EffortLevel): Promise { @@ -568,7 +581,7 @@ function WorkbenchSessionSurface({ const draft: NewSessionDraft | null = sessions.draft ? { initialWorkspaceId, - initialProvider: lastProvider ?? 'claude-code', + initialHarness: lastHarness ?? 'claude-code', } : null; @@ -651,8 +664,8 @@ function WorkbenchSessionSurface({ newSessionWorkspaceId={newSessionWorkspaceId} onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} newSessionDefaultModels={newSessionDefaultModels} + accountModels={accountModels} agentCatalogs={agentCatalogs} - newSessionPreferredModels={newSessionPreferredModels} newSessionPreferredEfforts={newSessionPreferredEfforts} newSessionPreferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={RuntimeNewSessionBranchPicker} diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 31893341..bce1c99d 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -121,7 +121,7 @@ describe('dev mock transport', () => { expect(replyText).toContain('Hello mocked daemon'); const providers = { - codex: { enabled: true, defaultModel: 'mock-model' }, + codex: { enabled: true, model: 'mock-model' }, } satisfies ProvidersConfig; await client.setProviderConfig(providers); expect(await client.getProviderConfig()).toEqual(providers); @@ -147,7 +147,7 @@ describe('dev mock transport', () => { { ...boundAccount, label: 'Updated relay' }, ]); expect(await client.getProviderConfig()).toEqual({ - codex: { enabled: true, defaultModel: 'mock-model', activeAccountId: 'acc_2' }, + codex: { enabled: true, model: 'mock-model', activeAccountId: 'acc_2' }, }); client.dispose(); 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'; diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index 0603549f..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. */ @@ -39,6 +40,14 @@ export const AccountEndpointSchema = z.object({ }); export type AccountEndpoint = z.infer; +/** A model an account can run on: read from the service's own model list, or typed by the user for + * an endpoint that serves no list. `label` is the provider's display name when it ships one. */ +export const AccountModelSchema = z.object({ + id: z.string().min(1), + label: z.string().optional(), +}); +export type AccountModel = z.infer; + export const AccountSchema = z.object({ /** Stable id referenced by `providers[kind].activeAccountId` and `StartOptions.config.accountId`. */ id: z.string().min(1), @@ -54,22 +63,16 @@ export const AccountSchema = z.object({ * gateway ids). The account holds these rather than a resolved URL, because one secret can * resolve to a different endpoint per agent. */ endpointParams: z.record(z.string(), z.string()).optional(), - /** Per-account default model (vendor-specific), overriding the provider default when set. */ - model: z.string().optional(), + /** The models the user picked for this account, and the only ones its pickers offer. Fetched from + * the service's model list, typed in freehand, or both; an empty or absent set means no session + * can start on this account until the user picks one. */ + models: z.array(AccountModelSchema).optional(), /** Extra environment injected into the agent process (escape hatch, e.g. gateway flags). */ extraEnv: z.record(z.string(), z.string()).optional(), createdAt: TimestampSchema, }); export type Account = z.infer; -/** A model an endpoint advertises on its own model list, as read by the daemon's probe. `label` is - * the provider's display name when it ships one; relays usually ship the bare id only. */ -export const AccountModelSchema = z.object({ - id: z.string().min(1), - label: z.string().optional(), -}); -export type AccountModel = z.infer; - /** The global account pool, keyed by position; account ids are unique within it. */ export const AccountsSchema = z.array(AccountSchema); export type Accounts = z.infer; diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 1a00b8b9..be0fa84b 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -64,9 +64,10 @@ export const StartOptionsSchema = z.object({ cwd: z.string().min(1), /** Existing local branch and whether to use the original checkout or a managed worktree. */ branch: BranchSelectionSchema.optional(), - /** Model id override (vendor-specific). Undefined applies the LinkCode-configured default; - * null explicitly defers to the agent/provider's own default. */ - model: z.string().nullable().optional(), + /** Model id (vendor-specific). Undefined falls back to the agent's persisted pick + * (`ProviderConfig.model`); if that is unset too, the session refuses to start rather than + * letting the agent choose for itself. */ + model: z.string().optional(), /** Initial session mode (e.g. plan / accept-edits), if the agent advertises modes. */ modeId: SessionModeIdSchema.optional(), /** Initial reasoning effort, if the selected adapter supports effort. */ @@ -169,8 +170,14 @@ export const AgentInputSchema = z.discriminatedUnion('type', [ * adapters that advertise policies via `approval-policy-update` accept this; others reject it. */ z.object({ type: z.literal('set-approval-policy'), policyId: ApprovalPolicyIdSchema }), /** Switch the model for the session, going forward (vendor-specific id). Only adapters that - * support changing the model on an already-running session accept this; others reject it. */ - z.object({ type: z.literal('set-model'), model: z.string().min(1) }), + * support changing the model on an already-running session accept this; others reject it. + * `accountId` names the account the model was picked from; switching to a different one restarts + * the session and resumes its transcript, because credentials are injected once at spawn. */ + z.object({ + type: z.literal('set-model'), + model: z.string().min(1), + accountId: z.string().min(1).optional(), + }), /** Switch the reasoning-effort level for the session, going forward. Same acceptance rule as * `set-model`: only adapters that can rebind effort on a live session accept this. */ z.object({ type: z.literal('set-effort'), effort: EffortLevelSchema }), diff --git a/packages/foundation/schema/src/model/provider-config.ts b/packages/foundation/schema/src/model/provider-config.ts index bdc9286f..34b8be5c 100644 --- a/packages/foundation/schema/src/model/provider-config.ts +++ b/packages/foundation/schema/src/model/provider-config.ts @@ -7,13 +7,22 @@ import { AgentKindSchema } from './primitives'; export const ProviderConfigSchema = z.object({ /** Whether the agent is offered in the client's agent picker. */ enabled: z.boolean().default(true), - /** Default model used when the client starts a session without specifying one. */ - defaultModel: z.string().optional(), + /** The model this agent currently runs on, picked by the user from the bound account's set and + * persisted so it survives across sessions. Not a fallback default: unset means no session can + * start, because nothing else resolves a model. */ + model: z.string().optional(), /** Legacy provider API key, superseded by the global account pool (`account.ts`) but kept so * pre-account configs still load; the resolver falls back to it when `activeAccountId` is unset. */ apiKey: z.string().optional(), - /** Id of the pooled `Account` this agent's new sessions use (see `account.ts`). */ + /** Id of the pooled `Account` this agent falls back to when nothing names one: automation, + * schedules, and IM-created threads, plus a new session started without picking a model. + * Sessions started from a picker carry their own account and never consult this. */ activeAccountId: z.string().optional(), + /** The accounts whose models this agent offers in its pickers. **Absent means every bindable + * account**, so an added account is offered without a trip through Settings; an explicit list is + * the user narrowing it. Availability still gates it — listing an account that cannot back this + * agent offers nothing. */ + enabledAccountIds: z.array(z.string().min(1)).optional(), }); export type ProviderConfig = z.infer; diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index 0717fdfb..8e4f9cfa 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -36,6 +36,12 @@ 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(), + /** The model this run resolved to. Recorded with the account because the two are one choice, and + * read back on relaunch: a thread keeps its own pick instead of adopting a default that moved. */ + model: z.string().min(1).optional(), startedAt: TimestampSchema, endedAt: TimestampSchema.optional(), }); @@ -76,6 +82,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 — what the session is talking to now. Picking a model from another + * account relaunches the session on it, which starts a new run. */ + accountId: z.string().min(1).optional(), /** Provider-history operations supported by this session's adapter/runtime. */ historyCapabilities: AgentHistoryCapabilitiesSchema.optional(), }); diff --git a/packages/foundation/schema/src/wire/config.ts b/packages/foundation/schema/src/wire/config.ts index eed2644b..00694dac 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import { - AccountEndpointSchema, AccountModelSchema, AccountSchema, AccountSecretSchema, @@ -38,14 +37,18 @@ export const configWireVariants = [ agent: AgentKindSchema, account: AccountSchema, }), - /** Enumerate what an endpoint serves, before the account is saved: the daemon reads the - * endpoint's own model list with the given secret. The client cannot do this itself — the - * renderer's CSP blocks remote fetches, and only the daemon may hold the secret. */ + /** Enumerate the ids a service serves. The daemon resolves the list URL from the service catalog + * and makes the call itself — the renderer's CSP blocks remote fetches, and the secret belongs on + * that side. `inline` carries a secret the add form has not saved yet; `account` names a saved one + * so its stored secret never travels back out to the client. */ z.object({ kind: z.literal('config.probe-models'), clientReqId: WireRequestIdSchema, - endpoint: AccountEndpointSchema, - secret: AccountSecretSchema, + service: z.string().min(1), + credential: z.discriminatedUnion('type', [ + z.object({ type: z.literal('inline'), secret: AccountSecretSchema }), + z.object({ type: z.literal('account'), accountId: z.string().min(1) }), + ]), }), z.object({ kind: z.literal('config.probe-models.result'), diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 8445c3c4..759512b5 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,11 +9,11 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 73 as const; +export const WIRE_PROTOCOL_VERSION = 74 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ -export const MIN_COMPATIBLE_WIRE_VERSION = 68 as const; +export const MIN_COMPATIBLE_WIRE_VERSION = 74 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index a4d0e70f..ef046143 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -90,7 +90,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they ## opencode & pi - **opencode** — `consumeEvents()` keeps one active `event.subscribe({directory: cwd})` and resubscribes after a clean SSE close at normal turn end (`session.idle`) or on cancel. The directory scope is LOAD-BEARING: events ride a per-directory instance bus, so a bare `subscribe()` silently misses every session event whenever the daemon cwd differs from the session cwd (verified live on 1.17.11). A close is fatal ONLY while a turn is active with no cancel pending, and the fatal path emits status `stopped` (NOT `idle`) so the UI disables the composer — misclassifying it (the pre-fix bug) stranded the composer enabled against a dead adapter. Each event has its own try/catch; the resubscribe delay prevents an empty-response busy loop. -- **opencode control plane** (CODE-224, live-verified on binary 1.18.2 × SDK 1.17.18 — script + readback transcript attached to the issue): `set-model` and `set-approval-policy` are pure store-then-emit — the pick is resent on every `session.promptAsync`/`session.command` as the `model`/`agent` fields, and a mid-session change routes the very next turn (assistant `providerID`/`modelID`/`agent` readback all flip; next-turn semantics, in-flight turns unaffected). User and assistant `message.updated` frames reflect the actually routed `providerID/modelID`, including the native default when no override was sent. No dedicated switched/ack event fires on the legacy bus, so the immediate reflect is the only switch confirmation channel. `set-model` rejects refs that aren't `providerID/modelID` (a stored bare id would emit a "successful" model-update while prompts silently omit the field) and rejects cross-provider switches when a per-account credential was injected at spawn (the injection is spawn-time-only, scoped to one provider). **The approval-policy axis IS opencode's agent axis**: selectable agents from `app.agents({directory})` (`mode === 'primary'|'all'`, non-hidden — hidden primaries like `compaction`/`title` and subagents are excluded) are advertised as policies, default = first primary (the TUI's own default); permission posture stays config-driven (CODE-136). The axis is dynamic: a failed discovery at start hides it for the session (a later `$` shell command retries and re-arms it on success). `$` shell runs under the selected agent. Resume adopts the Session record's last-used `model`/`agent` (both live-verified to update after every turn) unless `StartOptions.model` overrides; a credential-carrying resume without an explicit model pre-reads that record off the shared history server BEFORE the spawn, because the credential injection is spawn-time-only and keyed by the model's provider. There is NO effort axis — opencode's only analogue is per-model `variant` keys (free strings, incompatible with the closed `EffortLevel` enum; a follow-up on the dynamic catalog). **The model catalog is adapter-advertised** (CODE-226): `provider.list({directory})` at start → `available-models-update` (full-replace, engine-cached, attach-replayed — the command-catalog contract), filtered to connected / key-less `api`-source providers and narrowed to the credential-injected provider when one is in play; the composer prefers this catalog over the static `AGENT_MODEL_OPTIONS` table (which deliberately has no opencode entry — its model set is provider-dependent, not a fixed vendor list). +- **opencode control plane** (CODE-224, live-verified on binary 1.18.2 × SDK 1.17.18 — script + readback transcript attached to the issue): `set-model` and `set-approval-policy` are pure store-then-emit — the pick is resent on every `session.promptAsync`/`session.command` as the `model`/`agent` fields, and a mid-session change routes the very next turn (assistant `providerID`/`modelID`/`agent` readback all flip; next-turn semantics, in-flight turns unaffected). User and assistant `message.updated` frames reflect the actually routed `providerID/modelID`, including the native default when no override was sent. No dedicated switched/ack event fires on the legacy bus, so the immediate reflect is the only switch confirmation channel. `set-model` rejects refs that aren't `providerID/modelID` **and cannot be qualified** — a picked model id comes from the service's own model list and carries no provider, so `config.knownProvider` supplies the missing half (`resolveModelRef`); with neither, a stored bare id would emit a "successful" model-update while prompts silently omit the field. Both reflection paths compare *resolved* refs and then emit the id the user picked, not opencode's prefixed readback, so the client's selected set still matches and rejects cross-provider switches when a per-account credential was injected at spawn (the injection is spawn-time-only, scoped to one provider). **The approval-policy axis IS opencode's agent axis**: selectable agents from `app.agents({directory})` (`mode === 'primary'|'all'`, non-hidden — hidden primaries like `compaction`/`title` and subagents are excluded) are advertised as policies, default = first primary (the TUI's own default); permission posture stays config-driven (CODE-136). The axis is dynamic: a failed discovery at start hides it for the session (a later `$` shell command retries and re-arms it on success). `$` shell runs under the selected agent. Resume adopts the Session record's last-used `model`/`agent` (both live-verified to update after every turn) unless `StartOptions.model` overrides; a credential-carrying resume without an explicit model pre-reads that record off the shared history server BEFORE the spawn, because the credential injection is spawn-time-only and keyed by the model's provider. There is NO effort axis — opencode's only analogue is per-model `variant` keys (free strings, incompatible with the closed `EffortLevel` enum; a follow-up on the dynamic catalog). **The model catalog is adapter-advertised** (CODE-226): `provider.list({directory})` at start → `available-models-update` (full-replace, engine-cached, attach-replayed — the command-catalog contract), filtered to connected / key-less `api`-source providers and narrowed to the credential-injected provider when one is in play; the composer prefers this catalog over the static `AGENT_MODEL_OPTIONS` table (which deliberately has no opencode entry — its model set is provider-dependent, not a fixed vendor list). - **opencode turn lifecycle** (all verified live on 1.17.11, CODE-136): prompts go through `session.promptAsync` — the blocking `session.prompt` holds its HTTP response open for the whole turn, so `send()` would not return until the turn ended. `session.status {busy|retry}` is the on-stream acknowledgement that the active turn is running, and it ALWAYS precedes the turn's own error/idle — the `turnStarted` gate built on it is what keeps the previous turn's post-settle stragglers (an abort's DUPLICATE idle; the error re-fired with a stack after the settle) from falsely settling or poisoning a next turn that was already dispatched. An abort delivers `session.error {MessageAbortedError}` + `session.idle` — the error folds into the cancel path (stop `cancelled`), never surfaces as an error. Other `session.error`s fail the turn: `ProviderAuthError` → `AUTH_FAILED_ERROR_CODE` (non-recoverable, triggers the daemon login re-probe), everything else recoverable; `sessionID` is OPTIONAL on this one event — an unattributed error still counts as ours. A failed turn's idle settle emits status `idle` but NO `end_turn` stop. An idle absorbed before the busy acknowledgement logs a `console.warn` — the one trace if a server never emits `session.status` (the turn would then hang at `running`). - **opencode RPC results resolve, they don't reject**: the generated client returns `{error}` for HTTP and network failures alike (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). - **opencode permissions & questions** (CODE-136): opencode's default posture is allow-all — asks only fire when the user's own config (or a future preset) says `ask`. `permission.asked` → the shared `requestPermission` round-trip → `permission.reply({reply: 'once'|'always'|'reject'})`; `always` is persisted server-side as a saved rule. `question.asked` → `requestQuestion` → `question.reply({answers})` (one label array per question) or `question.reject`. An UNANSWERED ask gates the turn server-side forever, so a teardown-cancelled permission replies `reject` and a cancelled question calls `reject` — reply failures after a cancel are swallowed (the abort already discarded the ask). Asks cite their tool via `tool.callID`, but tool cards are announced under the PART id — `toolPartIdByCallId` re-joins them. A custom "Other" answer rides as an extra label: upstream `Question.reply` hands the answer arrays to the asking tool verbatim, with no validation against option labels (verified in anomalyco/opencode source). @@ -106,13 +106,14 @@ Product code must branch on `historyCapabilities` — never assume an op is supp | claude-code | ✓ | ✓ (live) | ✓ (live) | ✓ | ✓ | ✗ | detected user install / managed dir | | codex | ✓ | ✓ (next turn) | ✓ (next turn, low–max; Sol/Terra ultra) | ✓ (3 tiers) | ✓ | ✓ | detected user install / managed dir | | opencode | ✓ | ✓ (next turn, same provider) | ✗ | ✓ (agent axis: build/plan/custom; hidden if discovery fails) | ✓ | ✓ | detected user install / managed dir (CODE-76; PATH-name fallback for unprobed hosts) | -| pi | ✗ | ✗ | ✗ | fixed bypass | ✗ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | +| pi | ✓ | ✗ | ✗ | fixed bypass | ✗ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | | grok-build | ✗ | ✓ (next turn) | ✓ (next turn, low–high) | fixed bypass | ✗ | ✗ | detected user install | `StartOptions.effort` enters through the same `onSetEffort` hook before `onStart`, so startup-only levels (Claude `max`) and live-switchable levels share validation and reflection behavior. The engine caches the emitted effort and replays it when the newly created session attaches. +- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one); which it *falls back to* when a session names none is `activeAccountId`, used by automation, schedules, and IM threads. Sessions started from a picker carry `config.accountId` and ignore the fallback. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records the model and account it resolved to, and a relaunch reads them back, so a thread keeps its own pick even after the fallback moves. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **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/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index 06a505c0..e1747a49 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -1628,7 +1628,29 @@ describe('OpenCodeAdapter control plane (CODE-224)', () => { }); }); - it('rejects a set-model ref that is not providerID/modelID', async () => { + it('qualifies a bare picked id with the endpoint’s known provider', async () => { + // A picked id comes from the service's model list and carries no provider; opencode routes + // only by providerID/modelID, so `knownProvider` supplies the missing half. + const adapter = new OpenCodeAdapter(); + const events: AgentEvent[] = []; + adapter.onEvent((e) => events.push(e)); + await adapter.start({ + kind: 'opencode', + cwd: '/tmp/repo', + config: { knownProvider: 'deepseek' }, + }); + + await adapter.send({ type: 'set-model', model: 'deepseek-v4-pro' }); + // Reflected as the user picked it, so the client's own selected set matches. + expect(events).toContainEqual({ type: 'model-update', model: 'deepseek-v4-pro' }); + + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hi' }] }); + expect(client.session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ model: { providerID: 'deepseek', modelID: 'deepseek-v4-pro' } }), + ); + }); + + it('rejects a bare set-model ref when no known provider can qualify it', async () => { const { adapter, events } = await makeAdapter(); events.length = 0; diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 4d91fc8b..c76571fe 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -148,6 +148,17 @@ function parseModelRef(model: string): { providerID: string; modelID: string } | return { providerID, modelID }; } +/** A selected model id is the vendor's own, taken from the service's model list and carrying no + * provider — opencode routes only by `providerID/modelID`, so qualify it with the endpoint's id in + * opencode's catalog. Without a known provider a bare id stays unroutable and is rejected. */ +function resolveModelRef( + model: string, + knownProvider: string | undefined, +): { providerID: string; modelID: string } | undefined { + if (model.includes('/')) return parseModelRef(model); + return knownProvider === undefined ? undefined : { providerID: knownProvider, modelID: model }; +} + type OpencodeModule = typeof import('@opencode-ai/sdk/v2'); type OpencodeClient = ReturnType; type OpencodeProviderList = NonNullable< @@ -384,7 +395,14 @@ export class OpenCodeAdapter extends BaseAgentAdapter { } // A resumed session record confirms its current model. A fresh override is confirmed only when // the running server advertises that exact ref; a request or failed catalog is not acceptance. - if (opts.model && (opts.model === resumedModel || availableModels?.has(opts.model))) { + // Compare as resolved refs: a picked id is the vendor's own and carries no provider, while the + // catalog and the session record are always `providerID/modelID`. + const requested = this.model(); + const advertised = + requested === undefined + ? false + : availableModels?.has(`${requested.providerID}/${requested.modelID}`) === true; + if (opts.model && (advertised || opts.model === resumedModel)) { this.emitModel(opts.model); } void this.consumeEvents(); @@ -760,9 +778,9 @@ export class OpenCodeAdapter extends BaseAgentAdapter { * the legacy bus — the immediate reflect below is the only confirmation channel there is. */ protected override onSetModel(model: string): Promise { invariant(this.opts, 'opencode: session not started'); - const parsed = parseModelRef(model); + const parsed = resolveModelRef(model, readAgentCredential(this.opts.config).knownProvider); if (!parsed) { - // Storing an unparseable ref would emit a "successful" model-update while every following + // Storing an unroutable ref would emit a "successful" model-update while every following // prompt silently omits the model field and keeps running on the previous one. return Promise.reject( new Error(`opencode: model must be 'providerID/modelID' (got '${model}')`), @@ -821,7 +839,8 @@ export class OpenCodeAdapter extends BaseAgentAdapter { } private model(): { providerID: string; modelID: string } | undefined { - return this.opts?.model ? parseModelRef(this.opts.model) : undefined; + if (!this.opts?.model) return undefined; + return resolveModelRef(this.opts.model, readAgentCredential(this.opts.config).knownProvider); } /** Runs for the whole session, dispatching every SSE event and replacing a stream the server @@ -964,7 +983,12 @@ export class OpenCodeAdapter extends BaseAgentAdapter { ) { return; } - this.emitModel(model); + // opencode always reports `providerID/modelID`. When that is the pick resolved, reflect the + // pick verbatim instead — the client matches against the ids the user selected, which are bare. + const requested = this.model(); + const routedThePick = + requested !== undefined && `${requested.providerID}/${requested.modelID}` === model; + this.emitModel(routedThePick && this.opts?.model ? this.opts.model : model); } /** Turn settle on `session.idle`, guarded on liveness AND `turnStarted`: an abort's duplicate 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 06cd6bfa..d8489422 100644 --- a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts +++ b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts @@ -32,12 +32,12 @@ describe('engine agent catalog', () => { label: 'Catalog account', credential: { type: 'api-key', key: 'catalog-key' }, endpoint: { baseUrl: 'https://catalog.example.test', protocol: 'openai-chat' }, - model: 'provider/model', + models: [{ id: 'provider/model' }], createdAt: 0, }; providers.update({ providers: { - 'claude-code': { enabled: true, activeAccountId: account.id }, + 'claude-code': { enabled: true, activeAccountId: account.id, model: 'provider/model' }, }, accounts: [account], }); @@ -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-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index 35803127..b2de8813 100644 --- a/packages/host/engine/src/__tests__/engine-model-probe.test.ts +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -5,6 +5,7 @@ import type { WirePayload } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { probeEndpointModels, requestModelListAtAddress } from '../agent/model-probe'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; import { createSessionHarness } from './fixtures/session-harness'; /** The probe is a real HTTP round-trip, so its reply lands after `inject`'s task settle. */ @@ -34,19 +35,30 @@ function baseUrl(server: Server): string { return `http://127.0.0.1:${port}`; } -const localModelProbe: typeof probeEndpointModels = (endpoint, secret) => - probeEndpointModels(endpoint, secret, (url, headers) => +let relay: Server | undefined; + +/** Sends the catalog's own path at the local relay, so the relay records which path the service + * descriptor resolved to while the real HTTP round-trip stays under test. */ +const localModelProbe: typeof probeEndpointModels = (source, secret) => { + const resolved = new URL(source.url); + const local = `${baseUrl(nullthrow(relay, 'relay not started'))}${resolved.pathname}${resolved.search}`; + return probeEndpointModels({ ...source, url: local }, secret, (url, headers) => requestModelListAtAddress(url, headers, { address: '127.0.0.1', family: 4 }), ); +}; -function createHarness() { - return createSessionHarness(undefined, undefined, undefined, undefined, undefined, undefined, { - modelProbe: localModelProbe, - }); +function createHarness(providerStore?: InMemoryProviderConfigStore) { + return createSessionHarness( + undefined, + undefined, + undefined, + undefined, + undefined, + providerStore, + { modelProbe: localModelProbe }, + ); } -let relay: Server | undefined; - afterEach(async () => { const server = relay; if (server) { @@ -62,7 +74,7 @@ describe('config.probe-models', () => { const seen: string[] = []; relay = await startRelay((url) => { seen.push(url); - return url === '/v1/models' + return url === '/models' ? { status: 200, body: JSON.stringify({ data: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }] }) } : { status: 404, body: '{}' }; }); @@ -72,8 +84,8 @@ describe('config.probe-models', () => { await h.inject({ kind: 'config.probe-models', clientReqId: 'probe-1', - endpoint: { baseUrl: `${baseUrl(relay)}/v1`, protocol: 'openai-chat' }, - secret: { type: 'api-key', key: 'sk-test' }, + service: 'deepseek', + credential: { type: 'inline', secret: { type: 'api-key', key: 'sk-test' } }, }); await expect(replyFor(h.sent, 'probe-1')).resolves.toEqual({ @@ -81,7 +93,8 @@ describe('config.probe-models', () => { replyTo: 'probe-1', models: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }], }); - expect(seen).toEqual(['/v1/models']); + // The service's own path, not one derived from a variant's baseUrl. + expect(seen).toEqual(['/models']); }); it("relays the endpoint's own rejection to the client", async () => { @@ -95,8 +108,8 @@ describe('config.probe-models', () => { await h.inject({ kind: 'config.probe-models', clientReqId: 'probe-2', - endpoint: { baseUrl: baseUrl(relay), protocol: 'anthropic' }, - secret: { type: 'api-key', key: 'bad' }, + service: 'anthropic-api', + credential: { type: 'inline', secret: { type: 'api-key', key: 'bad' } }, }); const failed = await replyFor(h.sent, 'probe-2'); @@ -104,4 +117,56 @@ describe('config.probe-models', () => { expect(failed.message).toContain('401'); expect(failed.message).toContain('invalid api key'); }); + + it('refuses a service that serves no model list', async () => { + const h = createHarness(); + await h.engine.start(); + + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-3', + service: 'cloudflare-gateway', + credential: { type: 'inline', secret: { type: 'auth-token', token: 'cf' } }, + }); + + const failed = await replyFor(h.sent, 'probe-3'); + if (failed.kind !== 'request.failed') throw new Error('no request.failed for probe-3'); + expect(failed.message).toContain('serves no model list'); + }); + + it('reads a saved account by id so its secret never travels through the client', async () => { + const seen: string[] = []; + relay = await startRelay((url) => { + seen.push(url); + return { status: 200, body: JSON.stringify({ data: [{ id: 'deepseek-v4-pro' }] }) }; + }); + const providerStore = new InMemoryProviderConfigStore(); + providerStore.update({ + accounts: [ + { + id: 'acc_saved', + label: 'Saved', + service: 'deepseek', + credential: { type: 'api-key', key: 'sk-stored' }, + createdAt: 0, + }, + ], + }); + const h = createHarness(providerStore); + await h.engine.start(); + + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-4', + service: 'deepseek', + credential: { type: 'account', accountId: 'acc_saved' }, + }); + + await expect(replyFor(h.sent, 'probe-4')).resolves.toEqual({ + kind: 'config.probe-models.result', + replyTo: 'probe-4', + models: [{ id: 'deepseek-v4-pro' }], + }); + expect(seen).toEqual(['/models']); + }); }); 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..65aa51bf 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -10,7 +10,9 @@ import type { WorkspaceId, } from '@linkcode/schema'; import { MessageIdSchema, textBlock } from '@linkcode/schema'; +import { nullthrow } from 'foxts/guard'; 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 +925,293 @@ 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'); + }); +}); + +describe('a session keeps its own pick', () => { + it('resumes on the run’s account and model after the daemon default moved', async () => { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_first', model: 'model-first' }, + }, + accounts: [ + { + id: 'acc_first', + label: 'First', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-first' }, + models: [{ id: 'model-first' }], + createdAt: 0, + }, + { + id: 'acc_second', + label: 'Second', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-second' }, + models: [{ id: 'model-second' }], + createdAt: 0, + }, + ], + }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + + // Settings moves the agent's default to the other account while the thread sleeps. + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_second', model: 'model-second' }, + }, + }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.model).toBe('model-first'); + expect(resumed.resumedWith?.config?.apiKey).toBe('sk-first'); + expect((await store.load())[0].runs.at(-1)?.accountId).toBe('acc_first'); + }); +}); + +class ResumelessAdapter extends FakeAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities = { + list: false, + read: true, + resume: false, + }; +} + +describe('live account switching', () => { + /** Two accounts an agent can bind, one of them currently bound. */ + function twoAccountStore(): InMemoryProviderConfigStore { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_first', model: 'model-first' }, + }, + accounts: [ + { + id: 'acc_first', + label: 'First', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-first' }, + models: [{ id: 'model-first' }], + createdAt: 0, + }, + { + id: 'acc_second', + label: 'Second', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-second' }, + models: [{ id: 'model-second' }], + createdAt: 0, + }, + ], + }); + return providers; + } + + async function liveSession( + makeAdapter?: () => FakeAdapter, + options: { withTranscript?: boolean } = {}, + ) { + const { withTranscript = true } = options; + const store = new InMemorySessionStore(); + const h = harness(store, makeAdapter, undefined, undefined, undefined, twoAccountStore()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + if (withTranscript) { + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-live') }); + await tick(); + } + return { ...h, store, sessionId }; + } + + function switchTo( + h: Awaited>, + accountId: string, + model: string, + clientReqId = 'switch', + ) { + return h.inject({ + kind: 'agent.input', + clientReqId, + sessionId: h.sessionId, + input: { type: 'set-model', model, accountId }, + }); + } + + it('relaunches on the new account, resuming the transcript under the same id', async () => { + const h = await liveSession(); + + await switchTo(h, 'acc_second', 'model-second'); + + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'switch' }); + // A fresh adapter, resumed from the transcript the old one had established. + expect(h.adapters).toHaveLength(2); + expect(h.adapters[0].stopped).toBe(true); + expect(h.adapters[1].resumedFrom).toBe('native-live'); + expect(h.adapters[1].resumedWith?.model).toBe('model-second'); + expect(h.adapters[1].resumedWith?.config?.apiKey).toBe('sk-second'); + + const runs = (await h.store.load())[0].runs; + expect(runs).toHaveLength(2); + expect(runs[1].accountId).toBe('acc_second'); + + await h.inject({ kind: 'session.list', clientReqId: 'listed' }); + expect(listedSessions(h.sent, 'listed')[0]?.accountId).toBe('acc_second'); + }); + + it('forwards a pick on the session’s own account in place, recording no new run', async () => { + const h = await liveSession(); + + await switchTo(h, 'acc_first', 'model-first'); + + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'switch' }); + expect(h.adapters).toHaveLength(1); + expect(h.adapters[0].sentInputs).toContainEqual({ + type: 'set-model', + model: 'model-first', + accountId: 'acc_first', + }); + expect((await h.store.load())[0].runs).toHaveLength(1); + }); + + it('refuses a switch while a turn is running', async () => { + const h = await liveSession(); + h.adapters[0].emit({ type: 'status', status: 'running' }); + await tick(); + + await switchTo(h, 'acc_second', 'model-second'); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'switch', + code: 'conflict', + message: 'The session is busy; switch accounts once the turn has finished', + }); + expect(h.adapters).toHaveLength(1); + }); + + it('refuses a switch on a session with no provider transcript rather than starting fresh', async () => { + const h = await liveSession(undefined, { withTranscript: false }); + + await switchTo(h, 'acc_second', 'model-second'); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'switch', + code: 'conflict', + message: 'The session has no provider transcript to carry to another account', + }); + expect(h.adapters).toHaveLength(1); + }); + + it('refuses before teardown when the agent cannot resume, leaving the session live', async () => { + const h = await liveSession(() => new ResumelessAdapter()); + + await switchTo(h, 'acc_second', 'model-second'); + + expect(h.sent).toContainEqual({ + kind: 'request.failed', + replyTo: 'switch', + code: 'unsupported', + message: 'claude-code: switching account needs history resume, which it does not support', + }); + // The whole point of checking first: the session it refused to move is still running. + expect(h.adapters).toHaveLength(1); + expect(h.adapters[0].stopped).toBe(false); + }); +}); diff --git a/packages/host/engine/src/__tests__/fixtures/session-harness.ts b/packages/host/engine/src/__tests__/fixtures/session-harness.ts index be9127b0..9226112e 100644 --- a/packages/host/engine/src/__tests__/fixtures/session-harness.ts +++ b/packages/host/engine/src/__tests__/fixtures/session-harness.ts @@ -37,6 +37,9 @@ export class FakeAdapter implements AgentAdapter { startedWith: StartOptions | null = null; resumedFrom: string | null = null; + /** The options a resume was spawned with — a relaunch carries its model/credentials here, not + * through `startedWith`. */ + resumedWith: StartOptions | null = null; stopped = false; readonly sentInputs: AgentInput[] = []; private readonly listeners = new Set<(event: AgentEvent) => void>(); @@ -67,8 +70,9 @@ export class FakeAdapter implements AgentAdapter { }); } - resumeHistory(opts: AgentHistoryResumeOptions): Promise { + resumeHistory(opts: AgentHistoryResumeOptions, startOpts: StartOptions): Promise { this.resumedFrom = opts.historyId; + this.resumedWith = startOpts; return Promise.resolve(); } diff --git a/packages/host/engine/src/__tests__/model-probe.test.ts b/packages/host/engine/src/__tests__/model-probe.test.ts index 5d7ab454..d55b58dc 100644 --- a/packages/host/engine/src/__tests__/model-probe.test.ts +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -1,18 +1,27 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; +import type { ServiceModelList } from '@linkcode/providers'; import type { AccountEndpoint } from '@linkcode/schema'; import { describe, expect, it, vi } from 'vitest'; import type { ModelListRequest } from '../agent/model-probe'; import { modelListHeaders, - modelListUrl, + modelListUrlFromEndpoint, probeEndpointModels, requestModelListAtAddress, resolvePublicEndpoint, } from '../agent/model-probe'; -const anthropic: AccountEndpoint = { baseUrl: 'https://relay.test/', protocol: 'anthropic' }; -const openai: AccountEndpoint = { baseUrl: 'https://relay.test/v1', protocol: 'openai-chat' }; +const anthropicEndpoint: AccountEndpoint = { + baseUrl: 'https://relay.test/', + protocol: 'anthropic', +}; +const openaiEndpoint: AccountEndpoint = { + baseUrl: 'https://relay.test/v1', + protocol: 'openai-chat', +}; +const anthropic: ServiceModelList = { url: 'https://relay.test/v1/models', wire: 'anthropic' }; +const openai: ServiceModelList = { url: 'https://relay.test/v1/models', wire: 'openai' }; const REJECTION_PATTERN = /401.*invalid api key/; const NOT_A_LIST_PATTERN = /did not answer a model list/; const HTTP_PATTERN = /HTTP/; @@ -25,31 +34,42 @@ function jsonResponse(body: unknown): Awaited> { return { status: 200, statusText: 'OK', body: JSON.stringify(body) }; } -describe('endpoint model list addressing', () => { +describe('custom endpoint model list addressing', () => { it('appends /v1 for Anthropic-shaped base URLs and only /models for OpenAI-shaped ones', () => { - expect(modelListUrl(anthropic)).toBe('https://relay.test/v1/models?limit=1000'); - expect(modelListUrl(openai)).toBe('https://relay.test/v1/models'); + // Only custom accounts reach this: catalog services carry an explicit URL instead. + expect(modelListUrlFromEndpoint(anthropicEndpoint)).toBe( + 'https://relay.test/v1/models?limit=1000', + ); + expect(modelListUrlFromEndpoint(openaiEndpoint)).toBe('https://relay.test/v1/models'); }); it('rejects URL components that string-appending could misaddress', () => { expect(() => - modelListUrl({ baseUrl: 'https://relay.test/v1?tenant=x', protocol: 'openai-chat' }), + modelListUrlFromEndpoint({ + baseUrl: 'https://relay.test/v1?tenant=x', + protocol: 'openai-chat', + }), ).toThrow(INVALID_ENDPOINT_PATTERN); expect(() => - modelListUrl({ baseUrl: 'https://user:secret@relay.test/v1', protocol: 'openai-chat' }), + modelListUrlFromEndpoint({ + baseUrl: 'https://user:secret@relay.test/v1', + protocol: 'openai-chat', + }), ).toThrow(INVALID_ENDPOINT_PATTERN); }); - it('authenticates per protocol and credential shape', () => { - expect(modelListHeaders(anthropic, { type: 'api-key', key: 'k' })).toMatchObject({ + it('authenticates per wire and credential shape', () => { + expect(modelListHeaders('anthropic', { type: 'api-key', key: 'k' })).toMatchObject({ 'x-api-key': 'k', 'anthropic-version': '2023-06-01', }); - expect(modelListHeaders(anthropic, { type: 'auth-token', token: 't' })).toMatchObject({ + expect(modelListHeaders('anthropic', { type: 'auth-token', token: 't' })).toMatchObject({ authorization: 'Bearer t', 'anthropic-version': '2023-06-01', }); - expect(modelListHeaders(openai, { type: 'api-key', key: 'k' }).authorization).toBe('Bearer k'); + expect(modelListHeaders('openai', { type: 'api-key', key: 'k' }).authorization).toBe( + 'Bearer k', + ); }); }); diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index 363672d0..ce49777f 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -10,13 +10,12 @@ describe('applyProviderDefaults', () => { expect(applyProviderDefaults(baseOpts, providers).options).toBe(baseOpts); }); - it('fills the default model only when the client did not specify one', () => { - const providers: ProvidersConfig = { codex: { enabled: true, defaultModel: 'o4-mini' } }; + it('fills the persisted pick only when the client did not specify one', () => { + const providers: ProvidersConfig = { codex: { enabled: true, model: 'o4-mini' } }; expect(applyProviderDefaults(baseOpts, providers).options.model).toBe('o4-mini'); expect(applyProviderDefaults({ ...baseOpts, model: 'gpt-4o' }, providers).options.model).toBe( 'gpt-4o', ); - expect(applyProviderDefaults({ ...baseOpts, model: null }, providers).options.model).toBeNull(); }); it('injects the api key into config, preserving existing config keys', () => { @@ -27,7 +26,7 @@ describe('applyProviderDefaults', () => { it('does not mutate the input options', () => { const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'o4-mini', apiKey: 'sk' }, + codex: { enabled: true, model: 'o4-mini', apiKey: 'sk' }, }; const opts: StartOptions = { kind: 'codex', cwd: '/repo' }; applyProviderDefaults(opts, providers); @@ -46,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', }); }); @@ -77,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', @@ -108,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', @@ -120,13 +122,16 @@ describe('applyProviderDefaults account pool', () => { expect(forOpencode.options.config).toMatchObject({ knownProvider: 'openai' }); }); - it('prefers the account model over the provider default model', () => { + it('takes the model from the agent, never from the bound account', () => { + // The account holds the set the pick came from; only `providers[kind].model` names the pick. const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'o4-mini', activeAccountId: 'acc_1' }, + codex: { enabled: true, model: 'o4-mini', activeAccountId: 'acc_1' }, }; expect( - applyProviderDefaults(baseOpts, providers, [{ ...account, model: 'gpt-5' }]).options.model, - ).toBe('gpt-5'); + applyProviderDefaults(baseOpts, providers, [ + { ...account, models: [{ id: 'gpt-5' }, { id: 'o4-mini' }] }, + ]).options.model, + ).toBe('o4-mini'); }); it('falls back to the legacy apiKey when the bound account id is stale', () => { @@ -146,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', + }); }); }); @@ -160,7 +168,7 @@ describe('accountBinding', () => { it('preserves unrelated providers and accounts while binding the selected agent', () => { const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'gpt-5' }, + codex: { enabled: true, model: 'gpt-5' }, opencode: { enabled: false, activeAccountId: 'acc_2' }, }; const other: Account = { @@ -172,7 +180,7 @@ describe('accountBinding', () => { expect(accountBinding(providers, [other], 'codex', account)).toEqual({ providers: { - codex: { enabled: true, defaultModel: 'gpt-5', activeAccountId: 'acc_1' }, + codex: { enabled: true, model: 'gpt-5', activeAccountId: 'acc_1' }, opencode: { enabled: false, activeAccountId: 'acc_2' }, }, accounts: [other, account], diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 74e7114f..e4639024 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -160,6 +160,11 @@ describe('account binding at session start', () => { function storeWith(account: Account, agent: AgentKind): InMemoryProviderConfigStore { const store = new InMemoryProviderConfigStore(); store.createAndBindAccount(agent, account); + // A bound agent without a picked model refuses to start; these cases are about endpoints. + const providers = store.get(); + store.update({ + providers: { ...providers, [agent]: { ...providers[agent], model: 'picked-model' } }, + }); return store; } @@ -171,6 +176,22 @@ describe('account binding at session start', () => { ...overrides, }); + it('refuses a bound agent with no picked model, but lets an unbound one resolve its own', async () => { + const store = new InMemoryProviderConfigStore(); + store.createAndBindAccount('codex', account({ service: 'openai-api' })); + const bound = new SessionStartOptionsResolver(store, undefined); + await expect( + Effect.runPromise(bound.resolve({ kind: 'codex', cwd: '/repo' }, SESSION)), + ).rejects.toThrow('No model selected for codex'); + + // Nothing bound: the agent still runs on whatever it resolves for itself. + const unbound = new SessionStartOptionsResolver(new InMemoryProviderConfigStore(), undefined); + const { options } = await Effect.runPromise( + unbound.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + expect(options.model).toBeUndefined(); + }); + it('refuses the session when the bound account has no endpoint the agent speaks', async () => { const anthropicOnly = account({ endpoint: { baseUrl: 'https://api.anthropic.com', protocol: 'anthropic' }, diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts index 02a3a859..79411e66 100644 --- a/packages/host/engine/src/agent/model-probe.ts +++ b/packages/host/engine/src/agent/model-probe.ts @@ -3,6 +3,7 @@ import { lookup as dnsLookup } from 'node:dns/promises'; import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { BlockList, isIP } from 'node:net'; +import type { ServiceModelList } from '@linkcode/providers'; import type { AccountEndpoint, AccountModel, AccountSecret } from '@linkcode/schema'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { z } from 'zod'; @@ -88,7 +89,9 @@ export type ModelListRequest = ( ) => Promise; export type ModelProbe = typeof probeEndpointModels; -export function modelListUrl(endpoint: AccountEndpoint): string { +/** A custom account names its own endpoint, so its list path can only be guessed from the protocol. + * Catalog services never come through here — they carry an explicit URL (`@linkcode/providers`). */ +export function modelListUrlFromEndpoint(endpoint: AccountEndpoint): string { const url = new URL(endpoint.baseUrl); if (url.username || url.password || url.search || url.hash) { throw new Error('Model detection endpoint cannot contain credentials, a query, or a fragment'); @@ -100,11 +103,11 @@ export function modelListUrl(endpoint: AccountEndpoint): string { } export function modelListHeaders( - endpoint: AccountEndpoint, + wire: ServiceModelList['wire'], secret: AccountSecret, ): Record { const headers: Record = { accept: 'application/json' }; - if (endpoint.protocol === 'anthropic') { + if (wire === 'anthropic') { headers['anthropic-version'] = ANTHROPIC_VERSION; // An Anthropic-shaped gateway authenticates with whichever header its own credential is. if (secret.type === 'api-key') headers['x-api-key'] = secret.key; @@ -246,21 +249,21 @@ export function requestModelListAtAddress( }); } -/** Model ids the endpoint advertises, deduped, in the order it listed them. Rejects with a +/** Model ids the source advertises, deduped, in the order it listed them. Rejects with a * user-facing message (status + the vendor's own reason) — the dialog shows it verbatim. */ export async function probeEndpointModels( - endpoint: AccountEndpoint, + source: ServiceModelList, secret: AccountSecret, request: ModelListRequest = requestPublicModelList, ): Promise { - const url = new URL(modelListUrl(endpoint)); + const url = new URL(source.url); const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(new Error('Model detection timed out')); }, PROBE_TIMEOUT_MS); let response: ModelListResponse; try { - response = await request(url, modelListHeaders(endpoint, secret), controller.signal); + response = await request(url, modelListHeaders(source.wire, secret), controller.signal); } finally { clearTimeout(timeout); } diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index b2872f02..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 @@ -121,8 +131,8 @@ export interface AppliedProviderDefaults { } /** Apply the stored config to a session's StartOptions: resolve the bound account (or legacy - * per-agent api key) and inject the credential/endpoint bundle into `config`; a resolved account's - * `model` outranks the provider default. Returns a new object; never mutates the input. */ + * per-agent api key), inject the credential/endpoint bundle into `config`, and fall back to the + * agent's persisted model pick. Returns a new object; never mutates the input. */ export function applyProviderDefaults( opts: StartOptions, providers: ProvidersConfig, @@ -133,10 +143,8 @@ export function applyProviderDefaults( if (!config && !account) return { options: opts }; const next: StartOptions = { ...opts }; - if (next.model === undefined) { - const model = account?.model ?? config?.defaultModel; - if (model !== undefined) next.model = model; - } + // The account holds the models the user may pick from; the pick itself is per agent. + if (next.model === undefined && config?.model !== undefined) next.model = config.model; if (account) { const resolved = accountConfigBundle(account, opts.kind); if ('unavailable' in resolved) return { options: next, unavailable: resolved.unavailable }; diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 76a1d223..f5deb272 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -1,5 +1,6 @@ import type { AdapterFactory } from '@linkcode/agent-adapter'; -import type { WirePayload } from '@linkcode/schema'; +import { modelListSource } from '@linkcode/providers'; +import type { AccountSecret, WirePayload } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; @@ -150,7 +151,11 @@ export class AgentRequestHandler { payload.clientReqId, Effect.tryPromise({ try: async () => { - const models = await this.probeModels(payload.endpoint, payload.secret); + const source = modelListSource(payload.service); + if (!source) { + throw new Error(`${payload.service} serves no model list`); + } + const models = await this.probeModels(source, this.probeSecret(payload.credential)); this.transport.send( createWireMessage({ kind: 'config.probe-models.result', @@ -205,6 +210,22 @@ export class AgentRequestHandler { return Effect.void; } } + + /** The secret to probe with. A saved account is named by id rather than shipping its secret back + * out to the client and in again; an oauth login holds none, so it cannot be probed. */ + private probeSecret( + credential: Extract['credential'], + ): AccountSecret { + if (credential.type === 'inline') return credential.secret; + const account = this.providers + .getAccounts() + .find((candidate) => candidate.id === credential.accountId); + if (!account) throw new Error('Account not found'); + if (account.credential.type === 'oauth') { + throw new Error('A subscription login holds no secret to read the model list with'); + } + return account.credential; + } } function updateProviderConfig( diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 67eb6d78..ae06ff2e 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -1,5 +1,7 @@ +import type { AgentAdapter } from '@linkcode/agent-adapter'; import type { AgentHistoryId, + AgentInput, AgentKind, ContentBlock, MessageId, @@ -13,6 +15,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'; @@ -22,7 +25,7 @@ import type { HistoryService } from './history-service'; import { decodeLiveBranchCursor } from './live-session'; import type { SessionOrchestrator } from './orchestrator'; import type { SessionRecordRegistry } from './session-record-registry'; -import type { SessionStartOptionsResolver } from './start-options-resolver'; +import type { ResolvedStartOptions, SessionStartOptionsResolver } from './start-options-resolver'; type RunEffect = (effect: Effect.Effect, options?: Effect.RunOptions) => Promise; @@ -101,7 +104,7 @@ export class SessionLifecycleService { createdVia: resolved.createdVia, createdAt: now, updatedAt: now, - runs: [{ startedAt: now }], + runs: [{ startedAt: now, ...runOf(resolved) }], }; yield* sessions.startLive( replyTo, @@ -176,7 +179,7 @@ export class SessionLifecycleService { origin: { type: 'imported', historyId, importedAt: now }, createdAt: now, updatedAt: now, - runs: [{ historyId, startedAt: now }], + runs: [{ historyId, startedAt: now, ...runOf(startOptions) }], }; yield* sessions.startLive( replyTo, @@ -235,12 +238,11 @@ export class SessionLifecycleService { ); } - const { history, records, sessions, startOptions: resolver } = this; + const { history, sessions } = this; + const resolveForRecord = this.resolveForRecord.bind(this); + const launchRun = this.launchRun.bind(this); return Effect.gen(function* () { - const { options: startOptions, warnings } = yield* resolver.resolve( - { kind: source.kind, cwd: source.cwd }, - sourceSessionId, - ); + const resolved = yield* resolveForRecord(source); yield* sessions.stopForReplacement(sourceSessionId); const resolvedBranchCursor = liveCursor.type === 'live' @@ -252,17 +254,16 @@ export class SessionLifecycleService { liveCursor.contentFingerprint, ) : branchCursor; - records.beginRun(sourceSessionId); - yield* sessions.startLive( + yield* launchRun( replyTo, source, + resolved, (adapter) => history.branch( adapter, { historyId: sourceHistoryId, cursor: resolvedBranchCursor }, - startOptions, + resolved.options, ), - warnings, { initialInput: { type: 'prompt', content }, registerRecord: false, @@ -298,13 +299,13 @@ export class SessionLifecycleService { // A never-prompted session has no provider transcript to resume from (the adapter only mints one // on the first prompt); waking it is a fresh start under the same LinkCode id. const historyId = this.records.historyId(sessionId); - const { history, sessions, startOptions: resolver, workspaces, worktrees } = this; + const { workspaces, worktrees } = this; + const resolveForRecord = this.resolveForRecord.bind(this); + const launchRun = this.launchRun.bind(this); + const resumeStrategy = this.resumeStrategy.bind(this); return Effect.gen(function* () { yield* worktrees.verifyResume(sessionId); - const { options: startOptions, warnings } = yield* resolver.resolve( - { kind: record.kind, cwd: record.cwd }, - sessionId, - ); + const resolved = yield* resolveForRecord(record); // Register before starting so a persistence failure cannot follow a successful // `session.started` reply with a contradictory request failure. const worktree = worktrees.get(sessionId); @@ -314,21 +315,149 @@ export class SessionLifecycleService { } else if (record.cwd) { yield* workspaceTouch(workspaces, record.cwd); } - record.runs.push({ historyId, startedAt: Date.now() }); - yield* sessions.startLive( - replyTo, + yield* launchRun(replyTo, record, resolved, resumeStrategy(historyId, resolved.options), { + historyId, + }); + }); + }), + ); + } + + /** + * Point a live session at a model belonging to `accountId`. Credentials and base URL are injected + * once at spawn, so a cross-account switch cannot happen in place: it is a relaunch under the same + * id that resumes the transcript. A switch within the session's own account stays in place, which + * is why the error channel is the adapter's untyped one rather than {@link EngineFailure}. + */ + switchModel( + sessionId: SessionId, + model: string, + accountId: string, + ): Effect.Effect { + return this.sessionSemaphore(sessionId).withPermit( + Effect.suspend(() => { + const record = this.records.get(sessionId); + if (!record) { + return Effect.fail( + new RequestError({ code: 'not_found', message: `Unknown session: ${sessionId}` }), + ); + } + if (!this.sessions.has(sessionId)) { + return Effect.fail( + new RequestError({ + code: 'conflict', + message: `Session is not running: ${sessionId}`, + }), + ); + } + if (this.records.accountId(sessionId) === accountId) { + return this.sessions.sendInput(sessionId, { type: 'set-model', model, accountId }); + } + if (this.sessions.isBusy(sessionId)) { + return Effect.fail( + new RequestError({ + code: 'conflict', + message: 'The session is busy; switch accounts once the turn has finished', + }), + ); + } + // Relaunching without a transcript would silently start a fresh conversation in place of + // the one on screen. Losing the thread is worse than refusing the switch. + const historyId = this.records.historyId(sessionId); + if (historyId === undefined) { + return Effect.fail( + new RequestError({ + code: 'conflict', + message: 'The session has no provider transcript to carry to another account', + }), + ); + } + // Asked before the teardown below: a refusal from `history.resume` would arrive with the + // old adapter already gone. + if (this.sessions.historyCapabilities(sessionId)?.resume !== true) { + return Effect.fail( + new RequestError({ + code: 'unsupported', + message: `${record.kind}: switching account needs history resume, which it does not support`, + }), + ); + } + + const { sessions } = this; + const resolveForRecord = this.resolveForRecord.bind(this); + const launchRun = this.launchRun.bind(this); + const resumeStrategy = this.resumeStrategy.bind(this); + return Effect.gen(function* () { + const resolved = yield* resolveForRecord(record, { model, config: { accountId } }); + yield* sessions.stopForReplacement(sessionId); + yield* launchRun( + undefined, record, - (adapter) => - historyId === undefined - ? sessions.startAdapter(adapter, startOptions) - : history.resume(adapter, historyId, startOptions), - warnings, + resolved, + resumeStrategy(historyId, resolved.options), + { historyId, registerRecord: false }, ); }); }), ); } + /** + * Resolve the options an existing record relaunches under. Absent an explicit `override`, the + * thread's own last run supplies the model and account: the daemon's configured default answers + * for new and unpinned sessions, and adopting it here would silently move a running thread to + * whatever Settings now says. + */ + private resolveForRecord( + record: SessionRecord, + override?: Pick, + ): Effect.Effect { + const pinned = override ?? this.records.pinnedOptions(record.sessionId); + return this.startOptions.resolve( + { kind: record.kind, cwd: record.cwd, ...pinned }, + record.sessionId, + ); + } + + /** Record the run this launch begins, then bind the record to a fresh adapter. Every relaunch of + * an existing record goes through here, so `runs` has exactly one writer. */ + private launchRun( + replyTo: string | undefined, + record: SessionRecord, + resolved: ResolvedStartOptions, + startAdapter: (adapter: AgentAdapter) => Effect.Effect, + options: { + historyId?: AgentHistoryId; + initialInput?: AgentInput; + registerRecord?: boolean; + rewindMessageId?: MessageId; + } = {}, + ): Effect.Effect { + const { historyId, ...startOptions } = options; + return Effect.suspend(() => { + this.records.beginRun(record.sessionId, { ...runOf(resolved.options), historyId }); + return this.sessions.startLive( + replyTo, + record, + startAdapter, + resolved.warnings, + startOptions, + ); + }); + } + + /** Wake an adapter onto an existing transcript, or start it fresh when there is none to resume. */ + private resumeStrategy( + historyId: AgentHistoryId | undefined, + options: StartOptions, + ): (adapter: AgentAdapter) => Effect.Effect { + const { history, sessions } = this; + return (adapter) => + historyId === undefined + ? sessions.startAdapter(adapter, options) + : history.resume(adapter, historyId, options); + } + private createAutomationSession(options: { kind: AgentKind; cwd: string; @@ -353,7 +482,7 @@ export class SessionLifecycleService { automation: options.automation, createdAt: now, updatedAt: now, - runs: [{ startedAt: now }], + runs: [{ startedAt: now, ...runOf(startOptions) }], }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); yield* sessions.startLive(undefined, record, (adapter) => @@ -425,3 +554,13 @@ function workspaceRegisterWorktree( }), }); } + +/** What a run resolved to, spread into a `SessionRun`. Unresolved fields stay absent rather than + * writing `undefined` into the record, and are what a later relaunch reads back to stay put. */ +function runOf(opts: StartOptions): { accountId?: string; model?: string } { + const accountId = resolvedAccountId(opts); + return { + ...(accountId !== undefined && { accountId }), + ...(opts.model !== undefined && { model: opts.model }), + }; +} diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 813e172c..24b093fd 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -2,6 +2,7 @@ import type { AdapterFactory, AgentAdapter, BrowserToolsetFactory } from '@linkc import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentEvent, + AgentHistoryCapabilities, AgentInput, ContentBlock, McpWarning, @@ -73,6 +74,12 @@ export class SessionOrchestrator { return session !== undefined && (session.turnInputActive || session.status === 'running'); } + /** The running adapter's history capabilities — asked of the live instance rather than a fresh + * one, so a caller about to tear it down learns what *this* session can do. */ + historyCapabilities(sessionId: SessionId): AgentHistoryCapabilities | undefined { + return this.sessions.get(sessionId)?.adapter.historyCapabilities; + } + replay(sessionId: SessionId): void { const session = this.sessions.get(sessionId); if (session) this.events.broadcast(sessionId, session.replay()); diff --git a/packages/host/engine/src/session/request-handler.ts b/packages/host/engine/src/session/request-handler.ts index 21725d99..49c71c81 100644 --- a/packages/host/engine/src/session/request-handler.ts +++ b/packages/host/engine/src/session/request-handler.ts @@ -38,15 +38,21 @@ export class SessionRequestHandler { payload.clientReqId, this.lifecycle.start(payload.clientReqId, payload.opts), ); - case 'agent.input': + case 'agent.input': { + const { input, sessionId } = payload; + // A model pick naming an account can mean a relaunch on that account, which lifecycle owns. + // Both paths answer with the same plain ack, so the client's contract is unchanged. + const applied = + input.type === 'set-model' && input.accountId !== undefined + ? this.lifecycle.switchModel(sessionId, input.model, input.accountId) + : this.sessions.sendInput(sessionId, input); return this.responder.reply( payload.clientReqId, - this.sessions - .sendInput(payload.sessionId, payload.input) - .pipe( - Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), - ), + applied.pipe( + Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), + ), ); + } case 'session.stop': return this.responder.reply( payload.clientReqId, diff --git a/packages/host/engine/src/session/session-record-registry.ts b/packages/host/engine/src/session/session-record-registry.ts index 39fa3c9f..ed4ceebb 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -6,6 +6,8 @@ import type { SessionId, SessionInfo, SessionRecord, + SessionRun, + StartOptions, } from '@linkcode/schema'; import { Effect } from 'effect'; import { nullthrow } from 'foxts/guard'; @@ -82,6 +84,7 @@ export class SessionRecordRegistry { createdVia: record.createdVia, automation: record.automation, historyId: latestHistoryId(record), + accountId: latestAccountId(record), })); } @@ -137,10 +140,12 @@ export class SessionRecordRegistry { this.persist(record); } - beginRun(sessionId: SessionId): void { + /** The single writer for a relaunch's run entry. `historyId` is known up front only when the + * relaunch resumes a transcript; a fresh one gets it later via {@link bindHistoryId}. */ + beginRun(sessionId: SessionId, run: Omit = {}): void { const record = this.records.get(sessionId); if (!record) return; - record.runs.push({ startedAt: Date.now() }); + record.runs.push({ startedAt: Date.now(), ...definedFields(run) }); this.persist(record); } @@ -171,6 +176,29 @@ export class SessionRecordRegistry { return record ? latestHistoryId(record) : undefined; } + /** The account the newest run resolved to — what a live session is actually talking to. */ + accountId(sessionId: SessionId): string | undefined { + const record = this.records.get(sessionId); + return record ? latestAccountId(record) : undefined; + } + + /** + * What the newest run resolved to, shaped as a start-options override. A relaunch applies this so + * the thread keeps its own model and account; the daemon's configured default answers for new and + * unpinned sessions only, and may have moved since this one started. + */ + pinnedOptions(sessionId: SessionId): Pick | undefined { + const record = this.records.get(sessionId); + if (!record) return undefined; + const accountId = latestAccountId(record); + const model = latestModel(record); + if (accountId === undefined && model === undefined) return undefined; + return { + ...(model !== undefined && { model }), + ...(accountId !== undefined && { config: { accountId } }), + }; + } + /** The in-memory record is authoritative while running; persistence is best-effort. */ private persist(record: SessionRecord): void { record.updatedAt = Date.now(); @@ -210,6 +238,31 @@ function storeFailure(operation: string, publicMessage: string, cause: unknown): return new OperationError({ subsystem: 'store', operation, publicMessage, cause }); } +/** The account the newest run resolved to. Older runs may name a different one — a rebind between + * runs is legitimate — so only the latest describes what a live session is actually talking to. */ +function latestAccountId(record: SessionRecord): string | undefined { + for (let index = record.runs.length - 1; index >= 0; index -= 1) { + const accountId = record.runs[index].accountId; + if (accountId !== undefined) return accountId; + } + return undefined; +} + +function latestModel(record: SessionRecord): string | undefined { + for (let index = record.runs.length - 1; index >= 0; index -= 1) { + const model = record.runs[index].model; + if (model !== undefined) return model; + } + return undefined; +} + +/** Spreading an explicit `undefined` would write the key into the persisted record. */ +function definedFields(fields: T): Partial { + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ) as Partial; +} + function latestHistoryId(record: SessionRecord): AgentHistoryId | undefined { for (let index = record.runs.length - 1; index >= 0; index -= 1) { const historyId = record.runs[index].historyId; diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 91566d02..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'; @@ -32,11 +32,12 @@ export class SessionStartOptionsResolver { options: StartOptions, sessionId: SessionId, ): Effect.Effect { - const defaults = applyProviderDefaults( - options, - this.providers.get(), - this.providers.getAccounts(), - ); + const providers = this.providers.get(); + const defaults = applyProviderDefaults(options, providers, this.providers.getAccounts()); + // Whether an account actually resolved — the caller's pin or, failing that, the agent's + // 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); @@ -47,7 +48,17 @@ 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 (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', + message: `No model selected for ${options.kind}`, }), ); } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 9ddcecb9..184eb6d1 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -333,6 +333,7 @@ export const en = { effortDefault: 'Default', effortShort: 'Def.', model: 'Model', + modelSwitchRestarts: 'Switching account restarts this thread and resumes it', effort: 'Effort', resetToDefault: 'Reset to default', add: 'Add', @@ -348,7 +349,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 +769,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 +868,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 +881,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: @@ -1025,16 +1026,29 @@ 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', oauthDelegate: 'Follows the {agent} CLI login', connections: 'Connected agents', connectionsEnabled: '{bound} / {available} enabled', - boundNote: 'Active provider for this agent', - boundElsewhere: 'Currently provided by “{label}”', - noProvider: 'No provider — follows the CLI login', + accountDefault: 'Shown in the model menu · used when no account is named', + accountEnabled: 'Shown in the model menu · “{label}” used when none is named', + accountEnabledNoDefault: 'Shown in the model menu · follows the CLI login when none is named', + accountDisabled: 'Hidden from this agent’s model menu', + setDefaultAccount: 'Use as default account', + clearDefaultAccount: 'Stop using as default account', translateBadge: 'Translated', translateNote: 'A local gateway translates Anthropic wire to OpenAI Chat', unavailableOauth: 'Only connects to {agent}', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6978c123..a6002ec6 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -323,6 +323,7 @@ export const zhCN = { effortDefault: '默认', effortShort: '默认', model: '模型', + modelSwitchRestarts: '切换账号将重启并恢复此对话', effort: '推理强度', resetToDefault: '恢复默认设置', add: '添加', @@ -338,7 +339,7 @@ export const zhCN = { attachmentUnsupportedAgent: '当前 agent 暂不支持图片附件', attachmentReadFailed: '读取文件失败', approvalTitle: '如何审批 {agent} 的操作?', - provider: '提供方', + harness: '编码助手', }, mode: { label: '模式', @@ -753,9 +754,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 +851,7 @@ export const zhCN = { }, historyImport: { portalLabel: '导入聊天历史', - panelTitle: '从 {provider} 导入聊天历史', + panelTitle: '从 {harness} 导入聊天历史', conversationCount: '{count} 条对话', refresh: '刷新', sortLabel: '排序方式', @@ -863,7 +864,7 @@ export const zhCN = { importedBadge: '已导入', open: '打开', emptyTitle: '暂无历史对话', - emptyHint: '该提供方在本机还没有历史对话。', + emptyHint: '该编码助手在本机还没有历史对话。', loadFailedTitle: '无法加载历史记录', retry: '重试', showingLatest: '仅显示最近 {count} 条对话', @@ -999,16 +1000,29 @@ export const zhCN = { copySecret: '复制', endpoint: '端点', protocols: '协议形态', - accountModel: '默认模型', + accountModel: '可用模型', + models: { + label: '可用模型', + hint: '获取该服务的模型列表后勾选;只有勾选的模型会出现在输入框的模型选择里。', + hintUnlistable: '该端点不提供模型列表,请手动填写模型 ID。', + refresh: '获取列表', + fetchFailed: '获取模型列表失败', + secretFirst: '请先填写密钥,再获取模型列表', + add: '添加', + addPlaceholder: '手动添加模型 ID', + }, loginState: '登录状态', loggedIn: '已登录', loggedOut: '未登录', oauthDelegate: '跟随 {agent} CLI 登录', connections: '接入的智能体', connectionsEnabled: '{bound} / {available} 已启用', - boundNote: '此账号为当前 Provider', - boundElsewhere: '当前由「{label}」提供', - noProvider: '当前无 Provider · 跟随 CLI 登录', + accountDefault: '在模型菜单中显示 · 未指定账号时使用此账号', + accountEnabled: '在模型菜单中显示 · 未指定时使用「{label}」', + accountEnabledNoDefault: '在模型菜单中显示 · 未指定账号时跟随 CLI 登录', + accountDisabled: '不在此智能体的模型菜单中显示', + setDefaultAccount: '设为默认账号', + clearDefaultAccount: '取消默认账号', translateBadge: '经转换', translateNote: '本地网关将 Anthropic 协议转为 OpenAI Chat', unavailableOauth: '仅可接入 {agent}', diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index 58ed7e47..68d1d1df 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { effortOptionsForModel } from '../shell/agent-efforts'; -import { AGENT_MODEL_OPTIONS, groupModelsByProvider, resolveModel } from '../shell/agent-models'; +import { + AGENT_MODEL_OPTIONS, + groupModelsByProvider, + resolveModel, + switchesAccount, +} from '../shell/agent-models'; const claude = AGENT_MODEL_OPTIONS['claude-code']; const codex = AGENT_MODEL_OPTIONS.codex; @@ -26,6 +31,21 @@ describe('resolveModel', () => { }); }); +describe('switchesAccount', () => { + const onSecond = { id: 'model-a', label: 'A', accountId: 'acc_second' }; + + it('flags an entry from an account the session is not running on', () => { + expect(switchesAccount(onSecond, 'acc_first')).toBe(true); + expect(switchesAccount(onSecond, 'acc_second')).toBe(false); + }); + + it('stays false when either side has no account to compare', () => { + // A draft has no running account, and a curated-table entry belongs to none. + expect(switchesAccount(onSecond, undefined)).toBe(false); + expect(switchesAccount({ id: 'model-a', label: 'A' }, 'acc_first')).toBe(false); + }); +}); + describe('groupModelsByProvider', () => { const multiProvider = [ { id: 'opencode/hy3', label: 'Hy3', description: 'OpenCode Zen' }, 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 5f7cb78f..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 @@ -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/; @@ -54,8 +56,10 @@ 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/; 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/; @@ -118,7 +122,7 @@ describe('NewSessionSurface', () => { render( { { { { { render( { render( { render( { render( { render( { { { { render( { render( { render( { 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(); @@ -608,7 +616,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} preferredEfforts={{ 'claude-code': 'medium' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -635,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={[]} @@ -660,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: [], @@ -686,15 +694,16 @@ 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 () => { @@ -721,8 +731,8 @@ 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 provider defaults', async () => { + it('can return remembered model and effort choices to the configured ones', async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( @@ -776,9 +783,8 @@ 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', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -789,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(); @@ -799,19 +810,126 @@ describe('NewSessionSurface', () => { typeInComposer('use provider defaults'); await pressInComposer('Enter'); await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); - expect(onSubmit.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ model: null, effort: null }), + const submitted = onSubmit.mock.calls[0]?.[0]; + expect(submitted).toEqual(expect.objectContaining({ effort: null })); + // No "back to the provider's own model" tier any more: an absent model defers to the + // agent's persisted pick, which the daemon resolves. + 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( + , + ); + + 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( { 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)} @@ -908,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)} @@ -954,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)} @@ -977,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)} @@ -997,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)} @@ -1009,29 +1127,29 @@ 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( , ); + // 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 () => { @@ -1053,7 +1171,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)} @@ -1102,7 +1220,7 @@ describe('NewSessionSurface', () => { render( ; } +/** + * 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}`; +} + +/** Whether picking this entry leaves the account a session is currently running on. Credentials and + * base URL are injected once at spawn, so such a pick relaunches the agent rather than rebinding it + * in place. Unknown accounts on either side mean the question doesn't apply. */ +export function switchesAccount( + option: ModelOption, + currentAccountId: string | undefined, +): boolean { + return ( + currentAccountId !== undefined && + option.accountId !== undefined && + option.accountId !== currentAccountId + ); +} + /** 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,26 +70,23 @@ 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}-`)) ); } -/** Verified provider defaults used before a session exists to reflect its served model. A saved - * account/provider default supplied by the workbench takes precedence over these values. */ -export const AGENT_DEFAULT_MODELS: Readonly>> = { - '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/composer-controls.tsx b/packages/presentation/ui/src/shell/composer-controls.tsx index 7860235a..2462bf7e 100644 --- a/packages/presentation/ui/src/shell/composer-controls.tsx +++ b/packages/presentation/ui/src/shell/composer-controls.tsx @@ -30,7 +30,12 @@ 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, + switchesAccount, +} from './agent-models'; import type { AgentRuntimeCue, AgentRuntimeCues } from './agent-onboarding-card'; // Linear lookup: the policy/effort lists are a handful of entries at most. @@ -145,7 +150,7 @@ export function ApprovalPolicyMenu({ ); } -// Known workflow-mode glyphs; unknown provider modes fall back to a generic one. +// Known workflow-mode glyphs; unknown harness modes fall back to a generic one. const MODE_CHIP_ICONS: Record = { plan: ListTodoIcon, goal: TargetIcon, @@ -182,7 +187,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; @@ -211,57 +216,91 @@ function RuntimeCueBadge({ cue }: { cue?: AgentRuntimeCue }): React.ReactNode { ); } +/** One model entry. `description` is the account label the flat list needs to disambiguate; a + * provider submenu already names it and passes none. */ +function ModelMenuItem({ + option, + description, + restartHint, +}: { + option: ModelOption; + description?: string; + restartHint?: string; +}): React.ReactNode { + return ( + + + {option.label} + {description ? {description} : null} + {restartHint ? {restartHint} : null} + + + ); +} + export function ModelSelectorMenu({ disabled, - provider, - selectableProviders, + harness, + selectableHarnesses, runtimeCues, modelOptions, effortOptions, selectedModelId, + selectedAccountId, + accountSwitchRestarts = false, selectedEffortId, onSelectModel, 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[]; selectedModelId: string | null; + /** Disambiguates the selection when the list spans accounts serving the same model id. */ + selectedAccountId?: string; + /** Live sessions only: credentials are injected at spawn, so leaving `selectedAccountId` relaunches + * the agent. Entries from another account say so; a draft has nothing to restart. */ + accountSwitchRestarts?: boolean; 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. */ + /** 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); + const selectedModel = resolveModel(modelOptions, selectedModelId, selectedAccountId); const providerGroups = groupModelsByProvider(modelOptions); + const restartHintFor = (option: ModelOption): string | undefined => + accountSwitchRestarts && switchesAccount(option, selectedAccountId) + ? t('modelSwitchRestarts') + : undefined; 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}`); @@ -272,7 +311,7 @@ export function ModelSelectorMenu({ disabled={disabled} render={ onSetBinding(binding.kind, checked ? accountId : undefined)} + onCheckedChange={(checked) => onSetAccountEnabled(agent.kind, checked)} /> ); diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 5b3dc521..6d908c00 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -11,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'; @@ -57,8 +58,9 @@ export interface ShellFrameProps agentCatalogs?: AgentStartCatalogs; /** Effective daemon-configured default models for new sessions; null while unresolved. */ newSessionDefaultModels: Readonly>> | null; - /** Last model accepted by LinkCode per provider, submitted as a new-session override. */ - newSessionPreferredModels: Readonly>>; + /** The models each agent may run on, picked on its bound account. An agent absent here has no + * account bound and keeps falling back to whatever its adapter or the curated table advertises. */ + accountModels: Readonly>> | null; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; newSessionPreferredBranches: Readonly>; @@ -131,7 +133,7 @@ export function ShellFrame({ attachmentSupport, agentCatalogs, newSessionDefaultModels, - newSessionPreferredModels, + accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -222,7 +224,7 @@ export function ShellFrame({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} - preferredModels={newSessionPreferredModels} + accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} @@ -243,6 +245,8 @@ export function ShellFrame({ composer={conversationComposer} agentKind={active?.kind} agentLabel={active ? active.kind : undefined} + accountModels={active ? accountModels?.[active.kind] : undefined} + accountId={active?.accountId} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} disabled={!active || active.status === 'stopped'} isRunning={isRunning}