diff --git a/AGENTS.md b/AGENTS.md index ee0d1bebb..e9f69d6b4 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/AGENTS.md b/apps/daemon/AGENTS.md index 805fcdfaf..37525e949 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -86,6 +86,11 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr tables in `src/db/schema.ts`). The zod `SessionRecordSchema` is the contract: rows are re-validated through it on load; the table is just storage. After editing `src/db/schema.ts`, run `pnpm -F @linkcode/daemon exec drizzle-kit generate` and commit `drizzle/` — migrations run at boot. + - **A record field with no column is dropped in silence.** The store enumerates columns on write and + rebuilds the record on read, so an `.optional()` field added to the schema alone survives until the + next boot and then parses cleanly as `undefined`. Adding one is three edits (column, write, read) + plus a migration, and the engine's `InMemorySessionStore` cannot catch a missed one — only a + round-trip through this store can (`__tests__/session-store.test.ts`). - **`runtime.json`** — endpoint discovery (`{name,pid,startedAt,listeners:[{type,url}]}`), written `0600` only AFTER every listener binds and removed on graceful `SIGINT`/`SIGTERM` shutdown. diff --git a/apps/daemon/drizzle/0009_add_session_run_pin.sql b/apps/daemon/drizzle/0009_add_session_run_pin.sql new file mode 100644 index 000000000..cb28147bb --- /dev/null +++ b/apps/daemon/drizzle/0009_add_session_run_pin.sql @@ -0,0 +1,4 @@ +ALTER TABLE `session_runs` ADD `account_id` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `model` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `effort` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `approval_policy_id` text; \ No newline at end of file diff --git a/apps/daemon/drizzle/meta/0009_snapshot.json b/apps/daemon/drizzle/meta/0009_snapshot.json new file mode 100644 index 000000000..1ae3c26e7 --- /dev/null +++ b/apps/daemon/drizzle/meta/0009_snapshot.json @@ -0,0 +1,892 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d784a44f-1c49-4484-ad8b-7b6934f24d5e", + "prevId": "df784a45-0a6e-40a4-9dcd-52336eb9bdcf", + "tables": { + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_resources": { + "name": "session_resources", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_type": { + "name": "locator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator": { + "name": "locator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_locator_key": { + "name": "normalized_locator_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_resources_session_idx": { + "name": "session_resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_resources_locator_idx": { + "name": "session_resources_locator_idx", + "columns": ["session_id", "normalized_locator_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_resources_session_id_sessions_session_id_fk": { + "name": "session_resources_session_id_sessions_session_id_fk", + "tableFrom": "session_resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_policy_id": { + "name": "approval_policy_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/_journal.json b/apps/daemon/drizzle/meta/_journal.json index d6f2cca1f..433015041 100644 --- a/apps/daemon/drizzle/meta/_journal.json +++ b/apps/daemon/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1785425562289, "tag": "0008_add_session_resources", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1786331414776, + "tag": "0009_add_session_run_pin", + "breakpoints": true } ] } diff --git a/apps/daemon/src/__tests__/config-persistence.test.ts b/apps/daemon/src/__tests__/config-persistence.test.ts index 1bd590ae2..82a3bf079 100644 --- a/apps/daemon/src/__tests__/config-persistence.test.ts +++ b/apps/daemon/src/__tests__/config-persistence.test.ts @@ -100,17 +100,17 @@ describe('provider config persistence', () => { const store = createProviderConfigStore(createInMemoryVault(), {}, []); store.update({ - providers: { codex: { enabled: true, activeAccountId: oauthAccount.id } }, + providers: { codex: { enabled: true, enabledAccountIds: [oauthAccount.id] } }, accounts: [oauthAccount], }); expect(readConfig()).toEqual({ hostname: '127.0.0.2', - providers: { codex: { enabled: true, activeAccountId: oauthAccount.id } }, + providers: { codex: { enabled: true, enabledAccountIds: [oauthAccount.id] } }, accounts: [oauthAccount], }); expect(store.get()).toEqual({ - codex: { enabled: true, activeAccountId: oauthAccount.id }, + codex: { enabled: true, enabledAccountIds: [oauthAccount.id] }, }); expect(store.getAccounts()).toEqual([oauthAccount]); expect(statSync(config).mode & 0o777).toBe(0o600); diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index b7ec922db..25f906fe9 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -76,12 +76,27 @@ describe('loadConfig providers', () => { const config = loadConfig(vault); - expect(config.providers).toEqual({ - 'claude-code': { enabled: true, defaultModel: 'sonnet' }, - }); + // Neither default survives: an agent's only per-account state is which accounts it offers. + expect(config.providers).toEqual({ 'claude-code': { enabled: true } }); expect(errorSpy).toHaveBeenCalled(); }); + it('keeps the old default account by enabling it, and drops both default models', () => { + writeConfig({ + // A narrowed list that omits the default would silently take that account away on upgrade. + 'claude-code': { enabled: true, activeAccountId: 'acc_a', enabledAccountIds: ['acc_b'] }, + codex: { enabled: true, activeAccountId: 'acc_a', model: 'gpt-5.6-sol' }, + opencode: { enabled: true, activeAccountId: 'acc_a', enabledAccountIds: ['acc_a'] }, + }); + + expect(loadConfig(vault).providers).toEqual({ + 'claude-code': { enabled: true, enabledAccountIds: ['acc_b', 'acc_a'] }, + // No list to join: absent already means every bindable account, this one included. + codex: { enabled: true }, + opencode: { enabled: true, enabledAccountIds: ['acc_a'] }, + }); + }); + it('drops an entry keyed by an unknown agent kind, logging the error', () => { const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); writeConfig({ @@ -236,6 +251,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 @@ -278,13 +301,13 @@ describe('saveProviderConfiguration', () => { saveProviderConfiguration( vault, - { codex: { enabled: true, activeAccountId: 'acc_1', apiKey: 'sk-provider' } }, + { codex: { enabled: true, enabledAccountIds: ['acc_1'], apiKey: 'sk-provider' } }, [validAccount], ); expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ hostname: '127.0.0.1', - providers: { codex: { enabled: true, activeAccountId: 'acc_1' } }, + providers: { codex: { enabled: true, enabledAccountIds: ['acc_1'] } }, accounts: [{ ...validAccount, credential: { type: 'api-key' } }], }); expect(vault.refs.get('provider:codex')).toBe('sk-provider'); diff --git a/apps/daemon/src/__tests__/session-store.test.ts b/apps/daemon/src/__tests__/session-store.test.ts new file mode 100644 index 000000000..921b73d0f --- /dev/null +++ b/apps/daemon/src/__tests__/session-store.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionRecordSchema } from '@linkcode/schema'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createSessionStore } from '../session-store'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function databasePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'linkcode-session-store-')); + temporaryDirectories.push(directory); + return join(directory, 'daemon.db'); +} + +describe('SQLite session store', () => { + /** + * The engine reads a thread's own picks back off its runs to relaunch it, so a field this table + * drops is a thread silently returning to the agent's configured default on the next daemon boot. + * The in-memory store round-trips whole objects and cannot catch that; only this can. + */ + it('round-trips every field of a run, not just the ones the engine happens to set', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-pinned', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 2, + runs: [ + { startedAt: 1, endedAt: 2, historyId: 'native-1', accountId: 'acc_first' }, + { + startedAt: 3, + historyId: 'native-2', + accountId: 'acc_second', + model: 'model-second', + effort: 'xhigh', + approvalPolicyId: 'acceptEdits', + }, + ], + }); + await createSessionStore(database).save(record); + + expect(await createSessionStore(database).load()).toEqual([record]); + }); + + it('keeps run order across a reload, since the array position is part of the record', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-ordered', + kind: 'codex', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [ + { startedAt: 1, model: 'first' }, + { startedAt: 2, model: 'second' }, + { startedAt: 3, model: 'third' }, + ], + }); + const store = createSessionStore(database); + await store.save(record); + // A later save rewrites the whole run list; the newest run is what a relaunch reads back. + await store.save({ ...record, runs: [...record.runs, { startedAt: 4, model: 'fourth' }] }); + + const [reloaded] = await createSessionStore(database).load(); + expect(reloaded.runs.map((run) => run.model)).toEqual(['first', 'second', 'third', 'fourth']); + }); +}); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index a9387305b..752214590 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,39 @@ 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 }] }; +} + +/** + * An agent's only per-account state is now which accounts it offers, so the default account and + * default model are dropped on read — zod would strip them anyway. The default account is folded + * into the enabled list first: it was necessarily an account the user meant this agent to use, and + * an explicit list that omitted it would silently take it away. + */ +function withEnabledAccounts(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value; + const { + activeAccountId, + defaultModel: _model, + model: _pick, + ...rest + } = value as { activeAccountId?: unknown; defaultModel?: unknown; model?: unknown } & { + enabledAccountIds?: unknown; + }; + if (typeof activeAccountId !== 'string' || activeAccountId === '') return rest; + const enabled = rest.enabledAccountIds; + if (!Array.isArray(enabled)) return rest; + return enabled.includes(activeAccountId) + ? rest + : { ...rest, enabledAccountIds: [...enabled, activeAccountId] }; +} + /** * Parse element by element like {@link parseAccounts}: one invalid server is dropped and logged, * never blanking the rest. @@ -270,7 +303,7 @@ function parseProviders(store: SecretStore, raw: unknown): Parsed ({ historyId: run.historyId ?? undefined, + accountId: run.accountId ?? undefined, + model: run.model ?? undefined, + effort: run.effort ?? undefined, + approvalPolicyId: run.approvalPolicyId ?? undefined, startedAt: run.startedAt, endedAt: run.endedAt ?? undefined, })), diff --git a/apps/desktop/src/renderer/src/settings/history-import-tab.tsx b/apps/desktop/src/renderer/src/settings/history-import-tab.tsx index e153a5cc1..b8e724665 100644 --- a/apps/desktop/src/renderer/src/settings/history-import-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/history-import-tab.tsx @@ -71,7 +71,7 @@ export function HistoryImportTab({ kind }: { kind: AgentKind }): React.ReactNode <> - {t('panelTitle', { provider: AGENT_LABELS[kind] })} + {t('panelTitle', { harness: AGENT_LABELS[kind] })} {surface.count > 0 && ( diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index f7722673f..5f42c24fb 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -79,8 +79,7 @@ export function DesktopShell({ runtimeCues, attachmentSupport, agentCatalogs, - newSessionDefaultModels, - newSessionPreferredModels, + accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -428,8 +427,7 @@ export function DesktopShell({ runtimeCues={runtimeCues} attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} - defaultModels={newSessionDefaultModels} - preferredModels={newSessionPreferredModels} + accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} @@ -453,6 +451,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 12adad857..10bfb975c 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 c075411e2..4ffd9fa2b 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -1,6 +1,4 @@ import type { - Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -793,8 +791,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 +839,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). */ @@ -1133,10 +1134,6 @@ export class LinkCodeClient { return this.control.setProviderConfig(providers); } - createAndBindAccount(agent: AgentKind, account: Account): Promise { - return this.control.createAndBindAccount(agent, account); - } - setAccounts(accounts: Accounts): Promise { return this.control.setAccounts(accounts); } diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 03202a443..73458efee 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,6 +1,4 @@ import type { - Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -267,9 +265,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. */ @@ -570,23 +574,19 @@ export class ControlChannel { })); } - createAndBindAccount(agent: AgentKind, account: Account): Promise { - return this.sendCorrelated('ack', (clientReqId) => ({ - kind: 'config.account.create-and-bind', - clientReqId, - agent, - account, - })); - } - - /** Ask the daemon 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 b19906171..61d59728c 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -9,8 +9,6 @@ import type { } from '@linkcode/client-core'; import { LinkCodeClient } from '@linkcode/client-core'; import type { - Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -243,8 +241,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 }> { @@ -277,10 +275,6 @@ export class LinkCodeSdkClient { return toResult(this.raw.setProviderConfig(providers)); } - createAndBindAccount(agent: AgentKind, account: Account): RequestResult<{ ok: true }> { - return toResult(this.raw.createAndBindAccount(agent, account)); - } - /** Read the daemon-owned global account pool (data plane). */ getAccounts(): RequestResult { return toResult(this.raw.getAccounts()); @@ -291,12 +285,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 ed3531449..b9117f961 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -6,8 +6,6 @@ import type { SessionStartResult, } from '@linkcode/client-core'; import type { - Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -222,9 +220,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( @@ -263,12 +261,6 @@ export function setProviderConfig( return resolveClient(options).setProviderConfig(options.providers); } -export function createAndBindAccount( - options: Options<{ agent: AgentKind; account: Account }>, -): RequestResult<{ ok: true }> { - return resolveClient(options).createAndBindAccount(options.agent, options.account); -} - export function getAccounts(options?: Options): RequestResult { return resolveClient(options).getAccounts(); } @@ -278,9 +270,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/agent-runtime/__tests__/onboarding.test.ts b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts index ac8de7d8e..4ed99648a 100644 --- a/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts +++ b/packages/client/workbench/src/agent-runtime/__tests__/onboarding.test.ts @@ -244,12 +244,12 @@ describe('deriveAgentRuntimeCues', () => { ).toEqual({}); }); - it('suppresses the login cue for a bound key account, but not for a bound oauth one', () => { + it('suppresses the login cue for an enabled key account, but not for an oauth one', () => { const runtimes: AgentRuntimes = { 'claude-code': { status: 'available', source: 'detected', auth: { loggedIn: false } }, }; const providers: ProvidersConfig = { - 'claude-code': { enabled: true, activeAccountId: 'acc_1' }, + 'claude-code': { enabled: true, enabledAccountIds: ['acc_1'] }, }; const relay: Accounts = [ { @@ -273,7 +273,7 @@ describe('deriveAgentRuntimeCues', () => { expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, delegated)).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' }, }); - // A stale binding (account deleted) leaves nothing injected either. + // An enabled list naming an account that no longer exists leaves nothing injected either. expect(deriveAgentRuntimeCues(runtimes, ASSETS, {}, {}, {}, providers, [])).toEqual({ 'claude-code': { state: 'needs-login', phase: 'idle' }, }); diff --git a/packages/client/workbench/src/agent-runtime/onboarding.ts b/packages/client/workbench/src/agent-runtime/onboarding.ts index 1a7fa4514..ffb8cec0b 100644 --- a/packages/client/workbench/src/agent-runtime/onboarding.ts +++ b/packages/client/workbench/src/agent-runtime/onboarding.ts @@ -1,4 +1,5 @@ import { useLinkCodeClient } from '@linkcode/client-core'; +import { enabledAccounts } from '@linkcode/providers'; import type { Accounts, AgentKind, @@ -125,8 +126,8 @@ export function deriveAgentRuntimeCues( /** * Whether LinkCode injects a secret for this agent at spawn, which makes a signed-out CLI runnable - * (`applyProviderDefaults`): the bound account's own key/token, or the legacy per-agent api key. An - * `oauth` account delegates back to the CLI's login store, so it does not count. + * (`applyProviderDefaults`): any enabled account's own key/token, or the legacy per-agent api key. + * An `oauth` account delegates back to the CLI's login store, so it does not count. */ export function hasInjectedCredential( kind: AgentKind, @@ -134,10 +135,9 @@ export function hasInjectedCredential( accounts: Accounts, ): boolean { if (providers[kind]?.apiKey?.trim()) return true; - const boundId = providers[kind]?.activeAccountId; - if (boundId === undefined) return false; - const bound = accounts.find((account) => account.id === boundId); - return bound?.credential.type === 'api-key' || bound?.credential.type === 'auth-token'; + return enabledAccounts(accounts, providers, kind).some( + ({ credential }) => credential.type === 'api-key' || credential.type === 'auth-token', + ); } /** The login cue for a signed-out runtime, its phase driven by any in-flight login activity. */ diff --git a/packages/client/workbench/src/mock/dev-mock-host.ts b/packages/client/workbench/src/mock/dev-mock-host.ts index d5d5f9ae2..5d9e7111d 100644 --- a/packages/client/workbench/src/mock/dev-mock-host.ts +++ b/packages/client/workbench/src/mock/dev-mock-host.ts @@ -411,23 +411,6 @@ export class DevMockHost { } this.sendSuccess(p.clientReqId); break; - case 'config.account.create-and-bind': { - await wait(CONTROL_LATENCY_MS); - const account = structuredClone(p.account); - const exists = this.accounts.some((candidate) => candidate.id === account.id); - this.accounts = exists - ? this.accounts.map((candidate) => (candidate.id === account.id ? account : candidate)) - : [...this.accounts, account]; - this.providers = { - ...this.providers, - [p.agent]: { - ...(this.providers[p.agent] ?? { enabled: true }), - activeAccountId: account.id, - }, - }; - this.sendSuccess(p.clientReqId); - break; - } case 'plugin.list.get': await wait(CONTROL_LATENCY_MS); this.send({ diff --git a/packages/client/workbench/src/settings/agents-settings.tsx b/packages/client/workbench/src/settings/agents-settings.tsx index a5c1ec21d..eb2249acd 100644 --- a/packages/client/workbench/src/settings/agents-settings.tsx +++ b/packages/client/workbench/src/settings/agents-settings.tsx @@ -1,4 +1,4 @@ -import { resolveBinding } from '@linkcode/providers'; +import { enabledAccounts, resolveBinding } from '@linkcode/providers'; import type { AgentKind, AgentRuntimeAvailability } from '@linkcode/schema'; import { getAccounts, getProviderConfig, setProviderConfig } from '@linkcode/sdk'; import { AgentIcon, AgentOnboardingCard, SettingsCard } from '@linkcode/ui'; @@ -44,14 +44,15 @@ export function AgentsSettingsPanel({ {AGENT_KINDS.map((kind) => { const runtime = runtimes?.[kind]; - const boundId = providers?.[kind]?.activeAccountId; - const boundAccount = accounts?.find((account) => account.id === boundId); + // The first enabled account is what a start that names none resolves to, so it is the one + // worth naming here; the rest are alternatives its model menu offers. + const boundAccount = enabledAccounts(accounts ?? [], providers, kind)[0]; const enabled = providers?.[kind]?.enabled ?? true; // A disabled agent's runtime gaps don't matter — no card, just the badge. const cue = enabled ? onboarding.cues[kind] : undefined; const translated = boundAccount !== undefined && resolveBinding(boundAccount, kind).tier === 'translate'; - // With no bound account the agent follows the CLI login — show who that is when probed. + // With no enabled account the agent follows the CLI login — show who that is when probed. const cliIdentity = boundAccount === undefined && runtime?.auth?.loggedIn === true ? runtime.auth.email @@ -70,7 +71,7 @@ export function AgentsSettingsPanel({ variant="ghost" size="sm" className="-mx-2 h-auto px-2 py-0.5 font-normal text-muted-foreground text-xs" - onClick={() => onOpenProviders(boundId)} + onClick={() => onOpenProviders(boundAccount?.id)} > {boundAccount ? boundAccount.label : t('followCli')} {translated ? ` · ${t('translated')}` : ''} diff --git a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts deleted file mode 100644 index a8d49fda7..000000000 --- a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// @vitest-environment jsdom - -import type { Accounts, ProvidersConfig } from '@linkcode/schema'; -import { getProviderConfig } from '@linkcode/sdk'; -import { cleanup, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configuredDefaultModels, useConfiguredDefaultModels } from '../default-models'; - -const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); - -vi.mock('../../../runtime/tayori', () => ({ useData: useDataMock })); - -let providersData: ProvidersConfig | undefined; -let accountsData: Accounts | undefined; - -beforeEach(() => { - providersData = undefined; - accountsData = undefined; - useDataMock.mockImplementation((operation: unknown) => ({ - data: operation === getProviderConfig ? providersData : accountsData, - })); -}); - -afterEach(() => { - cleanup(); - useDataMock.mockReset(); -}); - -describe('configuredDefaultModels', () => { - it('uses an active account model before the provider default and ignores stale bindings', () => { - const providers = { - codex: { - enabled: true, - activeAccountId: 'account-1', - defaultModel: 'provider-model', - }, - 'claude-code': { - enabled: true, - activeAccountId: 'missing-account', - defaultModel: 'claude-provider-model', - }, - } 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', - }); - }); - - it('keeps defaults unresolved until both configuration sources have loaded', () => { - const { result, rerender } = renderHook(() => useConfiguredDefaultModels()); - - expect(result.current).toBeNull(); - - providersData = {}; - rerender(); - expect(result.current).toBeNull(); - - accountsData = []; - rerender(); - expect(result.current).toEqual({}); - }); -}); diff --git a/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts new file mode 100644 index 000000000..542f8aa23 --- /dev/null +++ b/packages/client/workbench/src/settings/providers/__tests__/model-options.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import type { Accounts, ProvidersConfig } from '@linkcode/schema'; +import { getProviderConfig } from '@linkcode/sdk'; +import { modelChoiceKey } from '@linkcode/ui'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { accountModelOptions, useAccountModelOptions } from '../model-options'; + +const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); + +vi.mock('../../../runtime/tayori', () => ({ useData: useDataMock })); + +let providersData: ProvidersConfig | undefined; +let accountsData: Accounts | undefined; + +beforeEach(() => { + providersData = undefined; + accountsData = undefined; + useDataMock.mockImplementation((operation: unknown) => ({ + data: operation === getProviderConfig ? providersData : accountsData, + })); +}); + +afterEach(() => { + cleanup(); + useDataMock.mockReset(); +}); + +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 000000000..6368bc97d --- /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 058ccb532..bbeb01544 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -6,59 +6,64 @@ import { boundAgentKinds, maskSecret, providerAccountListViewModel, - withBinding, - withModel, + withAccountEnabled, withoutAccount, } from '../view'; const providers: ProvidersConfig = { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', defaultModel: 'claude-opus-4-8' }, - codex: { enabled: false, activeAccountId: 'acc_b' }, + 'claude-code': { enabled: true, enabledAccountIds: ['acc_a'] }, + codex: { enabled: false, enabledAccountIds: ['acc_b'] }, opencode: { enabled: true }, }; -describe('binding transforms', () => { - it('binds while preserving the entry and defaults enabled for a fresh kind', () => { - const next = withBinding(providers, 'codex', 'acc_a'); - expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_a' }); - expect(withBinding(providers, 'pi', 'acc_a').pi).toEqual({ - enabled: true, - activeAccountId: 'acc_a', - }); - }); +describe('provider config 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 }, + ]; - 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' }); + // 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('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', - }); - expect(withModel(providers, 'claude-code', undefined)['claude-code']).toEqual({ - enabled: true, - activeAccountId: 'acc_a', - }); + it('empties the enabled list rather than dropping it, which would re-offer everything', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + const next = withAccountEnabled(providers, 'claude-code', 'acc_a', false, pool); + expect(next['claude-code']?.enabledAccountIds).toEqual([]); }); - it('clears every binding of a removed account, identity-stable when none matched', () => { + it('drops a removed account from every enabled list, identity-stable when none named it', () => { const next = withoutAccount(providers, 'acc_a'); - expect(next['claude-code']).toEqual({ enabled: true, defaultModel: 'claude-opus-4-8' }); - expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_b' }); + expect(next['claude-code']).toEqual({ enabled: true, enabledAccountIds: [] }); + expect(next.codex).toEqual({ enabled: false, enabledAccountIds: ['acc_b'] }); expect(withoutAccount(providers, 'acc_missing')).toBe(providers); }); }); describe('view helpers', () => { - it('lists bound agents in stable order and renders the config snippet from them', () => { - expect(boundAgentKinds(providers, 'acc_a')).toEqual(['claude-code']); - const snippet = accountConfigSnippet(providers, 'acc_a'); + it('lists the agents offering this account in stable order, and snippets them', () => { + const anthropic: Accounts[number] = { + id: 'acc_a', + label: 'Anthropic', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'k' }, + createdAt: 0, + }; + // `opencode` and `pi` name no list, which means every bindable account — including this one. + // `codex` lists only `acc_b`, and `grok-build` takes no endpoint at all. + expect(boundAgentKinds(anthropic, providers)).toEqual(['claude-code', 'opencode', 'pi']); + const snippet = accountConfigSnippet(anthropic, providers); expect(JSON.parse(snippet)).toEqual({ providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', defaultModel: 'claude-opus-4-8' }, + 'claude-code': { enabled: true, enabledAccountIds: ['acc_a'] }, + opencode: { enabled: true }, }, }); }); @@ -115,7 +120,9 @@ describe('view helpers', () => { // the same answer the resolver gives, rather than a pin it will ignore. routing: { kind: 'catalog', protocols: ['openai-chat', 'openai-responses'] }, credentialType: 'api-key', - boundAgents: ['claude-code'], + // Enabled for claude-code by name, and for the two endpoint-agnostic agents by an absent + // list; codex lists only acc_b, and grok-build takes no endpoint at all. + boundAgents: ['claude-code', 'opencode', 'pi'], }, { id: 'acc_b', @@ -124,7 +131,8 @@ describe('view helpers', () => { serviceLabel: 'Claude', credentialType: 'oauth', auth: { loggedIn: true, email: 'claude@example.com' }, - boundAgents: ['codex'], + // An oauth login serves only its own agent, and claude-code's list does not name it. + boundAgents: [], }, { id: 'acc_c', @@ -139,12 +147,9 @@ describe('view helpers', () => { protocol: 'openai-chat', }, credentialType: 'auth-token', - boundAgents: [], + boundAgents: ['opencode', 'pi'], }, ], - detectedLogins: [{ service: 'chatgpt-sub', label: 'ChatGPT', email: 'codex@example.com' }], - bindingCount: 2, - agentCount: 5, }); }); @@ -156,7 +161,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 +172,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 +181,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 88f918609..3b8686197 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']; @@ -40,14 +44,16 @@ 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 98955b5b3..adea174da 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -1,12 +1,6 @@ import { serviceById } from '@linkcode/providers'; import type { Account, AgentKind, ProvidersConfig } from '@linkcode/schema'; -import { - createAndBindAccount, - getAccounts, - getProviderConfig, - setAccounts, - setProviderConfig, -} from '@linkcode/sdk'; +import { getAccounts, getProviderConfig, setAccounts, setProviderConfig } from '@linkcode/sdk'; import { AccountDetail, AccountList } from '@linkcode/ui'; import { Dialog, @@ -20,13 +14,13 @@ import { useTranslations } from 'use-intl'; import { useAgentRuntimes } from '../../agent-runtime/hooks'; import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; -import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; +import { AddAccountForm, EditAccountForm, ServiceCatalogView } from './add-flow'; +import { useModelSources } from './model-selection'; import { useProvidersSettingsStore } from './store'; import { providerAccountDetailViewModel, providerAccountListViewModel, - withBinding, - withModel, + withAccountEnabled, withoutAccount, } from './view'; @@ -45,9 +39,10 @@ export function ProvidersSettingsPanel(): React.ReactNode { const { data: providers, mutate: mutateProviders } = useData(getProviderConfig, {}); const { data: runtimes } = useAgentRuntimes(); const onboarding = useAgentRuntimeOnboarding(); - const bindAccount = useMutation(createAndBindAccount); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); + // The forms are presentation; only this page sits inside the data-plane provider tree. + const modelSources = useModelSources(); const view = useProvidersSettingsStore((state) => state.view); const select = useProvidersSettingsStore((state) => state.select); @@ -61,11 +56,11 @@ export function ProvidersSettingsPanel(): React.ReactNode { const pool = accounts ?? []; const accountsById = new Map(pool.map((account) => [account.id, account])); const selected = view.kind === 'account' ? accountsById.get(view.accountId) : undefined; - const busy = bindAccount.isMutating || saveAccounts.isMutating || saveProviders.isMutating; + const busy = saveAccounts.isMutating || saveProviders.isMutating; const selectedDetail = selected === undefined ? undefined - : providerAccountDetailViewModel(selected, pool, providers, runtimes); + : providerAccountDetailViewModel(selected, providers, runtimes); const accountList = providerAccountListViewModel(pool, providers, runtimes); const applyProviders = async (next: ProvidersConfig): Promise => { @@ -73,22 +68,16 @@ export function ProvidersSettingsPanel(): React.ReactNode { void mutateProviders(); }; - const handleSetBinding = (kind: AgentKind, accountId: string | undefined): void => { - void applyProviders(withBinding(providers ?? {}, kind, accountId)); - }; - - const handleSetModel = (kind: AgentKind, model: string | undefined): void => { - void applyProviders(withModel(providers ?? {}, kind, model)); + const handleSetAccountEnabled = (kind: AgentKind, enabled: boolean): void => { + if (!selected) return; + void applyProviders(withAccountEnabled(providers ?? {}, kind, selected.id, enabled, pool)); }; + // Every account joins the pool the same way. A subscription used to bind itself to its agent on + // the way in; with no default to claim, adding one is adding one. const handleAdd = async (account: Account): Promise => { - if (account.credential.type === 'oauth') { - await bindAccount.trigger({ agent: account.credential.agent, account }); - await Promise.all([mutateAccounts(), mutateProviders()]); - } else { - await saveAccounts.trigger({ accounts: [...pool, account] }); - await mutateAccounts(); - } + await saveAccounts.trigger({ accounts: [...pool, account] }); + await mutateAccounts(); closeDialog(); }; @@ -100,13 +89,6 @@ export function ProvidersSettingsPanel(): React.ReactNode { select(account.id); }; - // One-click adoption of a detected CLI login: same account the oauth form would create. - const handleAdoptDetected = (serviceId: string): void => { - const service = serviceById(serviceId); - if (service?.kind !== 'oauth') return; - void handleAdd(oauthAccount(service, t(`serviceName.${service.id}`))); - }; - const handleRemove = async (): Promise => { if (!selected) return; const cleared = withoutAccount(providers ?? {}, selected.id); @@ -130,13 +112,7 @@ export function ProvidersSettingsPanel(): React.ReactNode {
{/* The page title is rendered by the settings shell; this is the lead subtitle. */}

{t('hint')}

- + { @@ -194,8 +172,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { { void handleRemove(); diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 3467dce94..0019f2d3b 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -1,5 +1,5 @@ import { - detectedLoginSuggestions, + accountEnabledFor, pinnedEndpoint, resolveBinding, serviceById, @@ -18,8 +18,8 @@ import type { ProviderAccountListItem, ProviderAccountListViewModel, ProviderAccountRouting, - ProviderBindingStatus, - ProviderBindingViewModel, + ProviderAgentStatus, + ProviderAgentViewModel, ProviderCredentialViewModel, } from '@linkcode/ui'; @@ -33,21 +33,26 @@ 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 whose pickers offer this account's models, in stable agent order. Enablement alone is not + * enough — an absent list enables every agent, including the ones this account cannot back. */ export function boundAgentKinds( + account: Account, providers: ProvidersConfig | undefined, - accountId: string, ): AgentKind[] { - return AGENT_KINDS.filter((kind) => providers?.[kind]?.activeAccountId === accountId); + return AGENT_KINDS.filter( + (kind) => + resolveBinding(account, kind).tier !== 'unavailable' && + accountEnabledFor(providers, kind, account.id), + ); } /** The `providers` slice this account writes into `~/.linkcode/config.json`, pretty-printed for * the detail pane preview. Contains no secret (the account itself holds the credential). */ export function accountConfigSnippet( + account: Account, providers: ProvidersConfig | undefined, - accountId: string, ): string { - const bound = boundAgentKinds(providers, accountId); + const bound = boundAgentKinds(account, providers); const slice: Record = {}; for (const kind of bound) slice[kind] = providers?.[kind]; return JSON.stringify({ providers: slice }, null, 2); @@ -86,77 +91,61 @@ 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; 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, status }; } + // Enabled is the whole state, and the switch already shows it — only a reason to be off earns text. + const enabled = accountEnabledFor(providers, kind, account.id); return { - bound, tier: availability.tier, - status: bound - ? { kind: 'bound' } - : boundId === undefined - ? { kind: 'no-provider' } - : { kind: 'bound-elsewhere', accountLabel: accountLabels.get(boundId) ?? boundId }, + enabled, + ...(!enabled && { status: { kind: 'disabled' } }), }; } /** Selected account plus precomputed binding rows; UI owns only rendering and local interaction. */ export function providerAccountDetailViewModel( account: Account, - accounts: Accounts, providers: ProvidersConfig | undefined, runtimes: AgentRuntimes | undefined, ): ProviderAccountDetailViewModel { - const accountLabels = new Map(accounts.map((candidate) => [candidate.id, candidate.label])); - const bindings = AGENT_KINDS.map((kind): ProviderBindingViewModel => { - const binding = bindingStatus(account, accountLabels, kind, providers); - return { - kind, - ...binding, - currentModel: providers?.[kind]?.defaultModel ?? '', - }; - }); - const boundAgents = boundAgentKinds(providers, account.id); + const agents = AGENT_KINDS.map( + (kind): ProviderAgentViewModel => ({ kind, ...agentStatus(account, kind, providers) }), + ); + const boundAgents = boundAgentKinds(account, providers); const serviceLabel = serviceById(account.service)?.label; const routing = accountRouting(account); return { 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), + configPreview: accountConfigSnippet(account, providers), }), }; } @@ -187,7 +176,7 @@ function providerAccountListItem( id: account.id, label: account.label, credentialType: account.credential.type, - boundAgents: boundAgentKinds(providers, account.id), + boundAgents: boundAgentKinds(account, providers), ...(account.service !== undefined && { service: account.service }), ...(serviceLabel !== undefined && { serviceLabel }), ...(routing !== undefined && { routing }), @@ -200,7 +189,7 @@ function providerAccountListItem( }; } -/** Precomputed account rows and detected-login suggestions for the presentation-only list. */ +/** Precomputed account rows for the presentation-only list. */ export function providerAccountListViewModel( accounts: Accounts, providers: ProvidersConfig | undefined, @@ -208,29 +197,33 @@ export function providerAccountListViewModel( ): ProviderAccountListViewModel { return { accounts: accounts.map((account) => providerAccountListItem(account, providers, runtimes)), - detectedLogins: detectedLoginSuggestions(accounts, runtimes).map(({ service, auth }) => ({ - service: service.id, - label: service.label, - ...(auth.email !== undefined && { email: auth.email }), - })), - bindingCount: AGENT_KINDS.filter((kind) => providers?.[kind]?.activeAccountId !== undefined) - .length, - agentCount: AGENT_KINDS.length, }; } -/** Bind (or, with undefined, unbind) an agent's active account; other fields survive untouched. */ -export function withBinding( +/** + * Show or hide one account's models in an agent's pickers. Absent `enabledAccountIds` means every + * bindable account, so the first disable has to materialize the list from what is bindable *now* — + * otherwise hiding one account would read as "only this one", hiding every other account too. Once + * the list exists it is authoritative, so an account added later stays out until enabled. + */ +export function withAccountEnabled( providers: ProvidersConfig, kind: AgentKind, - accountId: string | undefined, + accountId: string, + enabled: boolean, + 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 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); + return { ...providers, [kind]: { ...entry, enabledAccountIds: next } }; } /** Toggle whether the agent is offered in the client's agent picker. */ @@ -242,30 +235,20 @@ export function withEnabled( return { ...providers, [kind]: { ...providers[kind], enabled } }; } -/** Set (or, with undefined, clear) an agent's default model. */ -export function withModel( - providers: ProvidersConfig, - kind: AgentKind, - model: string | undefined, -): ProvidersConfig { - const entry = providers[kind] ?? { enabled: true }; - if (model === undefined) { - const { defaultModel: _cleared, ...rest } = entry; - return { ...providers, [kind]: rest }; - } - return { ...providers, [kind]: { ...entry, defaultModel: model } }; -} - -/** Drop every binding referencing a removed account; returns the input unchanged when none did. */ +/** Drop a removed account from every enabled list; returns the input unchanged when none named it. + * An agent left with an absent list would silently re-offer every bindable account, so a list that + * loses its last entry stays present and empty. */ export function withoutAccount(providers: ProvidersConfig, accountId: string): ProvidersConfig { let changed = false; const next: ProvidersConfig = {}; for (const kind of AGENT_KINDS) { const entry = providers[kind]; if (entry === undefined) continue; - if (entry.activeAccountId === accountId) { - const { activeAccountId: _cleared, ...rest } = entry; - next[kind] = rest; + if (entry.enabledAccountIds?.includes(accountId)) { + next[kind] = { + ...entry, + enabledAccountIds: entry.enabledAccountIds.filter((id) => id !== accountId), + }; changed = true; } else { next[kind] = entry; diff --git a/packages/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 768fa8082..7a98d95a4 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 482443209..bca553192 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,19 +29,18 @@ 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 daemon + * owns both answers: `providers[kind].model` for the agent's default, the thread's run for a pick. */ model?: string | null; /** Null clears a remembered selection after an explicit reset or rejected reflection. */ effort?: EffortLevel | null; } export interface NewSessionDefaultsState { - /** Provider of the last successful new-session submit; null before the first (→ claude-code). */ - lastProvider: AgentKind | null; + /** Harness of the last successful new-session submit; null before the first (→ claude-code). */ + lastHarness: AgentKind | null; /** Workspace of the last successful submit; ids that no longer exist are skipped at resolve time. */ lastWorkspaceId: WorkspaceId | null; - /** 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 f36dfbfd4..8be491468 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; diff --git a/packages/client/workbench/src/surface/workbench.tsx b/packages/client/workbench/src/surface/workbench.tsx index fd1e18da7..31347d122 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,7 @@ 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 } from '../settings/providers/model-options'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; import { useSidebarGroupCollapseStore } from '../sidebar/collapse-store'; import { useSidebarOrderStore } from '../sidebar/order-store'; @@ -240,7 +241,7 @@ function WorkbenchSessionSurface({ const active = sessions.active; 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 +269,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 +374,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 +403,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 +448,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 +577,7 @@ function WorkbenchSessionSurface({ const draft: NewSessionDraft | null = sessions.draft ? { initialWorkspaceId, - initialProvider: lastProvider ?? 'claude-code', + initialHarness: lastHarness ?? 'claude-code', } : null; @@ -650,9 +659,8 @@ function WorkbenchSessionSurface({ draft={draft} 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 31893341a..6c1090de9 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, enabledAccountIds: ['acc_1'] }, } satisfies ProvidersConfig; await client.setProviderConfig(providers); expect(await client.getProviderConfig()).toEqual(providers); @@ -134,21 +134,16 @@ describe('dev mock transport', () => { // Independent fields: writing accounts preserved the provider config. expect(await client.getProviderConfig()).toEqual(providers); - const boundAccount = { + // Adding an account is one write to the pool; nothing about the agent's config moves with it. + const relay = { id: 'acc_2', label: 'Relay', credential: { type: 'api-key', key: 'sk-relay' }, createdAt: 1, } satisfies Accounts[number]; - await client.createAndBindAccount('codex', boundAccount); - await client.createAndBindAccount('codex', { ...boundAccount, label: 'Updated relay' }); - expect(await client.getAccounts()).toEqual([ - accounts[0], - { ...boundAccount, label: 'Updated relay' }, - ]); - expect(await client.getProviderConfig()).toEqual({ - codex: { enabled: true, defaultModel: 'mock-model', activeAccountId: 'acc_2' }, - }); + await client.setAccounts([...accounts, relay]); + expect(await client.getAccounts()).toEqual([accounts[0], relay]); + expect(await client.getProviderConfig()).toEqual(providers); client.dispose(); }); diff --git a/packages/foundation/providers/AGENTS.md b/packages/foundation/providers/AGENTS.md index 9b32a9692..21b772e5a 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__/enabled-models.test.ts b/packages/foundation/providers/src/__tests__/enabled-models.test.ts new file mode 100644 index 000000000..00099e7fb --- /dev/null +++ b/packages/foundation/providers/src/__tests__/enabled-models.test.ts @@ -0,0 +1,80 @@ +import type { Account, Accounts } from '@linkcode/schema'; +import { describe, expect, it } from 'vitest'; +import { accountEnabledFor, enabledAccountModels } from '../enabled-models'; + +function account(id: string, overrides: Partial = {}): Account { + return { + id, + label: id, + service: 'deepseek', + credential: { type: 'api-key', key: 'sk-test' }, + createdAt: 0, + ...overrides, + }; +} + +const POOL: Accounts = [ + account('acc_a', { models: [{ id: 'a-1' }, { id: 'a-2', label: 'A Two' }] }), + account('acc_b', { models: [{ id: 'b-1' }] }), +]; + +describe('enabledAccountModels', () => { + it('follows pool order then model order, so the head is a stable default', () => { + expect( + enabledAccountModels(POOL, {}, 'opencode').map(({ account: a, model }) => [a.id, model.id]), + ).toEqual([ + ['acc_a', 'a-1'], + ['acc_a', 'a-2'], + ['acc_b', 'b-1'], + ]); + // Reversing the pool moves the head: the order is the pool's, not a sort of its own. + expect(enabledAccountModels([...POOL].reverse(), {}, 'opencode')[0]?.model.id).toBe('b-1'); + }); + + it('narrows to the enabled list, and offers every bindable account when it is absent', () => { + const narrowed = enabledAccountModels( + POOL, + { opencode: { enabled: true, enabledAccountIds: ['acc_b'] } }, + 'opencode', + ); + expect(narrowed.map(({ model }) => model.id)).toEqual(['b-1']); + expect(enabledAccountModels(POOL, undefined, 'opencode')).toHaveLength(3); + expect( + enabledAccountModels( + POOL, + { opencode: { enabled: true, enabledAccountIds: [] } }, + 'opencode', + ), + ).toEqual([]); + }); + + it('drops an account that cannot back the agent even when it is enabled', () => { + // Cloudflare's Anthropic leg serves that protocol alone, and codex speaks only responses. + const anthropicOnly = account('acc_cf', { + service: 'cloudflare-anthropic', + endpointParams: { account_id: '8f3a', gateway_id: 'prod' }, + models: [{ id: 'claude-opus-5' }], + }); + expect(enabledAccountModels([anthropicOnly], {}, 'codex')).toEqual([]); + expect(enabledAccountModels([anthropicOnly], {}, 'claude-code')).toHaveLength(1); + const sub = account('acc_sub', { + service: 'claude-sub', + credential: { type: 'oauth', agent: 'claude-code' }, + models: [{ id: 'claude-opus-5' }], + }); + expect(enabledAccountModels([sub], {}, 'codex')).toEqual([]); + expect(enabledAccountModels([sub], {}, 'claude-code')).toHaveLength(1); + }); + + it('reports an account with no picked model as offering nothing, not as unavailable', () => { + expect(enabledAccountModels([account('acc_empty')], {}, 'opencode')).toEqual([]); + expect(accountEnabledFor({}, 'opencode', 'acc_empty')).toBe(true); + expect( + accountEnabledFor( + { opencode: { enabled: true, enabledAccountIds: [] } }, + 'opencode', + 'acc_a', + ), + ).toBe(false); + }); +}); diff --git a/packages/foundation/providers/src/__tests__/resolve.test.ts b/packages/foundation/providers/src/__tests__/resolve.test.ts index 78e4c50b0..799e2cb27 100644 --- a/packages/foundation/providers/src/__tests__/resolve.test.ts +++ b/packages/foundation/providers/src/__tests__/resolve.test.ts @@ -1,8 +1,8 @@ import type { Account, AgentKind, AgentRuntimes } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; -import { serviceById } from '../catalog'; -import { detectedLoginSuggestions } from '../detected-logins'; +import { endpointServiceById, modelListSource, serviceById } from '../catalog'; +import { detectedLogins } 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'); @@ -286,7 +313,7 @@ describe('catalog helpers', () => { ); }); - it('suggests detected CLI logins the pool does not represent yet', () => { + it('reports detected CLI logins the pool does not represent yet', () => { const runtimes: AgentRuntimes = { 'claude-code': { status: 'available', @@ -294,13 +321,13 @@ describe('catalog helpers', () => { }, codex: { status: 'available', auth: { loggedIn: false } }, }; - const suggested = detectedLoginSuggestions([], runtimes); - expect(suggested.map(({ service, auth }) => [service.id, auth.email])).toEqual([ + const detected = detectedLogins([], runtimes); + expect(detected.map(({ service, auth }) => [service.id, auth.email])).toEqual([ ['claude-sub', 'x@y.z'], ]); - // An existing oauth account for the agent absorbs the suggestion; unprobed runtimes yield none. + // An existing oauth account for the agent absorbs it; unprobed runtimes yield none. const claudeSub = account({ credential: { type: 'oauth', agent: 'claude-code' } }); - expect(detectedLoginSuggestions([claudeSub], runtimes)).toEqual([]); - expect(detectedLoginSuggestions([], undefined)).toEqual([]); + expect(detectedLogins([claudeSub], runtimes)).toEqual([]); + expect(detectedLogins([], undefined)).toEqual([]); }); }); diff --git a/packages/foundation/providers/src/catalog.ts b/packages/foundation/providers/src/catalog.ts index b6cdf9bc0..5287aed5f 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/curated-models.ts b/packages/foundation/providers/src/curated-models.ts new file mode 100644 index 000000000..91b810a9b --- /dev/null +++ b/packages/foundation/providers/src/curated-models.ts @@ -0,0 +1,46 @@ +import type { AccountModel, AgentKind } from '@linkcode/schema'; + +/** + * Curated model lists for the agents whose subscription serves no enumeration API, used to seed a + * delegated account's picked set — a subscription reaches the pickers the same way every other + * account does, through `Account.models`, so it needs those ids from somewhere. + * + * Only adapters with a *verified* live model switch get an entry, and every id was confirmed by + * reading the served model back off a live stream (source reading is not enough: claude-code's + * first design silently ignored the override). Legacy models are included deliberately — the choice + * belongs to the user. Anthropic ids and lifecycle come from + * https://platform.claude.com/docs/en/about-claude/models/overview. + * claude-opus-4-1 is deliberately excluded: setModel() accepts it but claude-opus-5 is silently + * served instead. Offering claude-fable-5 to everyone is safe: accounts without access get a hard + * CLI error and the picker keeps the previous model (confirm-then-reflect). + * `[1m]` ids (`claude-opus-5[1m]`) are a claude-code-side context tier, not Anthropic model ids; + * none are listed, and `resolveModel()` cannot fold one back onto its base entry. + * Keeping this table static is a deliberate CODE-104 decision (the dynamic reference + * implementation lives in closed PR #52); refresh it by hand under the discipline above. + * codex ids/labels are the app-server's `model/list` verbatim, kept as the seed a headless adoption + * can use — Settings still refreshes an account from the live catalog, which also carries the + * per-model effort levels this table cannot (`AccountModel` is ids and labels). + * opencode and pi have no entry — see their adapters' comments for why. + */ +export const CURATED_AGENT_MODELS: Partial> = { + 'claude-code': [ + { id: 'claude-fable-5', label: 'Fable 5' }, + { id: 'claude-opus-5', label: 'Opus 5' }, + { id: 'claude-opus-4-8', label: 'Opus 4.8' }, + { id: 'claude-opus-4-7', label: 'Opus 4.7 (Legacy)' }, + { id: 'claude-opus-4-6', label: 'Opus 4.6 (Legacy)' }, + { id: 'claude-sonnet-5', label: 'Sonnet 5' }, + { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (Legacy)' }, + { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, + ], + codex: [ + { id: 'gpt-5.6-sol', label: 'GPT-5.6-Sol' }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6-Terra' }, + { id: 'gpt-5.6-luna', label: 'GPT-5.6-Luna' }, + { id: 'gpt-5.5', label: 'GPT-5.5' }, + { id: 'gpt-5.4', label: 'GPT-5.4' }, + { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini' }, + ], + // Grok Build headless: model is a spawn-time `-m` flag (verified 0.2.102: grok-4.5). + 'grok-build': [{ id: 'grok-4.5', label: 'Grok 4.5' }], +}; diff --git a/packages/foundation/providers/src/detected-logins.ts b/packages/foundation/providers/src/detected-logins.ts index 1ece4200d..12ba1d40d 100644 --- a/packages/foundation/providers/src/detected-logins.ts +++ b/packages/foundation/providers/src/detected-logins.ts @@ -2,21 +2,21 @@ import type { Accounts, AgentAuthStatus, AgentRuntimes } from '@linkcode/schema' import type { ServiceDescriptor } from './catalog'; import { SERVICE_CATALOG } from './catalog'; -export interface DetectedLoginSuggestion { +export interface DetectedLogin { service: Extract; auth: AgentAuthStatus; } /** - * CLI logins the runtime probe sees that the pool does not represent yet, offered as one-click - * "detected" cards: `loggedIn: true` with no oauth account for that agent. The pool stays - * explicit user state — this is a suggestion, not an implicit member. + * CLI logins the runtime probe sees that the pool does not represent yet: `loggedIn: true` with no + * oauth account for that agent. The host adopts each one into the pool, so a delegated subscription + * reaches the model pickers on the same footing as a key the user typed. */ -export function detectedLoginSuggestions( +export function detectedLogins( accounts: Accounts, runtimes: AgentRuntimes | undefined, -): DetectedLoginSuggestion[] { - const suggestions: DetectedLoginSuggestion[] = []; +): DetectedLogin[] { + const suggestions: DetectedLogin[] = []; for (const service of SERVICE_CATALOG) { if (service.kind !== 'oauth') continue; const auth = runtimes?.[service.agent]?.auth; diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts new file mode 100644 index 000000000..cc54b1f7a --- /dev/null +++ b/packages/foundation/providers/src/enabled-models.ts @@ -0,0 +1,56 @@ +import type { Account, AccountModel, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; +import { resolveBinding } from './resolve'; + +/** One model an agent may run on, paired with the account that serves it — the pair is the unit, + * because two accounts legitimately serve the same model id. */ +export interface EnabledAccountModel { + account: Account; + model: AccountModel; +} + +/** Whether this account's models are offered for this agent. An absent list means every bindable + * account, so an account added later is offered without a visit to Settings; an explicit list is the + * user narrowing it. */ +export function accountEnabledFor( + providers: ProvidersConfig | undefined, + kind: AgentKind, + accountId: string, +): boolean { + const enabled = providers?.[kind]?.enabledAccountIds; + return enabled === undefined || enabled.includes(accountId); +} + +/** + * Every model this agent may run on, in the order its pickers offer them: each enabled account in + * pool order, contributing its picked set in its own order. Availability still gates it — an enabled + * account that cannot back this agent contributes nothing. + * + * **The first entry is the agent's default.** There is no stored default account or default model: + * the client shows this list's head and the daemon starts on it for a request that names no model, so + * the two cannot disagree about what "unpicked" means. That is the whole reason this list has one + * implementation instead of one per side. + */ +export function enabledAccountModels( + accounts: Accounts, + providers: ProvidersConfig | undefined, + kind: AgentKind, +): EnabledAccountModel[] { + return enabledAccounts(accounts, providers, kind).flatMap((account) => + (account.models ?? []).map((model) => ({ account, model })), + ); +} + +/** The accounts this agent may resolve to, in pool order. An account with no picked model is still + * one of them: it contributes nothing to the pickers, but it can still back a session pinned to it, + * and its credential is still what a signed-out CLI would run on. */ +export function enabledAccounts( + accounts: Accounts, + providers: ProvidersConfig | undefined, + kind: AgentKind, +): Account[] { + return accounts.filter( + (account) => + resolveBinding(account, kind).tier !== 'unavailable' && + accountEnabledFor(providers, kind, account.id), + ); +} diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index d7aebb17f..bdf02fe9f 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -2,11 +2,20 @@ export type { EndpointService, ServiceDescriptor, ServiceGroup, + ServiceModelList, ServiceVariant, } from './catalog'; -export { endpointServiceById, SERVICE_CATALOG, serviceById } from './catalog'; -export type { DetectedLoginSuggestion } from './detected-logins'; -export { detectedLoginSuggestions } from './detected-logins'; +export { + endpointServiceById, + modelListSource, + SERVICE_CATALOG, + serviceById, +} from './catalog'; +export { CURATED_AGENT_MODELS } from './curated-models'; +export type { DetectedLogin } from './detected-logins'; +export { detectedLogins } from './detected-logins'; +export type { EnabledAccountModel } from './enabled-models'; +export { accountEnabledFor, enabledAccountModels, enabledAccounts } from './enabled-models'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; export { pinnedEndpoint, resolveBinding, serviceProtocols } from './resolve'; export { fillTemplate, isTemplateFilled, templatePlaceholders } from './template'; diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index 0603549fb..920ad5c49 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.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,8 +40,16 @@ 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`. */ + /** Stable id referenced by `providers[kind].activeAccountId` and `StartOptions.accountId`. */ id: z.string().min(1), /** User-facing name. */ label: 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 1a00b8b94..4af26ac18 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -64,9 +64,15 @@ 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(), + /** The account the model was picked from, which outranks the agent's `activeAccountId` fallback. + * A request only: resolution consumes it, injects the credential bundle into `config`, and reports + * the account that actually resolved separately — so an id naming a deleted account can never read + * back as "an account is backing this run". */ + accountId: z.string().min(1).optional(), /** Initial session mode (e.g. plan / accept-edits), if the agent advertises modes. */ modeId: SessionModeIdSchema.optional(), /** Initial reasoning effort, if the selected adapter supports effort. */ @@ -169,8 +175,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 bdc9286fb..1467fd982 100644 --- a/packages/foundation/schema/src/model/provider-config.ts +++ b/packages/foundation/schema/src/model/provider-config.ts @@ -7,13 +7,20 @@ 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(), /** Legacy provider API key, superseded by the global account pool (`account.ts`) but kept so - * pre-account configs still load; the resolver falls back to it when `activeAccountId` is unset. */ + * pre-account configs still load; the resolver falls back to it when no account resolves. */ apiKey: z.string().optional(), - /** Id of the pooled `Account` this agent's new sessions use (see `account.ts`). */ - 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. + * + * This is an agent's only per-account state. There is no default account and no default model: + * a request that names neither resolves to the head of `enabledAccountModels`, which the client + * shows and the daemon starts on, so neither side can invent an answer the other disagrees with. + */ + enabledAccountIds: z.array(z.string().min(1)).optional(), }); export type ProviderConfig = z.infer; diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index 0717fdfbd..a09c4d020 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { EffortLevelSchema } from '../agent/input'; import { AgentHistoryCapabilitiesSchema } from '../history'; import { ImPlatformSchema } from '../im'; import { @@ -7,6 +8,7 @@ import { SessionIdSchema, TimestampSchema, } from '../primitives'; +import { ApprovalPolicyIdSchema } from './control'; import { SessionStatusSchema } from './lifecycle'; /** @@ -32,10 +34,25 @@ export const SessionOriginSchema = z.discriminatedUnion('type', [ ]); export type SessionOrigin = z.infer; -/** One live start/resume of a session. Providers usually mint a new native id per resume, so a - * session accumulates runs; `historyId` is backfilled once the adapter reports it (session-ref). */ +/** + * One live start/resume of a session. Providers usually mint a new native id per resume, so a + * session accumulates runs; `historyId` is backfilled once the adapter reports it (session-ref). + * + * Everything below `historyId` is what the thread is *set to* — the choices it launched with plus + * every pick accepted since — and a relaunch replays them so the thread keeps them when the + * configured default moves. What an adapter resolved for itself is deliberately absent: recording + * that would pin every thread to its first launch and cut it off from the agent's default for good. + */ export const SessionRunSchema = z.object({ historyId: AgentHistoryIdSchema.optional(), + /** The account this run resolved to. Credentials and base URL are injected once at spawn, so the + * account is fixed for the run's lifetime and a later rebind does not move it. */ + accountId: z.string().min(1).optional(), + /** Recorded with the account because the two are one choice. */ + model: z.string().min(1).optional(), + /** Both axes live on the adapter a relaunch destroys, so they are replayed from here or lost. */ + effort: EffortLevelSchema.optional(), + approvalPolicyId: ApprovalPolicyIdSchema.optional(), startedAt: TimestampSchema, endedAt: TimestampSchema.optional(), }); @@ -76,6 +93,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 eed2644b0..a5fb1c3bf 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,13 +1,6 @@ import { z } from 'zod'; -import { - AccountEndpointSchema, - AccountModelSchema, - AccountSchema, - AccountSecretSchema, - AccountsSchema, -} from '../model/account'; +import { AccountModelSchema, AccountSecretSchema, AccountsSchema } from '../model/account'; import { CustomMcpServerPatchOpSchema, CustomMcpServerPublicSchema } from '../model/custom-mcp'; -import { AgentKindSchema } from '../model/primitives'; import { ProvidersConfigSchema } from '../model/provider-config'; import { WireRequestIdSchema } from './request'; @@ -32,20 +25,18 @@ export const configWireVariants = [ /** Patch ops against the stored custom MCP servers; omitted when untouched. */ customMcpServers: z.array(CustomMcpServerPatchOpSchema).optional(), }), - z.object({ - kind: z.literal('config.account.create-and-bind'), - clientReqId: WireRequestIdSchema, - agent: AgentKindSchema, - account: AccountSchema, - }), - /** Enumerate 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 8445c3c4a..fad851881 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 = 76 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ -export const MIN_COMPATIBLE_WIRE_VERSION = 68 as const; +export const MIN_COMPATIBLE_WIRE_VERSION = 76 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index a4d0e70f0..7690ec640 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), and that is its **only** per-account state: there is no default account and no default model. A session that names neither — automation, schedules, IM threads, mobile — resolves to the head of `enabledAccountModels` (pool order × the account's own model order), the same entry the composer displays for an untouched draft, so the two sides cannot disagree about what "unpicked" means. Sessions started from a picker carry `StartOptions.accountId`; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to that same head instead of starting a session with no credential. An enabled account the agent cannot speak to is skipped rather than fatal (it never reaches the model menu either); only an account the request *names* fails the start loudly. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the head of the agent's list moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and a change to the agent's list could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env. - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** Precedence: model-ref (`providerID/modelID`, which decides routing) → for pi, the resumed session's own last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in the agent's own catalog, from `@linkcode/providers`) → for pi, its first available provider. Before `knownProvider` existed a bare model id left the credential uninjected entirely; putting it ahead of the resumed provider instead strands a resumed session on a provider that never got the key. - **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built). diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index 06a505c09..e1747a495 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 4d91fc8b0..c76571fe4 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 06cd6bfae..eb19b0eb9 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, enabledAccountIds: [account.id] }, }, accounts: [account], }); diff --git a/packages/host/engine/src/__tests__/engine-detected-logins.test.ts b/packages/host/engine/src/__tests__/engine-detected-logins.test.ts new file mode 100644 index 000000000..cf579298e --- /dev/null +++ b/packages/host/engine/src/__tests__/engine-detected-logins.test.ts @@ -0,0 +1,87 @@ +import { CURATED_AGENT_MODELS } from '@linkcode/providers'; +import type { AgentRuntimes } from '@linkcode/schema'; +import type { Transport } from '@linkcode/transport'; +import { Effect } from 'effect'; +import { noop } from 'foxts/noop'; +import { describe, expect, it, vi } from 'vitest'; +import { adoptDetectedLogins } from '../agent/detected-logins'; +import { InMemoryProviderConfigStore } from '../agent/provider-config'; +import { createTestEngine } from './fixtures/test-engine'; + +const LOGGED_IN: AgentRuntimes = { + 'claude-code': { + status: 'available', + source: 'detected', + auth: { loggedIn: true, method: 'claude.ai', subscriptionType: 'max', email: 'x@y.z' }, + }, + codex: { status: 'available', source: 'detected', auth: { loggedIn: false } }, +}; + +const ACCOUNT_ID_RE = /^acc_/; + +const silentTransport: Transport = { + connect: () => Promise.resolve(), + send: noop, + onMessage: () => noop, + onClose: () => noop, + close: noop, +}; + +describe('detected-login adoption', () => { + it('adopts a probed CLI login into the pool without binding it', async () => { + const providerStore = new InMemoryProviderConfigStore(); + const engine = createTestEngine(silentTransport, { + providerStore, + agentRuntimesReady: Promise.resolve(LOGGED_IN), + }); + await engine.start(); + await vi.waitFor(() => expect(providerStore.getAccounts()).toHaveLength(1)); + + // Seeded with the curated list, because the pickers offer `Account.models` and nothing else — + // an account with none is a switch that reveals nothing. + expect(providerStore.getAccounts()[0]).toEqual({ + id: expect.stringMatching(ACCOUNT_ID_RE), + label: 'Claude', + service: 'claude-sub', + credential: { type: 'oauth', agent: 'claude-code' }, + models: CURATED_AGENT_MODELS['claude-code'], + createdAt: expect.any(Number), + }); + // Nothing else grew: no enabled list narrowed, so no session changes what it runs on. + expect(providerStore.get()).toEqual({}); + await engine.stop(); + }); + + it('adopts once, and skips a signed-out runtime', async () => { + const providerStore = new InMemoryProviderConfigStore(); + await Effect.runPromise(adoptDetectedLogins(providerStore, LOGGED_IN)); + await Effect.runPromise(adoptDetectedLogins(providerStore, LOGGED_IN)); + expect(providerStore.getAccounts()).toHaveLength(1); + + const signedOut = new InMemoryProviderConfigStore(); + await Effect.runPromise( + adoptDetectedLogins(signedOut, { 'claude-code': { status: 'available' } }), + ); + expect(signedOut.getAccounts()).toEqual([]); + }); + + it('keeps the accounts a concurrent write added', async () => { + const providerStore = new InMemoryProviderConfigStore(); + providerStore.update({ + accounts: [ + { + id: 'acc_key', + label: 'DeepSeek', + service: 'deepseek', + credential: { type: 'api-key', key: 'k' }, + createdAt: 1, + }, + ], + }); + await Effect.runPromise(adoptDetectedLogins(providerStore, LOGGED_IN)); + expect(providerStore.getAccounts().map((account) => account.id)).toEqual([ + 'acc_key', + expect.stringMatching(ACCOUNT_ID_RE), + ]); + }); +}); diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index 358031270..9a9e08325 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,110 @@ 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']); + }); + + it('refuses to send an account secret to a service it does not belong to', async () => { + const reached: string[] = []; + relay = await startRelay((url) => { + reached.push(url); + return { status: 200, body: JSON.stringify({ data: [] }) }; + }); + const providerStore = new InMemoryProviderConfigStore(); + providerStore.update({ + accounts: [ + { + id: 'acc_anthropic', + label: 'Anthropic', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-anthropic' }, + createdAt: 0, + }, + { + id: 'acc_custom', + label: 'Custom', + credential: { type: 'api-key', key: 'sk-custom' }, + createdAt: 0, + }, + ], + }); + const h = createHarness(providerStore); + await h.engine.start(); + + // The destination and the credential are independent client-chosen fields; pairing them freely + // would hand one vendor's key to another. + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-5', + service: 'openrouter', + credential: { type: 'account', accountId: 'acc_anthropic' }, + }); + const crossed = await replyFor(h.sent, 'probe-5'); + if (crossed.kind !== 'request.failed') throw new Error('no request.failed for probe-5'); + expect(crossed.message).toContain('does not belong to the service being probed'); + + // A pre-catalog account names no service, so nothing it could legitimately match. + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-6', + service: 'deepseek', + credential: { type: 'account', accountId: 'acc_custom' }, + }); + const serviceless = await replyFor(h.sent, 'probe-6'); + if (serviceless.kind !== 'request.failed') throw new Error('no request.failed for probe-6'); + expect(serviceless.message).toContain('does not belong to the service being probed'); + + // Neither request may reach the wire at all. + expect(reached).toEqual([]); + }); }); diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 969706d85..58ff21b9c 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'; @@ -23,6 +25,15 @@ import { settleEngineTasks as tick, } from './fixtures/session-harness'; +/** An adapter that takes the input and then refuses it, like one asked for an unsupported level. */ +class PickyAdapter extends FakeAdapter { + override send(input: Parameters[0]) { + return super.send(input).then(() => { + throw new Error('claude-code: effort refused'); + }); + } +} + class CwdlessHistoryAdapter extends FakeAdapter { override readHistory(opts: AgentHistoryReadOptions) { return Promise.resolve({ @@ -923,3 +934,489 @@ 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, enabledAccountIds: [accountId] } }, + 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', + 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('replays a model picked mid-run, not the one the run launched with', async () => { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_one'] } }, + accounts: [ + { + id: 'acc_one', + label: 'One', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-one' }, + models: [{ id: 'model-a' }, { id: 'model-b' }], + createdAt: 0, + }, + ], + }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + + // Same account, so this switches in place and launches nothing — the ordinary way a user + // changes model. The accepted pick is what gets recorded, not anything the adapter reflects. + await h.inject({ + kind: 'agent.input', + clientReqId: 'pick', + sessionId, + input: { type: 'set-model', model: 'model-b', accountId: 'acc_one' }, + }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('model-b'); + }); + + it('leaves a model the adapter resolved for itself out of the pin', async () => { + const providers = new InMemoryProviderConfigStore(); + providers.update({ providers: { 'claude-code': { enabled: true } } }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + // What the CLI resolved on its own, reflected at launch: display state, not a choice anyone made. + h.adapters[0].emit({ type: 'model-update', model: 'adapter-choice' }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + + // Recording that reflection would pin the thread to its first launch, and the head of the + // agent's list could never reach it again. + providers.update({ + accounts: [ + { + id: 'acc_one', + label: 'One', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-one' }, + models: [{ id: 'configured' }], + createdAt: 0, + }, + ], + }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('configured'); + expect((await store.load())[0].runs[0].model).toBeUndefined(); + }); + + it('replays the effort and approval tier picked on the live session', async () => { + const h = harness(new InMemorySessionStore()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + // Both axes live on the adapter, so a relaunch is where they get lost. + await h.inject({ + kind: 'agent.input', + clientReqId: 'effort', + sessionId, + input: { type: 'set-effort', effort: 'xhigh' }, + }); + await h.inject({ + kind: 'agent.input', + clientReqId: 'policy', + sessionId, + input: { type: 'set-approval-policy', policyId: 'acceptEdits' }, + }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.effort).toBe('xhigh'); + expect(resumed.resumedWith?.approvalPolicyId).toBe('acceptEdits'); + }); + + it('records nothing when the session refuses the pick', async () => { + const store = new InMemorySessionStore(); + const h = harness(store, () => new PickyAdapter()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'effort', + sessionId, + input: { type: 'set-effort', effort: 'max' }, + }); + await tick(); + + expect(h.adapters[0].sentInputs).toContainEqual({ type: 'set-effort', effort: 'max' }); + expect((await store.load())[0].runs[0].effort).toBeUndefined(); + }); + + it('falls back to the agent’s default when the pinned account has been deleted', async () => { + const providers = new InMemoryProviderConfigStore(); + const surviving = { + id: 'acc_default', + label: 'Default', + service: 'anthropic-api' as const, + credential: { type: 'api-key' as const, key: 'sk-default' }, + models: [{ id: 'model-default' }], + createdAt: 0, + }; + providers.update({ + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_default'] } }, + accounts: [ + surviving, + { + id: 'acc_doomed', + label: 'Doomed', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-doomed' }, + models: [{ id: 'model-doomed' }], + createdAt: 0, + }, + ], + }); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + providers, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { + kind: 'claude-code', + cwd: '/repo', + model: 'model-doomed', + accountId: 'acc_doomed', + }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + + // The run's pin now names an account that no longer exists. Replaying it verbatim would start + // the agent with no credential at all, and the thread could never recover. + providers.update({ accounts: [surviving] }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.config?.apiKey).toBe('sk-default'); + // The new run records the account that actually backed it, so the thread's pin recovers too. + await h.inject({ kind: 'session.list', clientReqId: 'r4' }); + expect(listedSessions(h.sent, 'r4')[0]?.accountId).toBe('acc_default'); + }); + + it('resumes on the run’s account and model after the daemon default moved', async () => { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_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 narrows the agent to the other account while the thread sleeps, moving the head of + // its list — the only thing an unpinned start would resolve to. + providers.update({ + providers: { 'claude-code': { enabled: true, enabledAccountIds: ['acc_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, enabledAccountIds: ['acc_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'); + + // The relaunch sends no `session.started` and re-reports the historyId the record already has, + // so without this cue clients keep listing the previous account indefinitely. + expect(h.sent).toContainEqual({ + kind: 'session.changed', + sessionId: h.sessionId, + reason: 'updated', + }); + await h.inject({ kind: 'session.list', clientReqId: 'listed' }); + expect(listedSessions(h.sent, 'listed')[0]?.accountId).toBe('acc_second'); + }); + + 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 be9127b09..9226112e6 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 5d7ab4541..d55b58dc5 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 363672d00..4725eb6fd 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -1,22 +1,13 @@ import type { Account, ProvidersConfig, StartOptions } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { accountBinding, applyProviderDefaults } from '../agent/provider-config'; +import { applyProviderDefaults } from '../agent/provider-config'; const baseOpts: StartOptions = { kind: 'codex', cwd: '/repo' }; describe('applyProviderDefaults', () => { - it('returns the input untouched when no config exists for the kind', () => { + it('returns the input untouched when nothing is configured for the kind', () => { const providers: ProvidersConfig = { 'claude-code': { enabled: true, apiKey: 'sk-x' } }; - expect(applyProviderDefaults(baseOpts, providers).options).toBe(baseOpts); - }); - - it('fills the default model only when the client did not specify one', () => { - const providers: ProvidersConfig = { codex: { enabled: true, defaultModel: '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(); + expect(applyProviderDefaults(baseOpts, providers).options).toEqual(baseOpts); }); it('injects the api key into config, preserving existing config keys', () => { @@ -26,9 +17,7 @@ describe('applyProviderDefaults', () => { }); it('does not mutate the input options', () => { - const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'o4-mini', apiKey: 'sk' }, - }; + const providers: ProvidersConfig = { codex: { enabled: true, apiKey: 'sk' } }; const opts: StartOptions = { kind: 'codex', cwd: '/repo' }; applyProviderDefaults(opts, providers); expect(opts).toEqual({ kind: 'codex', cwd: '/repo' }); @@ -43,27 +32,62 @@ describe('applyProviderDefaults account pool', () => { createdAt: 0, }; - it('injects the credential from the account bound via activeAccountId', () => { - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; - expect(applyProviderDefaults(baseOpts, providers, [account]).options.config).toEqual({ - apiKey: 'sk-acc', - }); + it('injects the credential from the first account enabled for the agent', () => { + const merged = applyProviderDefaults(baseOpts, {}, [account]); + expect(merged.options.config).toEqual({ apiKey: 'sk-acc' }); + // Reported, not echoed into the adapter-facing config: the caller records what actually backed + // the run, and nothing downstream can mistake a request for a resolution. + expect(merged.accountId).toBe('acc_1'); }); - it('lets an explicit opts.config.accountId override activeAccountId', () => { - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; + it('takes the enabled list in pool order, and skips an account left out of it', () => { + const other: Account = { + ...account, + id: 'acc_2', + credential: { type: 'api-key', key: 'sk-2' }, + }; + // Pool order decides, not the order of `enabledAccountIds`. + expect( + applyProviderDefaults(baseOpts, { codex: { enabled: true, enabledAccountIds: ['acc_2'] } }, [ + account, + other, + ]).accountId, + ).toBe('acc_2'); + expect(applyProviderDefaults(baseOpts, {}, [account, other]).accountId).toBe('acc_1'); + expect( + applyProviderDefaults(baseOpts, { codex: { enabled: true, enabledAccountIds: [] } }, [ + account, + ]).accountId, + ).toBeUndefined(); + }); + + it('lets an explicit opts.accountId outrank the first enabled one, and consumes it', () => { const other: Account = { id: 'acc_2', label: 'Other', credential: { type: 'api-key', key: 'sk-other' }, createdAt: 0, }; - const merged = applyProviderDefaults( - { ...baseOpts, config: { accountId: 'acc_2' } }, - providers, - [account, other], - ); + const merged = applyProviderDefaults({ ...baseOpts, accountId: 'acc_2' }, {}, [account, other]); expect(merged.options.config).toMatchObject({ apiKey: 'sk-other' }); + expect(merged.accountId).toBe('acc_2'); + expect(merged.options.accountId).toBeUndefined(); + }); + + it('falls back to the first enabled account for a requested id that no longer resolves', () => { + // A relaunch replays a pin recorded on the run, and that account can be deleted in between. + const stale: StartOptions = { ...baseOpts, accountId: 'deleted' }; + const merged = applyProviderDefaults(stale, {}, [account]); + expect(merged.accountId).toBe('acc_1'); + expect(merged.options.accountId).toBeUndefined(); + // With nothing enabled either, the request's own id must not survive as an account. + const empty = applyProviderDefaults( + stale, + { codex: { enabled: true, enabledAccountIds: [] } }, + [account], + ); + expect(empty.accountId).toBeUndefined(); + expect(empty.options.accountId).toBeUndefined(); }); it('injects authToken, baseUrl and protocol for an auth-token account with an endpoint', () => { @@ -74,9 +98,7 @@ describe('applyProviderDefaults account pool', () => { endpoint: { baseUrl: 'https://relay.example.com/v1', protocol: 'openai-responses' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'gw' } }; - const merged = applyProviderDefaults(baseOpts, providers, [gateway]); - expect(merged.options.config).toEqual({ + expect(applyProviderDefaults(baseOpts, {}, [gateway]).options.config).toEqual({ authToken: 'or-tok', baseUrl: 'https://relay.example.com/v1', protocol: 'openai-responses', @@ -91,13 +113,14 @@ describe('applyProviderDefaults account pool', () => { endpoint: { baseUrl: 'https://openrouter.ai/api', protocol: 'anthropic' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'gw' } }; - const merged = applyProviderDefaults(baseOpts, providers, [anthropicOnly]); + // Named explicitly, so it resolves and then fails — an unusable account is never silently + // skipped in favour of the next one. + const merged = applyProviderDefaults({ ...baseOpts, accountId: 'gw' }, {}, [anthropicOnly]); expect(merged.unavailable).toBe('protocol-unsupported'); expect(merged.options.config?.baseUrl).toBeUndefined(); }); - it('resolves a catalog service to the endpoint the bound agent speaks', () => { + it('resolves a catalog service to the endpoint the agent speaks', () => { const openai: Account = { id: 'oa', label: 'OpenAI', @@ -105,33 +128,32 @@ describe('applyProviderDefaults account pool', () => { credential: { type: 'api-key', key: 'sk-oa' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oa' } }; // Codex overrides the base URL of its own Responses provider, so it carries no knownProvider. - expect(applyProviderDefaults(baseOpts, providers, [openai]).options.config).toEqual({ + expect(applyProviderDefaults(baseOpts, {}, [openai]).options.config).toEqual({ apiKey: 'sk-oa', baseUrl: 'https://api.openai.com/v1', protocol: 'openai-responses', }); - const forOpencode = applyProviderDefaults( - { ...baseOpts, kind: 'opencode' }, - { opencode: { enabled: true, activeAccountId: 'oa' } }, - [openai], - ); + const forOpencode = applyProviderDefaults({ ...baseOpts, kind: 'opencode' }, {}, [openai]); expect(forOpencode.options.config).toMatchObject({ knownProvider: 'openai' }); }); - it('prefers the account model over the provider default model', () => { - const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'o4-mini', activeAccountId: 'acc_1' }, - }; + it("fills the resolved account's first model, and never overrides the request's", () => { + // Nothing stores an agent default: the head of the account's picked set is it, which is also + // what the composer shows for an untouched draft. + const picked = { ...account, models: [{ id: 'gpt-5' }, { id: 'o4-mini' }] }; + expect(applyProviderDefaults(baseOpts, {}, [picked]).options.model).toBe('gpt-5'); expect( - applyProviderDefaults(baseOpts, providers, [{ ...account, model: 'gpt-5' }]).options.model, - ).toBe('gpt-5'); + applyProviderDefaults({ ...baseOpts, model: 'o4-mini' }, {}, [picked]).options.model, + ).toBe('o4-mini'); + // An account with nothing picked names no model, and the session start refuses rather than + // guessing one the endpoint may not serve. + expect(applyProviderDefaults(baseOpts, {}, [account]).options.model).toBeUndefined(); }); - it('falls back to the legacy apiKey when the bound account id is stale', () => { + it('falls back to the legacy apiKey when no account is enabled', () => { const providers: ProvidersConfig = { - codex: { enabled: true, apiKey: 'sk-legacy', activeAccountId: 'deleted' }, + codex: { enabled: true, apiKey: 'sk-legacy', enabledAccountIds: [] }, }; expect(applyProviderDefaults(baseOpts, providers, [account]).options.config).toEqual({ apiKey: 'sk-legacy', @@ -145,46 +167,9 @@ describe('applyProviderDefaults account pool', () => { credential: { type: 'oauth', agent: 'codex' }, createdAt: 0, }; - const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oauth_1' } }; - expect(applyProviderDefaults(baseOpts, providers, [oauth]).options.config).toEqual({}); - }); -}); - -describe('accountBinding', () => { - const account: Account = { - id: 'acc_1', - label: 'Relay', - credential: { type: 'api-key', key: 'sk-new' }, - createdAt: 1, - }; - - it('preserves unrelated providers and accounts while binding the selected agent', () => { - const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'gpt-5' }, - opencode: { enabled: false, activeAccountId: 'acc_2' }, - }; - const other: Account = { - id: 'acc_2', - label: 'Other', - credential: { type: 'api-key', key: 'sk-other' }, - createdAt: 0, - }; - - expect(accountBinding(providers, [other], 'codex', account)).toEqual({ - providers: { - codex: { enabled: true, defaultModel: 'gpt-5', activeAccountId: 'acc_1' }, - opencode: { enabled: false, activeAccountId: 'acc_2' }, - }, - accounts: [other, account], - }); - }); - - it('upserts by account id so retrying the same request is idempotent', () => { - const first = accountBinding({}, [], 'codex', account); - const updated = { ...account, label: 'Updated relay' }; - const retry = accountBinding(first.providers, first.accounts, 'codex', updated); - - expect(retry.accounts).toEqual([updated]); - expect(retry.providers.codex?.activeAccountId).toBe(account.id); + // The account still resolves — it just contributes nothing for the adapter to read. + const merged = applyProviderDefaults(baseOpts, {}, [oauth]); + expect(merged.options.config).toEqual({}); + expect(merged.accountId).toBe('oauth_1'); }); }); 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 74e7114f1..f872998ee 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -159,7 +159,11 @@ describe('simulator MCP injection at session start', () => { describe('account binding at session start', () => { function storeWith(account: Account, agent: AgentKind): InMemoryProviderConfigStore { const store = new InMemoryProviderConfigStore(); - store.createAndBindAccount(agent, account); + // An account with nothing picked refuses to start; these cases are about endpoints. + store.update({ + providers: { [agent]: { enabled: true, enabledAccountIds: [account.id] } }, + accounts: [{ ...account, models: [{ id: 'picked-model' }] }], + }); return store; } @@ -171,7 +175,23 @@ describe('account binding at session start', () => { ...overrides, }); - it('refuses the session when the bound account has no endpoint the agent speaks', async () => { + it('refuses an agent whose account picked no model, but lets one with none resolve its own', async () => { + const store = new InMemoryProviderConfigStore(); + store.update({ accounts: [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 an account named by the request that has no endpoint the agent speaks', async () => { const anthropicOnly = account({ endpoint: { baseUrl: 'https://api.anthropic.com', protocol: 'anthropic' }, }); @@ -179,8 +199,17 @@ describe('account binding at session start', () => { // Starting anyway would point codex at an endpoint that answers 404 on /responses. await expect( - Effect.runPromise(resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION)), + Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo', accountId: anthropicOnly.id }, SESSION), + ), ).rejects.toThrow('cannot back codex'); + + // Merely sitting enabled in the pool is not a request: it never reaches codex's model menu, so + // it is skipped rather than made to break every session the agent starts. + const { options } = await Effect.runPromise( + resolver.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + expect(options.config?.baseUrl).toBeUndefined(); }); it('starts an account the pre-variant add flow pinned to one endpoint', async () => { diff --git a/packages/host/engine/src/agent/detected-logins.ts b/packages/host/engine/src/agent/detected-logins.ts new file mode 100644 index 000000000..0c39e850a --- /dev/null +++ b/packages/host/engine/src/agent/detected-logins.ts @@ -0,0 +1,64 @@ +import { CURATED_AGENT_MODELS, detectedLogins } from '@linkcode/providers'; +import type { Account, AgentRuntimes } from '@linkcode/schema'; +import { Clock, Effect } from 'effect'; +import { OperationError } from '../failure'; +import type { ProviderConfigStore } from './provider-config'; + +/** + * Adopt every probed CLI login the pool does not represent yet. A delegated subscription is an + * account like any other — same picker, same resolution at session start — so detection alone is + * enough to create it; leaving it to an explicit import meant a signed-in agent had no account, and + * therefore nothing pickable, until the user visited Settings. + * + * Only the pool grows. Nothing is bound and no model is picked, so no session changes what it runs + * on, and a user who deletes the account gets it back on the next probe — which is correct: the CLI + * login is still there, and the pool describes what exists. + */ +export function adoptDetectedLogins( + providers: ProviderConfigStore, + runtimes: AgentRuntimes, +): Effect.Effect { + return Clock.currentTimeMillis.pipe( + Effect.flatMap((createdAt) => + Effect.tryPromise({ + // The pool is read inside the write path so a concurrent `config.set` cannot land between + // the two and lose either side's accounts. + async try() { + const accounts = providers.getAccounts(); + const adopted = detectedLogins(accounts, runtimes).map(({ service }): Account => { + // Seeded with the curated list, because an account with no models is an account whose + // switch reveals nothing — the pickers offer `Account.models` and nothing else. Settings + // can refresh it from the live catalog where the service serves one. + const models = CURATED_AGENT_MODELS[service.agent]; + return { + id: `acc_${crypto.randomUUID()}`, + label: service.label, + service: service.id, + credential: { type: 'oauth', agent: service.agent }, + ...(models !== undefined && { models }), + createdAt, + }; + }); + if (adopted.length === 0) return []; + await providers.update({ accounts: [...accounts, ...adopted] }); + return adopted; + }, + catch: (cause) => + new OperationError({ + subsystem: 'store', + operation: 'config.adopt-detected-logins', + publicMessage: 'Failed to adopt detected agent logins', + cause, + }), + }), + ), + Effect.flatMap((adopted) => + adopted.length === 0 + ? Effect.void + : Effect.logInfo('Adopted detected agent CLI logins', { + operation: 'config.adopt-detected-logins', + services: adopted.map((account) => account.service), + }), + ), + ); +} diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts index 02a3a859f..79411e660 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 b2872f022..768c7d49a 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -1,11 +1,10 @@ import type { BindingUnavailableReason } from '@linkcode/providers'; -import { resolveBinding } from '@linkcode/providers'; +import { enabledAccounts, resolveBinding } from '@linkcode/providers'; import type { Account, Accounts, AgentKind, CustomMcpServer, - ProviderConfig, ProvidersConfig, StartOptions, } from '@linkcode/schema'; @@ -17,13 +16,12 @@ import type { */ export interface ProviderConfigStore { get(): ProvidersConfig; - /** The global account pool bound by `providers[kind].activeAccountId`. */ + /** The global account pool an agent draws on through `providers[kind].enabledAccountIds`. */ getAccounts(): Accounts; update(next: { providers?: ProvidersConfig; accounts?: Accounts }): void | Promise; /** LinkCode-owned custom MCP servers (full plaintext — masking is the data plane's job). */ getCustomMcpServers(): CustomMcpServer[]; setCustomMcpServers(next: CustomMcpServer[]): void | Promise; - createAndBindAccount(agent: AgentKind, account: Account): void | Promise; } export class InMemoryProviderConfigStore implements ProviderConfigStore { @@ -51,45 +49,26 @@ export class InMemoryProviderConfigStore implements ProviderConfigStore { setCustomMcpServers(next: CustomMcpServer[]): void { this.customMcpServers = next; } - - createAndBindAccount(agent: AgentKind, account: Account): void { - const next = accountBinding(this.providers, this.accounts, agent, account); - this.providers = next.providers; - this.accounts = next.accounts; - } -} - -export function accountBinding( - providers: ProvidersConfig, - accounts: Accounts, - agent: AgentKind, - account: Account, -): { providers: ProvidersConfig; accounts: Accounts } { - const entry = providers[agent] ?? { enabled: true }; - const exists = accounts.some((candidate) => candidate.id === account.id); - return { - providers: { ...providers, [agent]: { ...entry, activeAccountId: account.id } }, - accounts: exists - ? accounts.map((candidate) => (candidate.id === account.id ? account : candidate)) - : [...accounts, account], - }; } /** - * Resolve the session's account: explicit `opts.config.accountId`, else the agent's - * `activeAccountId`. Undefined when neither resolves or the id is stale (account deleted) — - * the caller then falls back to the legacy `providers[kind].apiKey`. + * Resolve the session's account: explicit `opts.accountId`, else the first account enabled for the + * agent. A requested id that no longer resolves falls through to that first one rather than + * stranding the session — a relaunch replays a pin recorded on the run, and the account it names can + * be deleted in between. Undefined when the agent has no enabled account at all, which leaves the + * caller on the legacy `providers[kind].apiKey` and then on the agent's own login. */ function resolveAccount( opts: StartOptions, - config: ProviderConfig | undefined, + providers: ProvidersConfig, + kind: AgentKind, accounts: Accounts, ): Account | undefined { - const requestedId = - typeof opts.config?.accountId === 'string' ? opts.config.accountId : undefined; - const id = requestedId ?? config?.activeAccountId; - if (id === undefined) return undefined; - return accounts.find((account) => account.id === id); + const requested = + opts.accountId === undefined + ? undefined + : accounts.find((candidate) => candidate.id === opts.accountId); + return requested ?? enabledAccounts(accounts, providers, kind)[0]; } /** The adapter-facing bundle an account contributes to `StartOptions.config`; each adapter maps @@ -115,35 +94,42 @@ function accountConfigBundle( export interface AppliedProviderDefaults { readonly options: StartOptions; + /** The account whose bundle was injected — the request's pick when it still resolves, else the + * agent's default. Present only when a credential/endpoint bundle actually landed, so callers can + * treat it as "an account is backing this run" rather than a claim the request made. */ + readonly accountId?: string; /** Why the bound account cannot back this agent. A session must refuse to start rather than * run against an endpoint the agent cannot speak; pre-session reads may ignore it. */ readonly unavailable?: BindingUnavailableReason; } -/** Apply the stored config to a session's StartOptions: resolve the bound account (or legacy - * per-agent api key) 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. */ +/** Apply the stored config to a session's StartOptions: resolve the account (or legacy per-agent api + * key), inject the credential/endpoint bundle into `config`, and fall back to that account's first + * picked model. Returns a new object; never mutates the input. */ export function applyProviderDefaults( opts: StartOptions, providers: ProvidersConfig, accounts: Accounts = [], ): AppliedProviderDefaults { const config = providers[opts.kind]; - const account = resolveAccount(opts, config, accounts); - 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; - } + const account = resolveAccount(opts, providers, opts.kind, accounts); + // The request's pick never survives resolution: `config` carries what the adapter reads, and the + // account that actually resolved is this function's answer. A stale id therefore cannot travel + // downstream and read back as an account the session does not have. + const { accountId: _requested, ...next } = { ...opts }; if (account) { + // Nothing is stored as this agent's default model — the account's first pick *is* it, which is + // the entry the client shows for an untouched draft. Deriving it on both sides keeps a request + // that names no model starting on what the user was looking at. + if (next.model === undefined) next.model = account.models?.[0]?.id; const resolved = accountConfigBundle(account, opts.kind); if ('unavailable' in resolved) return { options: next, unavailable: resolved.unavailable }; - next.config = { ...next.config, ...resolved.bundle }; - } else if (config?.apiKey !== undefined) { - // Legacy: no account bound — fall back to the provider's bare api key. - next.config = { ...next.config, apiKey: config.apiKey }; + return { + options: { ...next, config: { ...next.config, ...resolved.bundle } }, + accountId: account.id, + }; } + // Legacy: no account at all — fall back to the provider's bare api key. + if (config?.apiKey !== undefined) next.config = { ...next.config, apiKey: config.apiKey }; return { options: next }; } diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 76a1d2238..7f652b739 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'; @@ -22,7 +23,6 @@ type AgentRequest = Extract< | 'agent.catalog' | 'config.get' | 'config.set' - | 'config.account.create-and-bind' | 'config.probe-models' | 'agent-login.start' | 'agent-login.submit-code' @@ -136,21 +136,19 @@ export class AgentRequestHandler { ), ); } - case 'config.account.create-and-bind': - return this.responder.reply( - payload.clientReqId, - updateProviderConfig('config.account.create-and-bind', () => - this.providers.createAndBindAccount(payload.agent, payload.account), - ).pipe( - Effect.andThen(Effect.sync(() => this.responder.sendSuccess(payload.clientReqId))), - ), - ); case 'config.probe-models': return this.responder.reply( payload.clientReqId, 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.service, payload.credential), + ); this.transport.send( createWireMessage({ kind: 'config.probe-models.result', @@ -205,6 +203,33 @@ 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. + * + * The destination and the credential arrive as two independent client-chosen fields, so the + * account must belong to the service being probed — otherwise a request could aim one vendor's + * key at another vendor's endpoint. An account with no service is refused for the same reason: + * only catalog services are probeable, so nothing it could legitimately match. + */ + private probeSecret( + service: string, + credential: Extract['credential'], + ): AccountSecret { + if (credential.type === 'inline') return credential.secret; + const account = this.providers + .getAccounts() + .find((candidate) => candidate.id === credential.accountId); + if (!account) throw new Error('Account not found'); + if (account.service !== service) { + throw new Error('That account does not belong to the service being probed'); + } + if (account.credential.type === 'oauth') { + throw new Error('A subscription login holds no secret to read the model list with'); + } + return account.credential; + } } function updateProviderConfig( diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index b10921e63..857cffced 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -6,6 +6,7 @@ import { createWireMessage } from '@linkcode/transport'; import type { Scope } from 'effect'; import { Cause, Effect, FiberSet } from 'effect'; import { CustomMcpServerService } from './agent/custom-mcp-service'; +import { adoptDetectedLogins } from './agent/detected-logins'; import { AgentLoginService } from './agent/login-service'; import { InMemoryProviderConfigStore } from './agent/provider-config'; import { AgentRequestHandler } from './agent/request-handler'; @@ -109,6 +110,19 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( collect: deps.collectAgentRuntimes, onChanged(next) { transport.send(createWireMessage({ kind: 'agent-runtime.changed', runtimes: next })); + // A probe is the only thing that sees a CLI login, so adoption rides the same signal — the + // push clients already revalidate on is what tells them the pool grew. + runTask( + adoptDetectedLogins(providerStore, next).pipe( + Effect.catch((error) => + Effect.logError( + error.publicMessage, + { operation: error.operation, subsystem: error.subsystem }, + error.cause, + ), + ), + ), + ); }, }, runTask, diff --git a/packages/host/engine/src/index.ts b/packages/host/engine/src/index.ts index 599f7511e..7d4e4f7c0 100644 --- a/packages/host/engine/src/index.ts +++ b/packages/host/engine/src/index.ts @@ -4,7 +4,7 @@ * feature implementations stay package-internal. */ -export { accountBinding, type ProviderConfigStore } from './agent/provider-config'; +export type { ProviderConfigStore } from './agent/provider-config'; export type { TranslatorService, TranslatorUpstream } from './agent/translator'; export type { AssetService } from './asset/service'; export type { LoopStore, ScheduleStore } from './automation'; diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 67eb6d783..45293e9dc 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, @@ -21,8 +23,12 @@ import type { WorktreeService } from '../worktree/worktree-service'; import type { HistoryService } from './history-service'; import { decodeLiveBranchCursor } from './live-session'; import type { SessionOrchestrator } from './orchestrator'; -import type { SessionRecordRegistry } from './session-record-registry'; -import type { SessionStartOptionsResolver } from './start-options-resolver'; +import type { + SessionPin, + SessionRecordRegistry, + SessionRunIntent, +} from './session-record-registry'; +import type { ResolvedStartOptions, SessionStartOptionsResolver } from './start-options-resolver'; type RunEffect = (effect: Effect.Effect, options?: Effect.RunOptions) => Promise; @@ -85,7 +91,11 @@ export class SessionLifecycleService { const { sessions, startOptions, workspaces, worktrees } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const { options: resolvedIntent, warnings } = yield* startOptions.resolve(options, sessionId); + const { + options: resolvedIntent, + accountId, + warnings, + } = yield* startOptions.resolve(options, sessionId); const resolved = yield* worktrees.provision(resolvedIntent, sessionId); if (options.cwd) { const parent = yield* workspaceTouch(workspaces, options.cwd); @@ -101,7 +111,7 @@ export class SessionLifecycleService { createdVia: resolved.createdVia, createdAt: now, updatedAt: now, - runs: [{ startedAt: now }], + runs: [{ startedAt: now, ...runOf(resolved, accountId) }], }; yield* sessions.startLive( replyTo, @@ -158,10 +168,11 @@ export class SessionLifecycleService { const { history, sessions, startOptions: resolver, workspaces, worktrees } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const { options: resolvedIntent, warnings } = yield* resolver.resolve( - { ...options, kind }, - sessionId, - ); + const { + options: resolvedIntent, + accountId, + warnings, + } = yield* resolver.resolve({ ...options, kind }, sessionId); const startOptions = yield* worktrees.provision(resolvedIntent, sessionId); if (options.cwd) { const parent = yield* workspaceTouch(workspaces, options.cwd); @@ -176,7 +187,7 @@ export class SessionLifecycleService { origin: { type: 'imported', historyId, importedAt: now }, createdAt: now, updatedAt: now, - runs: [{ historyId, startedAt: now }], + runs: [{ historyId, startedAt: now, ...runOf(startOptions, accountId) }], }; yield* sessions.startLive( replyTo, @@ -235,12 +246,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 +262,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 +307,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 +323,188 @@ 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, + }); + }); + }), + ); + } + + /** + * Route an input that changes what a relaunch must replay, and record it once the session has + * accepted it — a rejected pick never becomes the thread's own choice. Everything else is forwarded + * untouched, so the client's contract is one `agent.input` request either way. + */ + applyInput(sessionId: SessionId, input: AgentInput): Effect.Effect { + switch (input.type) { + case 'set-model': + return this.switchModel(sessionId, input.model, input.accountId); + case 'set-effort': + return this.recordAccepted(sessionId, input, { effort: input.effort }); + case 'set-approval-policy': + return this.recordAccepted(sessionId, input, { approvalPolicyId: input.policyId }); + default: + return this.sessions.sendInput(sessionId, input); + } + } + + /** + * Point a live session at a model, on `accountId` when the pick names one. Credentials and base URL + * are injected once at spawn, so a cross-account switch cannot happen in place: it is a relaunch + * under the same id that resumes the transcript. A switch within the session's own account stays in + * place, which is why the error channel is the adapter's untyped one rather than + * {@link EngineFailure}. + */ + private switchModel( + sessionId: SessionId, + model: string, + accountId?: string, + ): 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}`, + }), + ); + } + // A pick that names no account, or names the session's own, is a switch within the account + // the run already resolved to: the adapter takes it in place and the run keeps its own. + if (accountId === undefined || this.records.accountId(sessionId) === accountId) { + return this.recordAccepted( + sessionId, + { type: 'set-model', model, ...(accountId !== undefined && { accountId }) }, + { model }, + ); + } + if (this.sessions.isBusy(sessionId)) { + return Effect.fail( + 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, 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 }, ); }); }), ); } + /** Forward a pick to the live adapter and record it on the run only if the adapter took it. */ + private recordAccepted( + sessionId: SessionId, + input: AgentInput, + intent: SessionRunIntent, + ): Effect.Effect { + return this.sessions + .sendInput(sessionId, input) + .pipe(Effect.tap(() => Effect.sync(() => this.records.setRunIntent(sessionId, intent)))); + } + + /** + * Resolve the options an existing record relaunches under. Absent an explicit `override`, the + * thread's own last run supplies the model, account, effort and approval tier: the daemon's + * configured default answers for new and unpinned sessions, and adopting it here would silently + * move a running thread to whatever Settings now says. + */ + private resolveForRecord( + record: SessionRecord, + override?: SessionPin, + ): 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, resolved.accountId), + 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; @@ -339,7 +515,7 @@ export class SessionLifecycleService { const { sessions, startOptions: resolver, workspaces } = this; const sessionId = this.nextSessionId(); return Effect.gen(function* () { - const { options: startOptions } = yield* resolver.resolve( + const { options: startOptions, accountId } = yield* resolver.resolve( { kind: options.kind, cwd: options.cwd, model: options.model }, sessionId, ); @@ -353,7 +529,7 @@ export class SessionLifecycleService { automation: options.automation, createdAt: now, updatedAt: now, - runs: [{ startedAt: now }], + runs: [{ startedAt: now, ...runOf(startOptions, accountId) }], }; if (startOptions.cwd) yield* workspaceTouch(workspaces, startOptions.cwd); yield* sessions.startLive(undefined, record, (adapter) => @@ -425,3 +601,16 @@ function workspaceRegisterWorktree( }), }); } + +/** What a launch settled on, spread into a `SessionRun`. The account comes from the resolver rather + * than the options it produced, because only the resolver knows one actually backed the run. + * Unresolved fields stay absent rather than writing `undefined` into the record, and are what a later + * relaunch reads back to stay put. */ +function runOf(options: StartOptions, accountId: string | undefined): SessionPin { + return { + ...(accountId !== undefined && { accountId }), + ...(options.model !== undefined && { model: options.model }), + ...(options.effort !== undefined && { effort: options.effort }), + ...(options.approvalPolicyId !== undefined && { approvalPolicyId: options.approvalPolicyId }), + }; +} diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 813e172cc..24b093fd6 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 21725d99c..bbdeb3236 100644 --- a/packages/host/engine/src/session/request-handler.ts +++ b/packages/host/engine/src/session/request-handler.ts @@ -38,15 +38,19 @@ export class SessionRequestHandler { payload.clientReqId, this.lifecycle.start(payload.clientReqId, payload.opts), ); - case 'agent.input': + case 'agent.input': { + const { input, sessionId } = payload; + // Lifecycle owns inputs that outlive the adapter holding them: it records an accepted pick on + // the run, and a model pick naming another account is a relaunch. Every path answers with the + // same plain ack, so the client's contract is one request either way. + const applied = this.lifecycle.applyInput(sessionId, input); return this.responder.reply( payload.clientReqId, - 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 39fa3c9f4..2f9f00adf 100644 --- a/packages/host/engine/src/session/session-record-registry.ts +++ b/packages/host/engine/src/session/session-record-registry.ts @@ -6,15 +6,29 @@ import type { SessionId, SessionInfo, SessionRecord, + SessionRun, + StartOptions, } from '@linkcode/schema'; import { Effect } from 'effect'; import { nullthrow } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import { OperationError } from '../failure'; import type { SessionStore } from './session-store'; const TITLE_MAX_LENGTH = 80; type RunTask = (effect: Effect.Effect) => void; +/** The fields of a run that say what the thread is *set to*, as opposed to how the run went. */ +type SessionPinnedRun = Pick; + +/** The same choices shaped as start options, which is how a relaunch replays them. `Pick` over + * `StartOptions` is the guarantee: a pinned field that a launch cannot accept fails typecheck. */ +export type SessionPin = Pick; + +/** A pick accepted on a live session; absent fields leave the run's current value alone. The account + * is not among them — credentials are injected at spawn, so moving accounts is a new run. */ +export type SessionRunIntent = Omit; + export class SessionRecordRegistry { private readonly records = new Map(); private runTask: RunTask | undefined; @@ -82,6 +96,7 @@ export class SessionRecordRegistry { createdVia: record.createdVia, automation: record.automation, historyId: latestHistoryId(record), + accountId: latestRunValue(record, 'accountId'), })); } @@ -129,6 +144,29 @@ export class SessionRecordRegistry { this.onChanged(sessionId, 'updated'); } + /** + * Record a pick the session accepted, on the newest run. A pick accepted mid-run launches nothing, + * so without this a relaunch replays what the run started with and silently drops it. Callers write + * only picks — never a value an adapter resolved for itself, which would pin the thread to its own + * first launch. Not an identity change — `SessionInfo` projects none of these — so it notifies + * nobody. + */ + setRunIntent(sessionId: SessionId, intent: SessionRunIntent): void { + const record = this.records.get(sessionId); + const run = record?.runs.at(-1); + if (!record || !run) return; + const { + model = run.model, + effort = run.effort, + approvalPolicyId = run.approvalPolicyId, + } = intent; + if (model === run.model && effort === run.effort && approvalPolicyId === run.approvalPolicyId) { + return; + } + Object.assign(run, definedFields({ model, effort, approvalPolicyId })); + this.persist(record); + } + sealCurrentRun(sessionId: SessionId): void { const record = this.records.get(sessionId); const run = record?.runs.at(-1); @@ -137,11 +175,17 @@ 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); + // A new run re-points the identity `list()` projects — `accountId`, `historyId` — so clients + // must revalidate. Nothing else announces a relaunch: it sends no `session.started`, and a + // resumed run already carries the historyId that would otherwise notify via `bindHistoryId`. + this.onChanged(sessionId, 'updated'); } setTitleFromContent(sessionId: SessionId, content: ContentBlock[]): void { @@ -171,6 +215,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 ? latestRunValue(record, 'accountId') : undefined; + } + + /** + * What the thread is set to, shaped as a start-options override. A relaunch applies this so the + * thread keeps its own choices; the daemon's configured default answers for new and unpinned + * sessions only, and may have moved since this one started. + */ + pinnedOptions(sessionId: SessionId): SessionPin | undefined { + const record = this.records.get(sessionId); + if (!record) return undefined; + const pin = definedFields({ + accountId: latestRunValue(record, 'accountId'), + model: latestRunValue(record, 'model'), + effort: latestRunValue(record, 'effort'), + approvalPolicyId: latestRunValue(record, 'approvalPolicyId'), + }); + return isObjectEmpty(pin) ? undefined : pin; + } + /** The in-memory record is authoritative while running; persistence is best-effort. */ private persist(record: SessionRecord): void { record.updatedAt = Date.now(); @@ -210,6 +277,26 @@ function storeFailure(operation: string, publicMessage: string, cause: unknown): return new OperationError({ subsystem: 'store', operation, publicMessage, cause }); } +/** The newest run that answers for `key`. Older runs may name a different value — a change between + * runs is legitimate — so only the latest describes what a live session is actually on. */ +function latestRunValue( + record: SessionRecord, + key: K, +): SessionRun[K] { + for (let index = record.runs.length - 1; index >= 0; index -= 1) { + const value = record.runs[index][key]; + if (value !== undefined) return value; + } + 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 91566d020..fe1b16378 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -13,6 +13,8 @@ import { MCP_CAPABLE_AGENT_KINDS } from './mcp-capability'; export interface ResolvedStartOptions { readonly options: StartOptions; + /** The account backing this run, recorded per run so a relaunch stays on it. */ + readonly accountId?: string; /** Custom-MCP injection advisories, delivered on the `session.started` reply. */ readonly warnings: McpWarning[]; } @@ -32,11 +34,13 @@ 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 request's pick or, failing that, the agent's + // configured default. Asking that rather than "is a default set" also covers a pinned session + // on an agent with no default at all. + const { accountId } = defaults; + const account = accountId === undefined ? {} : { accountId }; const { translator } = this; const withCustomMcpServers = this.withCustomMcpServers.bind(this); const withSimulatorMcp = this.withSimulatorMcp.bind(this); @@ -47,14 +51,24 @@ 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 (accountId !== undefined && defaults.options.model === undefined) { + // With an account in play, its selected set is the only model source and nothing falls back + // to the agent's own choice. Agents with no account keep resolving their own. + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: `No model selected for ${options.kind}`, }), ); } const custom = yield* withCustomMcpServers(defaults.options); const resolved = withSimulatorMcp(custom.options, sessionId); const upstream = translationUpstream(resolved); - if (!upstream) return { options: resolved, warnings: custom.warnings }; + if (!upstream) return { options: resolved, ...account, warnings: custom.warnings }; if (!translator) { return yield* Effect.fail( new RequestError({ @@ -75,6 +89,7 @@ export class SessionStartOptionsResolver { }); return { options: withTranslatorEndpoint(resolved, url), + ...account, warnings: custom.warnings, }; }); @@ -82,7 +97,9 @@ export class SessionStartOptionsResolver { /** Fold enabled custom MCP servers into the session's server list, warning instead of * silently dropping: unsupported agent kinds and name collisions are user-visible facts. */ - private withCustomMcpServers(options: StartOptions): Effect.Effect { + private withCustomMcpServers( + options: StartOptions, + ): Effect.Effect<{ options: StartOptions; warnings: McpWarning[] }> { const warnings: McpWarning[] = []; const enabled = this.customMcp?.listEnabled() ?? []; if (enabled.length === 0) return Effect.succeed({ options, warnings }); diff --git a/packages/host/engine/src/wire/request-router.ts b/packages/host/engine/src/wire/request-router.ts index 4f11c22eb..cede0f6e7 100644 --- a/packages/host/engine/src/wire/request-router.ts +++ b/packages/host/engine/src/wire/request-router.ts @@ -79,7 +79,6 @@ export class WireRequestRouter { case 'agent.catalog': case 'config.get': case 'config.set': - case 'config.account.create-and-bind': case 'config.probe-models': { return this.handlers.agent.handle(p); } diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 9ddcecb96..88fa8eb0e 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: @@ -1002,15 +1003,11 @@ export const en = { title: 'Accounts & providers', hint: 'Connect subscriptions, AI gateways, or custom endpoints to your agents; one account can back several agents, and each agent uses one account at a time.', searchPlaceholder: 'Search accounts…', - accountCount: '{count, plural, one {# account} other {# accounts}}', - boundCount: '{bound} / {total} agents connected', addAccount: 'Add account', customService: 'Custom endpoint', - unbound: 'Not connected', noMatches: 'No matching accounts.', emptyTitle: 'No accounts yet', emptyHint: 'Add a subscription, API key, AI gateway, or custom endpoint.', - detected: 'Detected', accountMenu: 'Account actions', edit: 'Edit account', backToAccount: 'Back to account details', @@ -1025,22 +1022,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', + accountDisabled: 'Hidden from this agent’s model menu', translateBadge: 'Translated', translateNote: 'A local gateway translates Anthropic wire to OpenAI Chat', unavailableOauth: 'Only connects to {agent}', unavailableProtocol: 'The endpoint protocol is incompatible with this agent', unavailableEndpointIncomplete: 'Endpoint details are incomplete — finish the account setup', - modelDefault: 'Default', configPreview: 'config.json snippet · what this account writes', configPreviewEmpty: '// not connected to any agent yet', remove: 'Remove account', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6978c1234..85adeac7c 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} 条对话', @@ -976,15 +977,11 @@ export const zhCN = { title: '账号与 Provider', hint: '把订阅、AI 网关或自定义端点接入你的智能体;一个账号可接入多个智能体,每个智能体同一时刻使用一个账号。', searchPlaceholder: '搜索账号…', - accountCount: '{count} 个账号', - boundCount: '{bound} / {total} 智能体已接入', addAccount: '添加账号', customService: '自定义端点', - unbound: '未接入', noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', emptyHint: '添加订阅、API 密钥、AI 网关或自定义端点。', - detected: '检测到', accountMenu: '账号操作', edit: '编辑账号', backToAccount: '返回账号详情', @@ -999,22 +996,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 登录', + accountDisabled: '不在此智能体的模型菜单中显示', translateBadge: '经转换', translateNote: '本地网关将 Anthropic 协议转为 OpenAI Chat', unavailableOauth: '仅可接入 {agent}', unavailableProtocol: '端点协议与此智能体不兼容', unavailableEndpointIncomplete: '端点信息不完整,请补全账号设置', - modelDefault: '默认', configPreview: 'config.json 片段 · 此账号写入的内容', configPreviewEmpty: '// 尚未接入任何智能体', remove: '移除账号', diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index 58ed7e474..c37c6c94d 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -1,9 +1,30 @@ import { describe, expect, it } from 'vitest'; import { effortOptionsForModel } from '../shell/agent-efforts'; -import { AGENT_MODEL_OPTIONS, groupModelsByProvider, resolveModel } from '../shell/agent-models'; +import type { ModelOption } from '../shell/agent-models'; +import { groupModelsByProvider, resolveModel, switchesAccount } from '../shell/agent-models'; -const claude = AGENT_MODEL_OPTIONS['claude-code']; -const codex = AGENT_MODEL_OPTIONS.codex; +// Ids and aliases straight from `CURATED_AGENT_MODELS`; the prefix rules under test are about the +// shape of the ids a provider serves, not about where the list came from. +const claude: ModelOption[] = [ + { id: 'claude-opus-5', label: 'Opus 5' }, + { id: 'claude-opus-4-8', label: 'Opus 4.8' }, + { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, +]; +// Codex advertises per-model effort levels on its live catalog; these mirror `model/list`. +const codex: ModelOption[] = [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + { id: 'gpt-5.4', label: 'GPT-5.4' }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6-Luna', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini' }, +]; describe('resolveModel', () => { it('resolves an exact catalog id', () => { @@ -26,6 +47,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__/conversation-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx index 638ac6bec..873c41b0d 100644 --- a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx @@ -62,7 +62,7 @@ const PERMISSION_ITEM: PermissionConversationItem = { }; const RE_MODEL_DEFAULT = /modelDefault/; -const RE_OPUS_4_8 = /Opus 4.8/; +const RE_OPUS_4_8_ID = /claude-opus-4-8/; const RE_MAX_EFFORT = /Max/; function surface( @@ -126,11 +126,15 @@ describe('ConversationSurface prompt card', () => { }); it('keeps the model unresolved until the adapter reports its concrete value', () => { + // No account is enabled for this thread's agent, so there is nothing to pick from and no + // placeholder to promise one. const { rerender } = render(surface()); - expect(screen.getByRole('button', { name: RE_MODEL_DEFAULT })).toBeTruthy(); + expect(screen.queryByRole('button', { name: RE_MODEL_DEFAULT })).toBeNull(); + // What the session actually runs on is still reported, unlabelled: no account listed this id, + // and withholding it would leave the thread's own model invisible. rerender(surface(undefined, { ...EMPTY_CONVERSATION, currentModel: 'claude-opus-4-8' })); - expect(screen.getByRole('button', { name: RE_OPUS_4_8 })).toBeTruthy(); + expect(screen.getByRole('button', { name: RE_OPUS_4_8_ID })).toBeTruthy(); }); it('shows any reflected normalized effort even when the adapter does not offer it', () => { diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 5f7cb78f4..1fba8111a 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,10 +56,11 @@ 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/; const RE_EFFORT_DEFAULT_MENU = /effort.*effortDefault/; const RE_APPROVAL_DEFAULT = /Default/; @@ -79,6 +82,32 @@ const PI_CONFIGURED_CATALOG: AgentStartCatalog = { defaultEffort: 'high', }; +/** Codex's per-model effort levels, as `model/list` advertises them: Sol takes `ultra`, Luna does + * not. The account carries the ids; only the catalog knows what each can run at. */ +const CODEX_EFFORT_CATALOG: AgentStartCatalog = { + models: [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6-Sol', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6-Luna', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + }, + ], + policies: [], + defaultModel: 'gpt-5.6-sol', +}; + +/** An account offering `pi/wide`: it heads the list, so it is displayed while the catalog still + * defaults to `pi/sonnet` — the shape that separates "what is shown" from "what the agent would + * resolve for itself". */ +const PI_WIDE_ACCOUNT: NewSessionSurfaceProps['accountModels'] = { + pi: [{ id: 'pi/wide', label: 'Pi Wide', effortLevels: ['low', 'high'], defaultEffort: 'low' }], +}; + type StandaloneProps = Omit & Partial>; @@ -118,7 +147,7 @@ describe('NewSessionSurface', () => { render( { { { { { render( { render( { it('names the model selector with its agent, model, and effort', () => { render( { render( { render( { { { { render( { render( { const onSubmit = vi.fn().mockResolvedValue(undefined); 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 +639,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} preferredEfforts={{ 'claude-code': 'medium' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -628,14 +659,18 @@ describe('NewSessionSurface', () => { ); }); - it('shows a configured model without turning the default into an explicit override', async () => { + it("shows the list's head without turning it into an explicit override", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { ); }); - it('does not show a guessed model while configured defaults are loading', async () => { + it('does not show a guessed model while the account models are loading', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); const props = { chatWorkspace: CHAT_WORKSPACE, draft: { - initialProvider: 'claude-code' as const, + initialHarness: 'claude-code' as const, initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }, mentionItems: [], @@ -669,8 +704,9 @@ describe('NewSessionSurface', () => { onSubmit, workspaces: [], }; - const { rerender } = render(); + const { rerender } = render(); + // The curated table would supply a head here, and it would flip the moment the accounts land. expect(screen.getByRole('button', { name: RE_MODEL_DEFAULT })).toBeTruthy(); expect(screen.queryByRole('button', { name: RE_SONNET_5 })).toBeNull(); @@ -681,37 +717,16 @@ describe('NewSessionSurface', () => { ); rerender( - , - ); - expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); - }); - - it('shows and explicitly submits the last successful provider model without reselection', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); - render( , ); - - expect(screen.getByRole('button', { name: RE_OPUS_4_8 })).toBeTruthy(); - typeInComposer('use my last model'); - await pressInComposer('Enter'); - - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-opus-4-8' })), - ); + expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); }); it('drops remembered Codex ultra when the fallback model switches to Luna', async () => { @@ -719,10 +734,17 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('effort'); }); - it('submits a remembered dynamic-provider model even without a draft catalog', async () => { + it('shows an account model for an agent with no curated table and no draft catalog', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { 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 list's head", async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( { />, ); + // 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,9 +835,170 @@ 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 })); + // Reset means "no pick", and the daemon derives the same head, so nothing has to travel. + expect(submitted?.model).toBeUndefined(); + }); + + it('starts on the account the picked model belongs to', 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 the accounts' models and nothing else, heading the list with one", async () => { + const user = userEvent.setup(); + render( + , ); + + // The account's model heads the list, so it is what an untouched draft shows. The curated + // Anthropic table is not on offer beside it: a model no enabled account carries would be a row + // the account switches cannot take away. + await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + expect(await screen.findByRole('menuitemradio', { name: RE_DEEPSEEK_PRO })).toBeTruthy(); + expect(screen.queryByRole('menuitemradio', { name: RE_OPUS_5 })).toBeNull(); + }); + + it('sends with no model when an account offers none, leaving the agent to resolve its own', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); + }); + + it("leaves an agent's own catalog off the menu, and still sends without one", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + expect(await screen.findByRole('menuitemradio', { name: RE_DEEPSEEK_PRO })).toBeTruthy(); + expect(screen.queryByRole('menuitemradio', { name: RE_PI_SONNET })).toBeNull(); + + await user.keyboard('{Escape}'); + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('pins no account on an untouched draft, leaving the daemon to derive the same head', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const shared = (accountId: string) => ({ id: 'shared-model', label: 'Shared', accountId }); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('accountId'); }); it('submits a model only after the user explicitly selects it', async () => { @@ -809,9 +1006,15 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { await user.click(screen.getByRole('button', { name: RE_SONNET_5 })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_SONNET_5_MENU })); - fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Opus 5' })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: RE_OPUS_5 })); typeInComposer('hello'); await pressInComposer('Enter'); @@ -846,7 +1049,7 @@ describe('NewSessionSurface', () => { render( { }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -908,7 +1111,7 @@ describe('NewSessionSurface', () => { { />, ); - expect(screen.getByRole('button', { name: RE_PI_SONNET })).toBeTruthy(); + // No account is enabled for pi, so it offers no model to show — but the effort axis still + // reports what the agent's own default model will run at. + expect(screen.queryByRole('button', { name: RE_PI_SONNET })).toBeNull(); expect(screen.getByRole('button', { name: RE_HIGH_EFFORT })).toBeTruthy(); typeInComposer('untouched pickers'); @@ -935,7 +1140,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)} @@ -944,17 +1149,17 @@ describe('NewSessionSurface', () => { />, ); - expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); + // `pi/basic` takes no effort at all, so the catalog's own default cannot apply to it. expect(screen.queryByRole('button', { name: RE_HIGH_EFFORT })).toBeNull(); }); it('does not lend the catalog effort to a model the catalog did not default to', () => { render( { const { defaultModel: _unset, ...modelless } = PI_CONFIGURED_CATALOG; render( { expect(screen.getByRole('button', { name: RE_HIGH_EFFORT })).toBeTruthy(); }); - it("lets a LinkCode-configured default outrank the agent's own", () => { - render( - , - ); - - expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); - }); - - it("lets a remembered pick outrank the agent's own default", async () => { + it("lets an account model outrank the agent's own default", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( , ); - expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); - typeInComposer('use my last model'); + // The account model heads the list and so wins the display over `catalog.defaultModel`; neither + // travels, since the daemon derives the same head. + expect(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })).toBeTruthy(); + typeInComposer('use the account model'); await pressInComposer('Enter'); - // 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 () => { @@ -1039,6 +1226,13 @@ describe('NewSessionSurface', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); 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)} @@ -1062,9 +1256,11 @@ describe('NewSessionSurface', () => { />, ); - await user.click(screen.getByRole('button', { name: RE_MODEL_DEFAULT })); - await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_DEFAULT_MENU })); - fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Pi Sonnet' })); + // `pi/sonnet` heads the account's list, so it is already displayed; picking it makes it an + // explicit choice that travels with the submission. + await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_PI_SONNET_MENU })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: RE_PI_SONNET })); await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); await user.click(await screen.findByRole('menuitem', { name: RE_EFFORT_DEFAULT_MENU })); fireEvent.click(await screen.findByRole('menuitemradio', { name: 'High' })); @@ -1085,7 +1281,7 @@ describe('NewSessionSurface', () => { onSubmit.mockClear(); await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_PI_SONNET_MENU })); - fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Pi Basic' })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: RE_PI_BASIC })); typeInComposer('no stale effort'); await pressInComposer('Enter'); await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); @@ -1102,7 +1298,7 @@ describe('NewSessionSurface', () => { render( > = }; /** - * Reasoning-effort choices, keyed by adapter — same discipline as `AGENT_MODEL_OPTIONS`: only + * Reasoning-effort choices, keyed by adapter — same discipline as `CURATED_AGENT_MODELS`: only * adapters with a verified live effort switch get an entry. * claude-code: `max` can't ride the live flag-settings channel, so the adapter restarts the * process and resumes in place (entering and leaving — the startup flag outranks flag-settings); diff --git a/packages/presentation/ui/src/shell/agent-models.ts b/packages/presentation/ui/src/shell/agent-models.ts index 62be173ab..4bf30a203 100644 --- a/packages/presentation/ui/src/shell/agent-models.ts +++ b/packages/presentation/ui/src/shell/agent-models.ts @@ -1,8 +1,11 @@ -import type { AgentKind, EffortLevel } from '@linkcode/schema'; +import type { EffortLevel } from '@linkcode/schema'; export interface ModelOption { id: string; label: string; + /** The account offering this model, when the list spans several. Two accounts can serve the same + * `id`, so this is what makes an entry identifiable — see {@link modelChoiceKey}. */ + accountId?: string; /** Secondary line in the picker (adapter-advertised catalogs carry the provider name here, * disambiguating same-named models across providers); static table entries omit it. */ description?: string; @@ -18,6 +21,29 @@ export interface ModelProviderGroups { groups: Array<{ label: string; options: ModelOption[] }>; } +/** + * Identity of one entry in a model menu. The model id alone is not unique once a list spans + * accounts — a direct DeepSeek account and an OpenRouter one both serve `deepseek-v4-pro` — and + * reusing it as a React key or a radio value collapses the two into one unselectable row. + */ +export function modelChoiceKey(option: ModelOption): string { + return `${option.accountId ?? ''}:${option.id}`; +} + +/** 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,75 +70,19 @@ 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[]; - -/** - * Curated model choices, keyed by adapter — only adapters with a *verified* live model switch get - * an entry, and every id was confirmed by reading the served model back off a live stream (source - * reading is not enough: claude-code's first design silently ignored the override). Legacy models - * are included deliberately — the choice belongs to the user. Anthropic ids and lifecycle come from - * https://platform.claude.com/docs/en/about-claude/models/overview. - * claude-opus-4-1 is deliberately excluded: setModel() accepts it but claude-opus-5 is silently - * served instead. Offering claude-fable-5 to everyone is safe: accounts without access get a hard - * CLI error and the picker keeps the previous model (confirm-then-reflect). - * `[1m]` ids (`claude-opus-5[1m]`) are a claude-code-side context tier, not Anthropic model ids; - * none are listed, and resolveModel() cannot fold one back onto its base entry. - * Keeping this table static is a deliberate CODE-104 decision (the dynamic reference - * implementation lives in closed PR #52); refresh it by hand under the discipline above. - * codex ids/labels are the app-server's `model/list` verbatim; switches apply from the next turn, - * not mid-turn. opencode and pi have no entry — see their adapters' comments for why. - */ -export const AGENT_MODEL_OPTIONS: Partial> = { - 'claude-code': [ - { id: 'claude-fable-5', label: 'Fable 5' }, - { id: 'claude-opus-5', label: 'Opus 5' }, - { id: 'claude-opus-4-8', label: 'Opus 4.8' }, - { id: 'claude-opus-4-7', label: 'Opus 4.7 (Legacy)' }, - { id: 'claude-opus-4-6', label: 'Opus 4.6 (Legacy)' }, - { id: 'claude-sonnet-5', label: 'Sonnet 5' }, - { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (Legacy)' }, - { id: 'claude-haiku-4-5', label: 'Haiku 4.5' }, - ], - codex: [ - { - id: 'gpt-5.6-sol', - label: 'GPT-5.6-Sol', - effortLevels: [...CODEX_BASE_EFFORTS, 'max', 'ultra'], - }, - { - id: 'gpt-5.6-terra', - label: 'GPT-5.6-Terra', - effortLevels: [...CODEX_BASE_EFFORTS, 'max', 'ultra'], - }, - { - id: 'gpt-5.6-luna', - label: 'GPT-5.6-Luna', - effortLevels: [...CODEX_BASE_EFFORTS, 'max'], - }, - { id: 'gpt-5.5', label: 'GPT-5.5', effortLevels: [...CODEX_BASE_EFFORTS] }, - { id: 'gpt-5.4', label: 'GPT-5.4', effortLevels: [...CODEX_BASE_EFFORTS] }, - { id: 'gpt-5.4-mini', label: 'GPT-5.4-Mini', effortLevels: [...CODEX_BASE_EFFORTS] }, - ], - // Grok Build headless: model is a spawn-time `-m` flag (verified 0.2.102: grok-4.5). - 'grok-build': [{ id: 'grok-4.5', label: 'Grok 4.5' }], -}; diff --git a/packages/presentation/ui/src/shell/composer-controls.tsx b/packages/presentation/ui/src/shell/composer-controls.tsx index 7860235a5..2462bf7e1 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={ @@ -176,47 +145,12 @@ export function AccountList({ {t('noMatches')} ) : null} - {!loading && needle === '' && accounts.length === 0 && detectedLogins.length === 0 ? ( + {!loading && needle === '' && accounts.length === 0 ? (
  • {t('emptyTitle')} {t('emptyHint')}
  • ) : null} - {!loading && needle === '' - ? detectedLogins.map((login) => ( -
  • - -
  • - )) - : null}
    diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 5b3dc5219..054e6fe73 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'; @@ -55,10 +56,9 @@ export interface ShellFrameProps /** Frontend capability stub used until attachment support is advertised by sessions. */ attachmentSupport?: AttachmentSupportByAgent; agentCatalogs?: AgentStartCatalogs; - /** Effective daemon-configured default models for new sessions; null while unresolved. */ - newSessionDefaultModels: Readonly>> | null; - /** Last model accepted by LinkCode per provider, submitted as a new-session override. */ - newSessionPreferredModels: Readonly>>; + /** The models each agent may run on, pooled from the accounts enabled for it, in the order the + * pickers offer them — the head is the agent's default. */ + accountModels: Readonly>> | null; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; newSessionPreferredBranches: Readonly>; @@ -130,8 +130,7 @@ export function ShellFrame({ runtimeCues, attachmentSupport, agentCatalogs, - newSessionDefaultModels, - newSessionPreferredModels, + accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -221,8 +220,7 @@ export function ShellFrame({ runtimeCues={runtimeCues} attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} - defaultModels={newSessionDefaultModels} - preferredModels={newSessionPreferredModels} + accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} @@ -243,6 +241,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}