diff --git a/AGENTS.md b/AGENTS.md index ee0d1beb..e9f69d6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ Large rewrites are encouraged when they're the right fix — replace subsystems - Keep table-definition / schema modules free of hooks and browser APIs so they stay importable anywhere. - Directory names must describe responsibility, not incidental data. For example, a sidebar footer belongs with sidebar/workbench presentation, not in a `host/` folder just because it displays host state; a layout adapter belongs under layout, not a one-file pseudo-subsystem. - Terminology: the product term **Thread** is the code/wire term **`session`** — the rename is UI/i18n-only. Never rename `session` in wire or code identifiers. +- Terminology: **"provider" means the account/service** (DeepSeek, OpenRouter) — never the agent. The agent is a **Harness**, so client-side UI text and identifiers use that (`selectableHarnesses`, `onHarnessChange`, `lastHarness`); `AgentKind` and every wire/daemon term stay as they are. The two meanings used to collide in adjacent UI — the composer's "provider" picker chose the *agent* while the Providers settings page meant accounts. `groupModelsByProvider` is the genuine exception: it groups by *model* provider. ## Tooling And Aliases diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 805fcdfa..37525e94 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -86,6 +86,11 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr tables in `src/db/schema.ts`). The zod `SessionRecordSchema` is the contract: rows are re-validated through it on load; the table is just storage. After editing `src/db/schema.ts`, run `pnpm -F @linkcode/daemon exec drizzle-kit generate` and commit `drizzle/` — migrations run at boot. + - **A record field with no column is dropped in silence.** The store enumerates columns on write and + rebuilds the record on read, so an `.optional()` field added to the schema alone survives until the + next boot and then parses cleanly as `undefined`. Adding one is three edits (column, write, read) + plus a migration, and the engine's `InMemorySessionStore` cannot catch a missed one — only a + round-trip through this store can (`__tests__/session-store.test.ts`). - **`runtime.json`** — endpoint discovery (`{name,pid,startedAt,listeners:[{type,url}]}`), written `0600` only AFTER every listener binds and removed on graceful `SIGINT`/`SIGTERM` shutdown. diff --git a/apps/daemon/drizzle/0009_add_session_run_pin.sql b/apps/daemon/drizzle/0009_add_session_run_pin.sql new file mode 100644 index 00000000..cb28147b --- /dev/null +++ b/apps/daemon/drizzle/0009_add_session_run_pin.sql @@ -0,0 +1,4 @@ +ALTER TABLE `session_runs` ADD `account_id` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `model` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `effort` text;--> statement-breakpoint +ALTER TABLE `session_runs` ADD `approval_policy_id` text; \ No newline at end of file diff --git a/apps/daemon/drizzle/meta/0009_snapshot.json b/apps/daemon/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..1ae3c26e --- /dev/null +++ b/apps/daemon/drizzle/meta/0009_snapshot.json @@ -0,0 +1,892 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d784a44f-1c49-4484-ad8b-7b6934f24d5e", + "prevId": "df784a45-0a6e-40a4-9dcd-52336eb9bdcf", + "tables": { + "loop_iterations": { + "name": "loop_iterations", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_session_id": { + "name": "worker_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifier_session_id": { + "name": "verifier_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verdict_json": { + "name": "verdict_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "loop_iterations_loop_id_loops_loop_id_fk": { + "name": "loop_iterations_loop_id_loops_loop_id_fk", + "tableFrom": "loop_iterations", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["loop_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "loop_iterations_loop_id_index_pk": { + "columns": ["loop_id", "index"], + "name": "loop_iterations_loop_id_index_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loops": { + "name": "loops", + "columns": { + "loop_id": { + "name": "loop_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "spec_json": { + "name": "spec_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iteration_count": { + "name": "iteration_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_runs_schedule_started_idx": { + "name": "schedule_runs_schedule_started_idx", + "columns": ["schedule_id", "started_at"], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_schedule_id_fk": { + "name": "schedule_runs_schedule_id_schedules_schedule_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": ["schedule_id"], + "columnsTo": ["schedule_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cadence_type": { + "name": "cadence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_config_json": { + "name": "target_config_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_reason": { + "name": "completed_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "misfire_policy": { + "name": "misfire_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "schedules_next_run_at_idx": { + "name": "schedules_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_resources": { + "name": "session_resources", + "columns": { + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_type": { + "name": "locator_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator": { + "name": "locator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_locator_key": { + "name": "normalized_locator_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_resources_session_idx": { + "name": "session_resources_session_idx", + "columns": ["session_id"], + "isUnique": false + }, + "session_resources_locator_idx": { + "name": "session_resources_locator_idx", + "columns": ["session_id", "normalized_locator_key"], + "isUnique": true + } + }, + "foreignKeys": { + "session_resources_session_id_sessions_session_id_fk": { + "name": "session_resources_session_id_sessions_session_id_fk", + "tableFrom": "session_resources", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_runs": { + "name": "session_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_id": { + "name": "history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_policy_id": { + "name": "approval_policy_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_runs_session_id_idx": { + "name": "session_runs_session_id_idx", + "columns": ["session_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_runs_session_id_sessions_session_id_fk": { + "name": "session_runs_session_id_sessions_session_id_fk", + "tableFrom": "session_runs", + "tableTo": "sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_history_id": { + "name": "origin_history_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_imported_at": { + "name": "origin_imported_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_via": { + "name": "created_via", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_kind": { + "name": "automation_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_updated_at_idx": { + "name": "sessions_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "parent_workspace_id": { + "name": "parent_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_cwd_unique": { + "name": "workspaces_cwd_unique", + "columns": ["cwd"], + "isUnique": true + }, + "workspaces_last_used_at_idx": { + "name": "workspaces_last_used_at_idx", + "columns": ["last_used_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_root": { + "name": "repo_root", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "worktrees_repo_root_branch_unique": { + "name": "worktrees_repo_root_branch_unique", + "columns": ["repo_root", "branch"], + "isUnique": true + }, + "worktrees_session_id_unique": { + "name": "worktrees_session_id_unique", + "columns": ["session_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/daemon/drizzle/meta/_journal.json b/apps/daemon/drizzle/meta/_journal.json index d6f2cca1..43301504 100644 --- a/apps/daemon/drizzle/meta/_journal.json +++ b/apps/daemon/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1785425562289, "tag": "0008_add_session_resources", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1786331414776, + "tag": "0009_add_session_run_pin", + "breakpoints": true } ] } diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index b7ec922d..399b3b69 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -76,8 +76,9 @@ describe('loadConfig providers', () => { const config = loadConfig(vault); + // `defaultModel` carries over as the persisted pick; without that it would be silently stripped. expect(config.providers).toEqual({ - 'claude-code': { enabled: true, defaultModel: 'sonnet' }, + 'claude-code': { enabled: true, model: 'sonnet' }, }); expect(errorSpy).toHaveBeenCalled(); }); @@ -236,6 +237,14 @@ describe('loadConfig accounts', () => { expect(errorSpy).toHaveBeenCalled(); }); + it("carries a pre-selection account's single model over as its picked set", () => { + writeAccountsConfig([{ ...validAccount, model: 'deepseek-v4-pro' }]); + + expect(loadConfig(vault).accounts).toEqual([ + { ...validAccount, models: [{ id: 'deepseek-v4-pro' }] }, + ]); + }); + it('drops an account whose stored secret is gone, rather than half-loading it', () => { const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); // The post-migration on-disk shape: an api-key credential with no key. With an empty vault the diff --git a/apps/daemon/src/__tests__/session-store.test.ts b/apps/daemon/src/__tests__/session-store.test.ts new file mode 100644 index 00000000..921b73d0 --- /dev/null +++ b/apps/daemon/src/__tests__/session-store.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SessionRecordSchema } from '@linkcode/schema'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createSessionStore } from '../session-store'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function databasePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'linkcode-session-store-')); + temporaryDirectories.push(directory); + return join(directory, 'daemon.db'); +} + +describe('SQLite session store', () => { + /** + * The engine reads a thread's own picks back off its runs to relaunch it, so a field this table + * drops is a thread silently returning to the agent's configured default on the next daemon boot. + * The in-memory store round-trips whole objects and cannot catch that; only this can. + */ + it('round-trips every field of a run, not just the ones the engine happens to set', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-pinned', + kind: 'claude-code', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 2, + runs: [ + { startedAt: 1, endedAt: 2, historyId: 'native-1', accountId: 'acc_first' }, + { + startedAt: 3, + historyId: 'native-2', + accountId: 'acc_second', + model: 'model-second', + effort: 'xhigh', + approvalPolicyId: 'acceptEdits', + }, + ], + }); + await createSessionStore(database).save(record); + + expect(await createSessionStore(database).load()).toEqual([record]); + }); + + it('keeps run order across a reload, since the array position is part of the record', async () => { + const database = await databasePath(); + const record = SessionRecordSchema.parse({ + sessionId: 'session-ordered', + kind: 'codex', + cwd: '/repo', + origin: { type: 'created' }, + createdAt: 1, + updatedAt: 1, + runs: [ + { startedAt: 1, model: 'first' }, + { startedAt: 2, model: 'second' }, + { startedAt: 3, model: 'third' }, + ], + }); + const store = createSessionStore(database); + await store.save(record); + // A later save rewrites the whole run list; the newest run is what a relaunch reads back. + await store.save({ ...record, runs: [...record.runs, { startedAt: 4, model: 'fourth' }] }); + + const [reloaded] = await createSessionStore(database).load(); + expect(reloaded.runs.map((run) => run.model)).toEqual(['first', 'second', 'third', 'fourth']); + }); +}); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index a9387305..690e1cce 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -211,7 +211,7 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed { // secret that is gone fails the schema and lands in the same drop-and-log path as a malformed one. const attached = withAccountSecret(store, value); migrated ||= attached.migrated; - const account = AccountSchema.safeParse(attached.value); + const account = AccountSchema.safeParse(withPickedModels(attached.value)); if (!account.success) { logger.warn({ operation: 'config.load' }, 'Dropping invalid account config'); continue; @@ -221,6 +221,25 @@ function parseAccounts(store: SecretStore, raw: unknown): Parsed { return { value: accounts, migrated }; } +/** Pre-selection configs stored one free-text model per account; carry it over as the picked set, + * or zod strips the unknown key and the user silently loses their model. Idempotent. */ +function withPickedModels(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value; + const { model, ...rest } = value as { model?: unknown; models?: unknown }; + if (typeof model !== 'string' || model === '' || rest.models !== undefined) return rest; + return { ...rest, models: [{ id: model }] }; +} + +/** Same carry-over for the per-agent default, which is now the persisted pick. */ +function withPickedModel(value: unknown): unknown { + if (typeof value !== 'object' || value === null) return value; + const { defaultModel, ...rest } = value as { defaultModel?: unknown; model?: unknown }; + if (typeof defaultModel !== 'string' || defaultModel === '' || rest.model !== undefined) { + return rest; + } + return { ...rest, model: defaultModel }; +} + /** * Parse element by element like {@link parseAccounts}: one invalid server is dropped and logged, * never blanking the rest. @@ -270,7 +289,7 @@ function parseProviders(store: SecretStore, raw: unknown): Parsed ({ 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 e153a5cc..b8e72466 100644 --- a/apps/desktop/src/renderer/src/settings/history-import-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/history-import-tab.tsx @@ -71,7 +71,7 @@ export function HistoryImportTab({ kind }: { kind: AgentKind }): React.ReactNode <> - {t('panelTitle', { provider: AGENT_LABELS[kind] })} + {t('panelTitle', { harness: AGENT_LABELS[kind] })} {surface.count > 0 && ( diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index f7722673..c0684084 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -80,7 +80,8 @@ export function DesktopShell({ attachmentSupport, agentCatalogs, newSessionDefaultModels, - newSessionPreferredModels, + newSessionDefaultAccounts, + accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -429,7 +430,8 @@ export function DesktopShell({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} - preferredModels={newSessionPreferredModels} + defaultAccounts={newSessionDefaultAccounts} + accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} @@ -453,6 +455,8 @@ export function DesktopShell({ composer={conversationComposer} agentKind={active?.kind} agentLabel={agentLabel} + accountModels={active ? accountModels?.[active.kind] : undefined} + accountId={active?.accountId} attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])} cwd={active?.cwd} runtimeCues={runtimeCues} diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index 12adad85..10bfb975 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -15,6 +15,7 @@ import { chromium } from 'playwright-core'; const webviewDir = fileURLToPath(new URL('..', import.meta.url)); const daemonDir = fileURLToPath(new URL('../../daemon', import.meta.url)); const viteCli = fileURLToPath(new URL('../../bin/vite.js', import.meta.resolve('vite'))); +const newSessionDefaultsKey = 'linkcode.workbench.new-session-defaults:v7'; const mockThreadTitle = 'Wire the workbench to the daemon'; const mockChatThreadTitle = 'Prototype without git'; const longThreadTitle = 'Long thread · navigation testbed'; @@ -69,12 +70,11 @@ async function sendPrompt(page: Page, prompt: string, appErrors: string[]): Prom } async function verifyNewChatIsolation(page: Page, appErrors: string[]): Promise { - await page.evaluate(() => { - localStorage.setItem( - 'linkcode.workbench.new-session-defaults:v5', - JSON.stringify({ state: { lastProvider: 'pi' }, version: 0 }), - ); - }); + // Must track NEW_SESSION_DEFAULTS_STORAGE_KEY and its schema: a stale blob is discarded silently, + // the new chat falls back to claude-code, and its `missing` mock runtime blocks Send forever. + await page.evaluate((key) => { + localStorage.setItem(key, JSON.stringify({ state: { lastHarness: 'pi' }, version: 0 })); + }, newSessionDefaultsKey); await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('[data-thread-title]', { hasText: mockChatThreadTitle }).waitFor(); await page.locator('[data-thread-title]', { hasText: mockChatThreadTitle }).click(); @@ -101,7 +101,14 @@ async function verifyNewChatIsolation(page: Page, appErrors: string[]): Promise< }); }); - await page.getByRole('button', { name: 'Send' }).click(); + const send = page.getByRole('button', { name: 'Send' }); + if (await send.isDisabled()) { + throw new Error( + `New chat cannot send: the ${newSessionDefaultsKey} seed did not resolve a sendable harness. ` + + 'Check the storage key version and the persisted field names against new-session-defaults-store.ts.', + ); + } + await send.click(); await page.getByText(`You said: ${prompt}`, { exact: false }).waitFor({ timeout: 15000 }); const titles = await page.evaluate(() => { const finish = Reflect.get(window, '__newChatIsolationProbe') as diff --git a/packages/client/core/src/client.ts b/packages/client/core/src/client.ts index c075411e..61694d36 100644 --- a/packages/client/core/src/client.ts +++ b/packages/client/core/src/client.ts @@ -1,6 +1,5 @@ import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -793,8 +792,8 @@ export class LinkCodeClient { return this.control.setSubscriptionMode(mode); } - setModel(sessionId: SessionId, model: string): Promise { - return this.control.setModel(sessionId, model); + setModel(sessionId: SessionId, model: string, accountId?: string): Promise { + return this.control.setModel(sessionId, model, accountId); } setEffort(sessionId: SessionId, effort: EffortLevel): Promise { @@ -841,9 +840,12 @@ export class LinkCodeClient { return this.control.getAccounts(); } - /** Model list an endpoint serves, read daemon-side with a not-yet-saved secret. */ - probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { - return this.control.probeAccountModels(endpoint, secret); + /** Models a service serves, read daemon-side with an unsaved secret or a saved account's own. */ + probeAccountModels( + service: string, + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }, + ): Promise { + return this.control.probeAccountModels(service, credential); } /** Masked custom MCP servers (env/header keys only — the daemon never returns values). */ diff --git a/packages/client/core/src/client/control-channel.ts b/packages/client/core/src/client/control-channel.ts index 03202a44..15f795e6 100644 --- a/packages/client/core/src/client/control-channel.ts +++ b/packages/client/core/src/client/control-channel.ts @@ -1,6 +1,5 @@ import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -267,9 +266,15 @@ export class ControlChannel { })); } - /** Switch the session's model, going forward. Rejects if the adapter can't rebind a live session. */ - setModel(sessionId: SessionId, model: string): Promise { - return this.send(sessionId, { type: 'set-model', model }); + /** Switch the session's model, going forward. Rejects if the adapter can't rebind a live session. + * `accountId` names the account the model came from: picking one the session isn't running on + * restarts it on that account and resumes the transcript. */ + setModel(sessionId: SessionId, model: string, accountId?: string): Promise { + return this.send(sessionId, { + type: 'set-model', + model, + ...(accountId !== undefined && { accountId }), + }); } /** Switch the session's reasoning-effort level, going forward. Same acceptance rule as setModel. */ @@ -579,14 +584,19 @@ export class ControlChannel { })); } - /** Ask the daemon what an endpoint serves, using a not-yet-saved secret: the account forms offer - * the answer as the model picker. The daemon must do it — the renderer's CSP blocks the fetch. */ - probeAccountModels(endpoint: AccountEndpoint, secret: AccountSecret): Promise { + /** Ask the daemon which models a service serves, so the account forms can offer a real list to + * pick from. The daemon must do it — the renderer's CSP blocks the fetch, and it resolves the list + * URL from the service catalog itself. Pass a secret the add form has not saved yet, or the id of + * a saved account so its stored secret never leaves the daemon. */ + probeAccountModels( + service: string, + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }, + ): Promise { return this.sendCorrelated('accountModels', (clientReqId) => ({ kind: 'config.probe-models', clientReqId, - endpoint, - secret, + service, + credential, })); } diff --git a/packages/client/sdk/src/client.ts b/packages/client/sdk/src/client.ts index b1990617..e2798f0a 100644 --- a/packages/client/sdk/src/client.ts +++ b/packages/client/sdk/src/client.ts @@ -10,7 +10,6 @@ import type { import { LinkCodeClient } from '@linkcode/client-core'; import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -243,8 +242,8 @@ export class LinkCodeSdkClient { return toResult(this.raw.cancel(sessionId)); } - setModel(sessionId: SessionId, model: string): RequestResult<{ ok: true }> { - return toResult(this.raw.setModel(sessionId, model)); + setModel(sessionId: SessionId, model: string, accountId?: string): RequestResult<{ ok: true }> { + return toResult(this.raw.setModel(sessionId, model, accountId)); } setEffort(sessionId: SessionId, effort: EffortLevel): RequestResult<{ ok: true }> { @@ -291,12 +290,12 @@ export class LinkCodeSdkClient { return toResult(this.raw.setAccounts(accounts)); } - /** Enumerate what an endpoint serves, using a secret that is not saved yet. */ + /** Enumerate the models a service serves, with an unsaved secret or a saved account's own. */ probeAccountModels( - endpoint: AccountEndpoint, - secret: AccountSecret, + service: string, + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }, ): RequestResult { - return toResult(this.raw.probeAccountModels(endpoint, secret)); + return toResult(this.raw.probeAccountModels(service, credential)); } /** Masked custom MCP servers (data plane) — env/header keys only, never a secret value. */ diff --git a/packages/client/sdk/src/operations.ts b/packages/client/sdk/src/operations.ts index ed353144..5b8e0d45 100644 --- a/packages/client/sdk/src/operations.ts +++ b/packages/client/sdk/src/operations.ts @@ -7,7 +7,6 @@ import type { } from '@linkcode/client-core'; import type { Account, - AccountEndpoint, AccountModel, AccountSecret, Accounts, @@ -222,9 +221,9 @@ export function cancelTurn( } export function setModel( - options: Options<{ sessionId: SessionId; model: string }>, + options: Options<{ sessionId: SessionId; model: string; accountId?: string }>, ): RequestResult<{ ok: true }> { - return resolveClient(options).setModel(options.sessionId, options.model); + return resolveClient(options).setModel(options.sessionId, options.model, options.accountId); } export function setEffort( @@ -278,9 +277,12 @@ export function setAccounts(options: Options<{ accounts: Accounts }>): RequestRe } export function probeAccountModels( - options: Options<{ endpoint: AccountEndpoint; secret: AccountSecret }>, + options: Options<{ + service: string; + credential: { type: 'inline'; secret: AccountSecret } | { type: 'account'; accountId: string }; + }>, ): RequestResult { - return resolveClient(options).probeAccountModels(options.endpoint, options.secret); + return resolveClient(options).probeAccountModels(options.service, options.credential); } /** Masked custom MCP servers — env/header keys only, never a secret value. */ diff --git a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts index a8d49fda..eea19086 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/default-models.test.ts @@ -2,9 +2,15 @@ import type { Accounts, ProvidersConfig } from '@linkcode/schema'; import { getProviderConfig } from '@linkcode/sdk'; +import { modelChoiceKey } from '@linkcode/ui'; import { cleanup, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configuredDefaultModels, useConfiguredDefaultModels } from '../default-models'; +import { + accountModelOptions, + configuredDefaultModels, + useAccountModelOptions, + useConfiguredDefaultModels, +} from '../default-models'; const { useDataMock } = vi.hoisted(() => ({ useDataMock: vi.fn() })); @@ -27,46 +33,124 @@ afterEach(() => { }); describe('configuredDefaultModels', () => { - it('uses an active account model before the provider default and ignores stale bindings', () => { + it('reads the per-agent pick and reports nothing for an agent that has none', () => { const providers = { - codex: { - enabled: true, - activeAccountId: 'account-1', - defaultModel: 'provider-model', - }, - 'claude-code': { - enabled: true, - activeAccountId: 'missing-account', - defaultModel: 'claude-provider-model', - }, + codex: { enabled: true, activeAccountId: 'account-1', model: 'gpt-5.6-sol' }, + // Bound but unpicked: no model to report, so a session start refuses rather than guessing. + 'claude-code': { enabled: true, activeAccountId: 'account-1' }, } satisfies ProvidersConfig; - const accounts = [ - { - id: 'account-1', - label: 'Configured account', - credential: { type: 'oauth', agent: 'codex' }, - model: 'account-model', - createdAt: 0, - }, - ] satisfies Accounts; - expect(configuredDefaultModels(providers, accounts)).toEqual({ - codex: 'account-model', - 'claude-code': 'claude-provider-model', - }); + expect(configuredDefaultModels(providers)).toEqual({ codex: 'gpt-5.6-sol' }); }); - it('keeps defaults unresolved until both configuration sources have loaded', () => { + it('keeps the pick unresolved until the provider config has loaded', () => { const { result, rerender } = renderHook(() => useConfiguredDefaultModels()); expect(result.current).toBeNull(); providersData = {}; rerender(); + expect(result.current).toEqual({}); + }); +}); + +const anthropicAccount = { + id: 'acc_anthropic', + label: 'Anthropic', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'k' }, + models: [{ id: 'claude-opus-5', label: 'Opus 5' }], + createdAt: 0, +} satisfies Accounts[number]; + +const deepseekAccount = { + id: 'acc_deepseek', + label: 'DeepSeek', + service: 'deepseek', + credential: { type: 'api-key', key: 'k' }, + models: [{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, { id: 'deepseek-v4-flash' }], + createdAt: 0, +} satisfies Accounts[number]; + +describe('accountModelOptions', () => { + it('spans every account that can back the agent, tagged with the account it came from', () => { + const options = accountModelOptions([anthropicAccount, deepseekAccount]); + + // claude-code speaks both: Anthropic natively, DeepSeek through its Anthropic-shaped endpoint. + expect(options['claude-code']).toEqual([ + { + id: 'claude-opus-5', + label: 'Opus 5', + description: 'Anthropic', + accountId: 'acc_anthropic', + }, + { + id: 'deepseek-v4-pro', + label: 'DeepSeek V4 Pro', + description: 'DeepSeek', + accountId: 'acc_deepseek', + }, + { + id: 'deepseek-v4-flash', + // A relay ships bare ids; the id doubles as the label rather than rendering blank. + label: 'deepseek-v4-flash', + description: 'DeepSeek', + accountId: 'acc_deepseek', + }, + ]); + }); + + it('omits an agent no account can back, and keeps a bindable-but-unpicked one empty', () => { + // grok-build only accepts an xAI account, so neither of these can back it. + expect(accountModelOptions([anthropicAccount, deepseekAccount])['grok-build']).toBeUndefined(); + // Bindable with nothing ticked: present-and-empty, which is what blocks sending. + expect( + accountModelOptions([{ ...anthropicAccount, models: undefined }])['claude-code'], + ).toEqual([]); + }); + + it('offers only the accounts enabled for that agent, and every bindable one when unset', () => { + const pool = [anthropicAccount, deepseekAccount]; + // Both can back claude-code, and an absent list means the user has narrowed nothing. + expect(accountModelOptions(pool, {})['claude-code']).toHaveLength( + accountModelOptions(pool)['claude-code']?.length ?? 0, + ); + + const narrowed = accountModelOptions(pool, { + 'claude-code': { enabled: true, enabledAccountIds: ['acc_deepseek'] }, + })['claude-code']; + expect(new Set(narrowed?.map((option) => option.accountId))).toEqual(new Set(['acc_deepseek'])); + + // Disabling every account leaves it present-and-empty, which blocks sending rather than + // silently handing the choice back to the agent. + expect( + accountModelOptions(pool, { 'claude-code': { enabled: true, enabledAccountIds: [] } })[ + 'claude-code' + ], + ).toEqual([]); + }); + + it('keeps same-id models from two accounts as separate, identifiable entries', () => { + const shared = { ...anthropicAccount, id: 'acc_other', label: 'Work key' }; + const options = accountModelOptions([anthropicAccount, shared])['claude-code'] ?? []; + + expect(options).toHaveLength(2); + expect(new Set(options.map(modelChoiceKey)).size).toBe(2); + }); + + it('stays unresolved until both the account pool and the enabled lists have loaded', () => { + const { result, rerender } = renderHook(() => useAccountModelOptions()); + expect(result.current).toBeNull(); + // Accounts alone are not enough: the enabled list narrows them, so offering the unnarrowed set + // would briefly show models the user disabled. accountsData = []; rerender(); + expect(result.current).toBeNull(); + + providersData = {}; + rerender(); expect(result.current).toEqual({}); }); }); diff --git a/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx new file mode 100644 index 00000000..6368bc97 --- /dev/null +++ b/packages/client/workbench/src/settings/providers/__tests__/model-selection.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import type { AccountModel } from '@linkcode/schema'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { nullthrow } from 'foxts/guard'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ModelSelection } from '../model-selection'; + +function translateKey(key: string): string { + return key; +} + +vi.mock('use-intl', () => ({ + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +const NONE: AccountModel[] = []; +const RE_REFRESH = /models\.refresh/; +const RE_ADD = /models\.add/; +const RE_BAD_KEY = /invalid api key/; + +/** Renders with the selection held above, the way both account forms do through `Controller`. */ +function Harness({ + initial = NONE, + onFetch, +}: { + initial?: AccountModel[]; + onFetch?: () => Promise; +}): React.ReactNode { + const [selected, setSelected] = useState(initial); + return ; +} + +function rowFor(id: string): HTMLInputElement { + const row = nullthrow(screen.getByText(id).closest('label'), `no row for ${id}`); + return nullthrow(row.querySelector('input'), `no checkbox for ${id}`); +} + +describe('ModelSelection', () => { + it('fetches a list, and only ticked ids become the set', async () => { + const onFetch = vi + .fn() + .mockResolvedValue([ + { id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, + { id: 'deepseek-v4-flash' }, + ]); + render(); + + fireEvent.click(screen.getByRole('button', { name: RE_REFRESH })); + await waitFor(() => expect(screen.getByText('deepseek-v4-pro')).toBeTruthy()); + + expect(rowFor('deepseek-v4-pro').checked).toBe(false); + fireEvent.click(rowFor('deepseek-v4-pro')); + await waitFor(() => expect(rowFor('deepseek-v4-pro').checked).toBe(true)); + expect(rowFor('deepseek-v4-flash').checked).toBe(false); + }); + + it('keeps a picked id the list no longer returns, rather than silently unpicking it', async () => { + // A freeform entry, or one the vendor has retired: dropping it would change the account's set + // behind the user's back on the next refresh. + const onFetch = vi.fn().mockResolvedValue([{ id: 'gpt-5' }]); + render(); + + fireEvent.click(screen.getByRole('button', { name: RE_REFRESH })); + await waitFor(() => expect(screen.getByText('gpt-5')).toBeTruthy()); + + expect(rowFor('retired-model').checked).toBe(true); + }); + + it('adds a hand-typed id and refuses a duplicate', () => { + render(); + + const input = screen.getByPlaceholderText('models.addPlaceholder'); + fireEvent.change(input, { target: { value: 'typed-model' } }); + fireEvent.click(screen.getByRole('button', { name: RE_ADD })); + expect(rowFor('typed-model').checked).toBe(true); + expect((input as HTMLInputElement).value).toBe(''); + + fireEvent.change(input, { target: { value: 'already' } }); + fireEvent.click(screen.getByRole('button', { name: RE_ADD })); + expect(screen.getAllByText('already')).toHaveLength(1); + }); + + it("surfaces the fetch failure's own reason instead of swallowing it", async () => { + const onFetch = vi.fn().mockRejectedValue(new Error('401 Unauthorized — invalid api key')); + render(); + + fireEvent.click(screen.getByRole('button', { name: RE_REFRESH })); + await waitFor(() => expect(screen.getByText(RE_BAD_KEY)).toBeTruthy()); + }); + + it('offers no fetch when nothing can list the endpoint', () => { + render(); + + expect(screen.queryByRole('button', { name: RE_REFRESH })).toBeNull(); + expect(screen.getByText('models.hintUnlistable')).toBeTruthy(); + }); +}); diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index 058ccb53..99a199cb 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -6,37 +6,61 @@ import { boundAgentKinds, maskSecret, providerAccountListViewModel, - withBinding, + withAccountEnabled, + withDefaultAccount, withModel, withoutAccount, } from '../view'; const providers: ProvidersConfig = { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', defaultModel: 'claude-opus-4-8' }, + 'claude-code': { enabled: true, activeAccountId: 'acc_a', model: 'claude-opus-4-8' }, codex: { enabled: false, activeAccountId: 'acc_b' }, opencode: { enabled: true }, }; -describe('binding transforms', () => { - it('binds while preserving the entry and defaults enabled for a fresh kind', () => { - const next = withBinding(providers, 'codex', 'acc_a'); +describe('provider config transforms', () => { + it('sets the default while preserving the entry and defaults enabled for a fresh kind', () => { + const next = withDefaultAccount(providers, 'codex', 'acc_a'); expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_a' }); - expect(withBinding(providers, 'pi', 'acc_a').pi).toEqual({ + expect(withDefaultAccount(providers, 'pi', 'acc_a').pi).toEqual({ enabled: true, activeAccountId: 'acc_a', }); }); - it('unbinds by dropping only activeAccountId', () => { - const next = withBinding(providers, 'claude-code', undefined); - expect(next['claude-code']).toEqual({ enabled: true, defaultModel: 'claude-opus-4-8' }); + it('clears the default by dropping only activeAccountId', () => { + const next = withDefaultAccount(providers, 'claude-code', undefined); + expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); + }); + + it('drops a pick the newly bound account does not offer, and keeps one it does', () => { + const offers = (id: string, models: string[]): Accounts[number] => ({ + id, + label: id, + credential: { type: 'api-key', key: 'k' }, + models: models.map((model) => ({ id: model })), + createdAt: 0, + }); + const pool = [offers('acc_keep', ['claude-opus-4-8']), offers('acc_drop', ['deepseek-v4-pro'])]; + + // Moving the default to an account that lists the pick leaves it alone. + expect(withDefaultAccount(providers, 'claude-code', 'acc_keep', pool)['claude-code']).toEqual({ + enabled: true, + activeAccountId: 'acc_keep', + model: 'claude-opus-4-8', + }); + // One that does not would otherwise start the next session on a model it never listed. + expect(withDefaultAccount(providers, 'claude-code', 'acc_drop', pool)['claude-code']).toEqual({ + enabled: true, + activeAccountId: 'acc_drop', + }); }); it('sets and clears the default model without touching the binding', () => { expect(withModel(providers, 'claude-code', 'claude-sonnet-5')['claude-code']).toEqual({ enabled: true, activeAccountId: 'acc_a', - defaultModel: 'claude-sonnet-5', + model: 'claude-sonnet-5', }); expect(withModel(providers, 'claude-code', undefined)['claude-code']).toEqual({ enabled: true, @@ -44,9 +68,42 @@ describe('binding transforms', () => { }); }); + it('materializes the enabled list from what is bindable on the first disable', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + { id: 'acc_b', label: 'B', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + + // Absent means "all bindable", so disabling one has to write the rest down explicitly. + const disabled = withAccountEnabled(providers, 'opencode', 'acc_a', false, pool); + expect(disabled.opencode?.enabledAccountIds).toEqual(['acc_b']); + // Re-enabling puts it back without duplicating. + const reEnabled = withAccountEnabled(disabled, 'opencode', 'acc_a', true, pool); + expect(reEnabled.opencode?.enabledAccountIds).toEqual(['acc_b', 'acc_a']); + }); + + it('clears the default when the account serving it is disabled', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + // Leaving it would keep resolving unpinned sessions onto an account just removed from the menu. + const next = withAccountEnabled(providers, 'claude-code', 'acc_a', false, pool); + expect(next['claude-code']?.activeAccountId).toBeUndefined(); + expect(next['claude-code']?.enabledAccountIds).toEqual([]); + }); + + it('leaves the default alone when a non-default account is disabled', () => { + const pool: Accounts = [ + { id: 'acc_a', label: 'A', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + { id: 'acc_b', label: 'B', credential: { type: 'api-key', key: 'k' }, createdAt: 0 }, + ]; + const next = withAccountEnabled(providers, 'claude-code', 'acc_b', false, pool); + expect(next['claude-code']?.activeAccountId).toBe('acc_a'); + }); + it('clears every binding of a removed account, identity-stable when none matched', () => { const next = withoutAccount(providers, 'acc_a'); - expect(next['claude-code']).toEqual({ enabled: true, defaultModel: 'claude-opus-4-8' }); + expect(next['claude-code']).toEqual({ enabled: true, model: 'claude-opus-4-8' }); expect(next.codex).toEqual({ enabled: false, activeAccountId: 'acc_b' }); expect(withoutAccount(providers, 'acc_missing')).toBe(providers); }); @@ -58,7 +115,7 @@ describe('view helpers', () => { const snippet = accountConfigSnippet(providers, 'acc_a'); expect(JSON.parse(snippet)).toEqual({ providers: { - 'claude-code': { enabled: true, activeAccountId: 'acc_a', defaultModel: 'claude-opus-4-8' }, + 'claude-code': { enabled: true, activeAccountId: 'acc_a', model: 'claude-opus-4-8' }, }, }); }); @@ -156,7 +213,7 @@ describe('view helpers', () => { service: 'openrouter', credential: { type: 'api-key', key: 'old-secret' }, endpoint: { baseUrl: 'https://old.example.com/v1', protocol: 'openai-chat' }, - model: 'old-model', + models: [{ id: 'old-model' }], extraEnv: { GATEWAY_MODE: 'strict' }, }; @@ -167,7 +224,7 @@ describe('view helpers', () => { secret: 'new-secret', baseUrl: 'https://new.example.com/v1', protocol: 'anthropic', - model: 'new-model', + models: [{ id: 'new-model' }], }), ).toEqual({ id: 'acc_a', @@ -176,7 +233,7 @@ describe('view helpers', () => { service: 'openrouter', credential: { type: 'auth-token', token: 'new-secret' }, endpoint: { baseUrl: 'https://new.example.com/v1', protocol: 'anthropic' }, - model: 'new-model', + models: [{ id: 'new-model' }], extraEnv: { GATEWAY_MODE: 'strict' }, }); }); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index 88f91860..185a54f1 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -1,13 +1,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import type { EndpointService, ServiceDescriptor, ServiceGroup } from '@linkcode/providers'; import { + modelListSource, pinnedEndpoint, SERVICE_CATALOG, serviceById, serviceProtocols, templatePlaceholders, } from '@linkcode/providers'; -import type { Account, AccountProtocol, AgentRuntimes } from '@linkcode/schema'; +import type { Account, AccountModel, AccountProtocol, AgentRuntimes } from '@linkcode/schema'; +import { AccountModelSchema } from '@linkcode/schema'; import { AgentOnboardingCard, ServiceIcon } from '@linkcode/ui'; import { Button } from 'coss-ui/components/button'; import { Field, FieldLabel } from 'coss-ui/components/field'; @@ -27,6 +29,8 @@ import { Controller, useForm } from 'react-hook-form'; import { useTranslations } from 'use-intl'; import { z } from 'zod'; import type { AgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; +import type { ModelSources } from './model-selection'; +import { ModelSelection } from './model-selection'; const GROUPS: ServiceGroup[] = ['subscription', 'direct', 'gateway', 'custom']; @@ -43,11 +47,13 @@ function newAccountBase(label: string): Pick, label: string, + models: AccountModel[] = [], ): Account { return { ...newAccountBase(label), service: service.id, credential: { type: 'oauth', agent: service.agent }, + ...(models.length > 0 && { models }), }; } @@ -67,7 +73,7 @@ function catalogAccount(service: EndpointService, draft: CatalogDraft): Account ? { type: 'auth-token', token: draft.secret } : { type: 'api-key', key: draft.secret }, ...(!isObjectEmpty(trimmed) && { endpointParams: trimmed }), - ...(draft.model.trim() && { model: draft.model.trim() }), + ...(draft.models.length > 0 && { models: draft.models }), }; } @@ -98,7 +104,7 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account credential: _credential, endpoint: _endpoint, label: _label, - model: _model, + models: _models, ...rest }) => rest)(account); return { @@ -110,7 +116,7 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account : { type: 'api-key', key: draft.secret }, ...(draft.baseUrl.trim() && protocol && { endpoint: { baseUrl: draft.baseUrl.trim(), protocol } }), - ...(draft.model.trim() && { model: draft.model.trim() }), + ...(draft.models.length > 0 && { models: draft.models }), }; } @@ -157,6 +163,7 @@ export function ServiceCatalogView({ /** Step two: the per-service seeded form (or the free-form one for `custom`). */ export function AddAccountForm({ serviceId, + sources, runtimes, onboarding, busy, @@ -164,6 +171,7 @@ export function AddAccountForm({ onSubmit, }: { serviceId: string; + sources?: ModelSources; runtimes: AgentRuntimes | undefined; onboarding: AgentRuntimeOnboarding; busy: boolean; @@ -188,15 +196,16 @@ export function AddAccountForm({ {service.kind === 'oauth' ? ( ) : service.kind === 'endpoint' ? ( - + ) : ( - + )} ); @@ -205,11 +214,13 @@ export function AddAccountForm({ /** Existing-account editor shown inside the account management dialog. */ export function EditAccountForm({ account, + sources, busy, onBack, onSubmit, }: { account: Account; + sources?: ModelSources; busy: boolean; onBack: () => void; onSubmit: (account: Account) => void; @@ -231,44 +242,70 @@ export function EditAccountForm({ {account.credential.type === 'oauth' ? ( - + ) : ( - + )} ); } -const OauthEditDraftSchema = z.object({ label: z.string().min(1) }); +const OauthEditDraftSchema = z.object({ + label: z.string().min(1), + models: z.array(AccountModelSchema), +}); type OauthEditDraft = z.infer; function OauthEditForm({ account, + sources, busy, onSubmit, }: { account: Account; + sources?: ModelSources; busy: boolean; onSubmit: (account: Account) => void; }): React.ReactNode { const t = useTranslations('settings.providers'); const { register, + control, handleSubmit, formState: { isSubmitting }, } = useForm({ resolver: zodResolver(OauthEditDraftSchema), - defaultValues: { label: account.label }, + defaultValues: { label: account.label, models: account.models ?? [] }, }); + const agent = account.credential.type === 'oauth' ? account.credential.agent : undefined; + const fetchModels = agent === undefined || !sources ? undefined : () => sources.oauth(agent); return (
onSubmit({ ...account, label: draft.label.trim() }))} + onSubmit={handleSubmit((draft) => + onSubmit({ + ...account, + label: draft.label.trim(), + ...(draft.models.length > 0 ? { models: draft.models } : { models: undefined }), + }), + )} > {t('form.label')} + ( + + )} + />

{t('oauthEditHint')}

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

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

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

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

+ {error !== undefined ?

{error}

: null} + {listed.length > 0 ? ( +
+ {listed.map((model) => ( + + ))} +
+ ) : null} +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') return; + // Enter here adds an id; letting it bubble would submit the whole account form. + event.preventDefault(); + addDraft(); + }} + placeholder={t('models.addPlaceholder')} + value={draft} + /> + +
+ + ); +} diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index 98955b5b..612174b5 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -21,11 +21,13 @@ import { useAgentRuntimes } from '../../agent-runtime/hooks'; import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; +import { useModelSources } from './model-selection'; import { useProvidersSettingsStore } from './store'; import { providerAccountDetailViewModel, providerAccountListViewModel, - withBinding, + withAccountEnabled, + withDefaultAccount, withModel, withoutAccount, } from './view'; @@ -48,6 +50,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { const bindAccount = useMutation(createAndBindAccount); const saveAccounts = useMutation(setAccounts); const saveProviders = useMutation(setProviderConfig); + // The forms are presentation; only this page sits inside the data-plane provider tree. + const modelSources = useModelSources(); const view = useProvidersSettingsStore((state) => state.view); const select = useProvidersSettingsStore((state) => state.select); @@ -73,8 +77,13 @@ export function ProvidersSettingsPanel(): React.ReactNode { void mutateProviders(); }; - const handleSetBinding = (kind: AgentKind, accountId: string | undefined): void => { - void applyProviders(withBinding(providers ?? {}, kind, accountId)); + const handleSetDefaultAccount = (kind: AgentKind, accountId: string | undefined): void => { + void applyProviders(withDefaultAccount(providers ?? {}, kind, accountId, pool)); + }; + + const handleSetAccountEnabled = (kind: AgentKind, enabled: boolean): void => { + if (!selected) return; + void applyProviders(withAccountEnabled(providers ?? {}, kind, selected.id, enabled, pool)); }; const handleSetModel = (kind: AgentKind, model: string | undefined): void => { @@ -169,6 +178,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { {view.kind === 'add-form' ? ( { @@ -194,7 +205,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { { diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 3467dce9..b3f78dba 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -18,10 +18,11 @@ import type { ProviderAccountListItem, ProviderAccountListViewModel, ProviderAccountRouting, - ProviderBindingStatus, - ProviderBindingViewModel, + ProviderAgentStatus, + ProviderAgentViewModel, ProviderCredentialViewModel, } from '@linkcode/ui'; +import { accountEnabledFor } from './default-models'; /** Pure view helpers for the Providers page — no hooks, unit-testable. */ @@ -33,7 +34,7 @@ export function maskSecret(secret: string): string { return `${secret.slice(0, 6)}…${secret.slice(-4)}`; } -/** Agents whose active provider is this account, in stable agent order. */ +/** Agents that fall back to this account when nothing names one, in stable agent order. */ export function boundAgentKinds( providers: ProvidersConfig | undefined, accountId: string, @@ -86,42 +87,39 @@ function credentialViewModel( }; } -function bindingStatus( +function agentStatus( account: Account, accountLabels: ReadonlyMap, kind: AgentKind, providers: ProvidersConfig | undefined, -): { bound: boolean; status: ProviderBindingStatus; tier: ProviderBindingViewModel['tier'] } { +): Omit { const availability = resolveBinding(account, kind); - const boundId = providers?.[kind]?.activeAccountId; - const bound = boundId === account.id; + const defaultId = providers?.[kind]?.activeAccountId; + const isDefault = defaultId === account.id; + const enabled = accountEnabledFor(providers, kind, account.id); if (availability.tier === 'unavailable') { - if (availability.reason === 'oauth-other-agent' && account.credential.type === 'oauth') { - return { - bound, - tier: availability.tier, - status: { kind: 'unavailable-oauth', agent: account.credential.agent }, - }; - } - return { - bound, - tier: availability.tier, - status: { - kind: - availability.reason === 'endpoint-incomplete' - ? 'unavailable-endpoint-incomplete' - : 'unavailable-protocol', - }, - }; + const status: ProviderAgentStatus = + availability.reason === 'oauth-other-agent' && account.credential.type === 'oauth' + ? { kind: 'unavailable-oauth', agent: account.credential.agent } + : { + kind: + availability.reason === 'endpoint-incomplete' + ? 'unavailable-endpoint-incomplete' + : 'unavailable-protocol', + }; + return { tier: availability.tier, enabled: false, isDefault: false, status }; } return { - bound, tier: availability.tier, - status: bound - ? { kind: 'bound' } - : boundId === undefined - ? { kind: 'no-provider' } - : { kind: 'bound-elsewhere', accountLabel: accountLabels.get(boundId) ?? boundId }, + enabled, + isDefault, + status: !enabled + ? { kind: 'disabled' } + : isDefault + ? { kind: 'default' } + : defaultId === undefined + ? { kind: 'enabled-no-default' } + : { kind: 'enabled', defaultLabel: accountLabels.get(defaultId) ?? defaultId }, }; } @@ -133,12 +131,13 @@ export function providerAccountDetailViewModel( runtimes: AgentRuntimes | undefined, ): ProviderAccountDetailViewModel { const accountLabels = new Map(accounts.map((candidate) => [candidate.id, candidate.label])); - const bindings = AGENT_KINDS.map((kind): ProviderBindingViewModel => { - const binding = bindingStatus(account, accountLabels, kind, providers); + const agents = AGENT_KINDS.map((kind): ProviderAgentViewModel => { + const status = agentStatus(account, accountLabels, kind, providers); + // Only the default account's row edits the default model — the pick belongs to that pairing. return { kind, - ...binding, - currentModel: providers?.[kind]?.defaultModel ?? '', + ...status, + ...(status.isDefault && { defaultModel: providers?.[kind]?.model ?? '' }), }; }); const boundAgents = boundAgentKinds(providers, account.id); @@ -148,13 +147,16 @@ export function providerAccountDetailViewModel( id: account.id, label: account.label, credential: credentialViewModel(account, runtimes), - bindings, + agents, boundAgents, - availableBindingCount: bindings.filter((binding) => binding.tier !== 'unavailable').length, + enabledAgentCount: agents.filter((agent) => agent.enabled).length, + availableAgentCount: agents.filter((agent) => agent.tier !== 'unavailable').length, ...(!(account.service === undefined) && { service: account.service }), ...(!(serviceLabel === undefined) && { serviceLabel }), ...(routing !== undefined && { routing }), - ...(!(account.model === undefined) && { accountModel: account.model }), + ...(account.models !== undefined && { + accountModels: account.models.map(({ id, label }) => ({ id, label: label ?? id })), + }), ...(!(boundAgents.length === 0) && { configPreview: accountConfigSnippet(providers, account.id), }), @@ -219,18 +221,64 @@ export function providerAccountListViewModel( }; } -/** Bind (or, with undefined, unbind) an agent's active account; other fields survive untouched. */ -export function withBinding( +/** + * Set (or, with undefined, clear) the account an agent falls back to when nothing names one. Other + * fields survive untouched — with one exception. The default model lives per agent while the set it + * came from lives on the account, so moving the default can orphan it. Dropping a model the new + * account does not offer leaves the agent unpicked, which blocks its unpinned sends until the user + * chooses again; keeping it would run the next one on a model that account never listed. + */ +export function withDefaultAccount( providers: ProvidersConfig, kind: AgentKind, accountId: string | undefined, + accounts: Accounts = [], ): ProvidersConfig { const entry = providers[kind] ?? { enabled: true }; if (accountId === undefined) { const { activeAccountId: _cleared, ...rest } = entry; return { ...providers, [kind]: rest }; } - return { ...providers, [kind]: { ...entry, activeAccountId: accountId } }; + const offered = accounts.find((candidate) => candidate.id === accountId)?.models; + const orphaned = + entry.model !== undefined && !(offered ?? []).some(({ id }) => id === entry.model); + const { model: _dropped, ...kept } = entry; + return { + ...providers, + [kind]: { ...(orphaned ? kept : entry), activeAccountId: accountId }, + }; +} + +/** + * Show or hide one account's models in an agent's pickers. Absent `enabledAccountIds` means every + * bindable account, so the first disable has to materialize the list from what is bindable *now* — + * otherwise hiding one account would read as "only this one", hiding every other account too. Once + * the list exists it is authoritative, so an account added later stays out until enabled. + * + * Disabling the agent's default account also clears the default: leaving it would keep resolving + * unpinned sessions onto an account the user just removed from the menu. + */ +export function withAccountEnabled( + providers: ProvidersConfig, + kind: AgentKind, + accountId: string, + enabled: boolean, + accounts: Accounts = [], +): ProvidersConfig { + const entry = providers[kind] ?? { enabled: true }; + const current = + entry.enabledAccountIds ?? + accounts.reduce((ids, account) => { + if (resolveBinding(account, kind).tier !== 'unavailable') ids.push(account.id); + return ids; + }, []); + const next = enabled + ? [...new Set([...current, accountId])] + : current.filter((id) => id !== accountId); + const withList: ProvidersConfig = { ...providers, [kind]: { ...entry, enabledAccountIds: next } }; + return enabled || entry.activeAccountId !== accountId + ? withList + : withDefaultAccount(withList, kind, undefined); } /** Toggle whether the agent is offered in the client's agent picker. */ @@ -242,18 +290,26 @@ export function withEnabled( return { ...providers, [kind]: { ...providers[kind], enabled } }; } -/** Set (or, with undefined, clear) an agent's default model. */ +/** + * Set (or, with undefined, clear) the model an agent runs on. Passing the account the model came + * from rebinds the agent to it, because a model and the account serving it are one choice — leaving + * the old binding in place would run the next session on an account that never listed this model. + */ export function withModel( providers: ProvidersConfig, kind: AgentKind, model: string | undefined, + accountId?: string, ): ProvidersConfig { const entry = providers[kind] ?? { enabled: true }; if (model === undefined) { - const { defaultModel: _cleared, ...rest } = entry; + const { model: _cleared, ...rest } = entry; return { ...providers, [kind]: rest }; } - return { ...providers, [kind]: { ...entry, defaultModel: model } }; + return { + ...providers, + [kind]: { ...entry, model, ...(accountId !== undefined && { activeAccountId: accountId }) }, + }; } /** Drop every binding referencing a removed account; returns the input unchanged when none did. */ diff --git a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts index 768fa808..7a98d95a 100644 --- a/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts +++ b/packages/client/workbench/src/surface/__tests__/new-session-defaults-store.test.ts @@ -1,7 +1,10 @@ import { WorkspaceIdSchema } from '@linkcode/schema'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NEW_SESSION_DEFAULTS_STORAGE_KEY } from '../new-session-defaults-store'; -const STORAGE_KEY = 'linkcode.workbench.new-session-defaults:v5'; +// Imported rather than restated: a hand-copied key drifted once, and the mismatch turned the +// malformed-blob test below into a vacuous pass. +const STORAGE_KEY = NEW_SESSION_DEFAULTS_STORAGE_KEY; const WORKSPACE_ID = WorkspaceIdSchema.parse('workspace-1'); const stored = new Map(); const storage = { @@ -25,41 +28,34 @@ beforeEach(() => storage.clear()); afterAll(() => vi.unstubAllGlobals()); describe('new-session defaults', () => { - it('keeps successful model and effort choices isolated per provider', async () => { + it('keeps successful effort choices isolated per provider', async () => { const store = await loadStore(); + // A confirmed model rides the same shape but is not stored here — daemon config owns it. store .getState() .remember('claude-code', WORKSPACE_ID, { model: 'claude-opus-4-8', effort: 'high' }); store.getState().rememberSelection('claude-code', { effort: 'medium' }); store.getState().rememberSelection('codex', { model: 'gpt-5.6-terra', effort: 'low' }); - expect(store.getState().modelsByProvider).toEqual({ - 'claude-code': 'claude-opus-4-8', - codex: 'gpt-5.6-terra', - }); expect(store.getState().effortsByProvider).toEqual({ 'claude-code': 'medium', codex: 'low' }); }); - it('clears an explicitly rejected selection without disturbing the other axis', async () => { + it('clears an explicitly rejected effort', async () => { const store = await loadStore(); - store - .getState() - .remember('claude-code', WORKSPACE_ID, { model: 'claude-opus-4-8', effort: 'ultracode' }); + store.getState().remember('claude-code', WORKSPACE_ID, { effort: 'ultracode' }); store.getState().remember('claude-code', WORKSPACE_ID, { effort: null }); - expect(store.getState().modelsByProvider).toEqual({ 'claude-code': 'claude-opus-4-8' }); expect(store.getState().effortsByProvider).toEqual({}); }); - it('rehydrates model and effort choices after a renderer restart', async () => { + it('rehydrates effort choices after a renderer restart', async () => { const first = await loadStore(); - first.getState().remember('grok-build', WORKSPACE_ID, { model: 'grok-4.5', effort: 'medium' }); + first.getState().remember('grok-build', WORKSPACE_ID, { effort: 'medium' }); const restarted = await loadStore(); - expect(restarted.getState().modelsByProvider).toEqual({ 'grok-build': 'grok-4.5' }); expect(restarted.getState().effortsByProvider).toEqual({ 'grok-build': 'medium' }); }); @@ -82,9 +78,8 @@ describe('new-session defaults', () => { STORAGE_KEY, JSON.stringify({ state: { - lastProvider: 'codex', + lastHarness: 'codex', lastWorkspaceId: WORKSPACE_ID, - modelsByProvider: { codex: '' }, effortsByProvider: { codex: 'unsupported' }, }, version: 0, @@ -93,8 +88,7 @@ describe('new-session defaults', () => { const store = await loadStore(); - expect(store.getState().lastProvider).toBeNull(); - expect(store.getState().modelsByProvider).toEqual({}); + expect(store.getState().lastHarness).toBeNull(); expect(store.getState().effortsByProvider).toEqual({}); expect(store.getState().branchesByWorkspace).toEqual({}); }); diff --git a/packages/client/workbench/src/surface/new-session-defaults-store.ts b/packages/client/workbench/src/surface/new-session-defaults-store.ts index 48244320..bca55319 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 f36dfbfd..8be49146 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 fd1e18da..d52d681c 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,11 @@ 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, + useConfiguredDefaultAccounts, + useConfiguredDefaultModels, +} from '../settings/providers/default-models'; import { RuntimeBranchStatus } from '../sidebar/branch-status'; import { useSidebarGroupCollapseStore } from '../sidebar/collapse-store'; import { useSidebarOrderStore } from '../sidebar/order-store'; @@ -241,6 +246,8 @@ function WorkbenchSessionSurface({ const currentPlan: CurrentPlan | null = selectCurrentPlan(conversation); const { mentionItems, onMentionQueryChange } = useFileMentionSource(); const newSessionDefaultModels = useConfiguredDefaultModels(); + const newSessionDefaultAccounts = useConfiguredDefaultAccounts(); + const accountModels = useAccountModelOptions(); const sdkClient = useWorkbenchSdkClient(); const activeSessionId = sessions.activeId; // Announce observation of the focused session so the daemon replays buffered per-session state @@ -268,9 +275,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 +380,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 +409,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 +454,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 +583,7 @@ function WorkbenchSessionSurface({ const draft: NewSessionDraft | null = sessions.draft ? { initialWorkspaceId, - initialProvider: lastProvider ?? 'claude-code', + initialHarness: lastHarness ?? 'claude-code', } : null; @@ -651,8 +666,9 @@ function WorkbenchSessionSurface({ newSessionWorkspaceId={newSessionWorkspaceId} onNewSessionWorkspaceChange={handleNewSessionWorkspaceChange} newSessionDefaultModels={newSessionDefaultModels} + newSessionDefaultAccounts={newSessionDefaultAccounts} + accountModels={accountModels} agentCatalogs={agentCatalogs} - newSessionPreferredModels={newSessionPreferredModels} newSessionPreferredEfforts={newSessionPreferredEfforts} newSessionPreferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={RuntimeNewSessionBranchPicker} diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 31893341..bce1c99d 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -121,7 +121,7 @@ describe('dev mock transport', () => { expect(replyText).toContain('Hello mocked daemon'); const providers = { - codex: { enabled: true, defaultModel: 'mock-model' }, + codex: { enabled: true, model: 'mock-model' }, } satisfies ProvidersConfig; await client.setProviderConfig(providers); expect(await client.getProviderConfig()).toEqual(providers); @@ -147,7 +147,7 @@ describe('dev mock transport', () => { { ...boundAccount, label: 'Updated relay' }, ]); expect(await client.getProviderConfig()).toEqual({ - codex: { enabled: true, defaultModel: 'mock-model', activeAccountId: 'acc_2' }, + codex: { enabled: true, model: 'mock-model', activeAccountId: 'acc_2' }, }); client.dispose(); diff --git a/packages/foundation/providers/AGENTS.md b/packages/foundation/providers/AGENTS.md index 9b32a969..21b772e5 100644 --- a/packages/foundation/providers/AGENTS.md +++ b/packages/foundation/providers/AGENTS.md @@ -38,6 +38,15 @@ Pure data plus pure functions: no hooks, no browser APIs, no I/O. Its only depen exactly this reason: the client used the raw field once and immediately disagreed with the resolver about the same account — showing a pinned endpoint for one that resolves per agent. Display, edit-form prefill, and resolution have to answer the question identically. +- **`models` is service-level and spelled out, never derived.** One secret reaches one model list, + and the ids are identical whichever protocol shape an agent resolves to — so the list belongs to + the service, not the variant, and one fetch serves every agent bound to the account. The URL is + written out because deriving it from a variant's `baseUrl` + protocol is wrong wherever variants + sit on different paths: DeepSeek's `/anthropic` variant would give `/anthropic/v1/models` and + Vercel's bare-origin one a root `/models`, neither of which exists. `wire` picks the auth header + and response shape only. Absent means the service serves no list, and the account is freeform-only + — true for both Cloudflare entries, whose `/compat` route has no model-list path (docs + verified + live). Anthropic's list defaults to `limit=20`, so the full list must be asked for. - **A missing variant is a claim about the vendor, so verify it.** Omitting `openai-responses` refuses codex outright, and an unverified assumption that "that endpoint doesn't serve it anyway" once shipped exactly that gap for xAI, OpenRouter and Vercel — all three do serve @@ -72,11 +81,18 @@ known provider" and fall through to current behavior, never fail a session. ## Not here yet Registering a **custom** provider for an endpoint no agent knows — opencode -`provider..{npm, models}`, pi `registerProvider` with `models[]` — is unimplemented. pi's -`models[]` requires `reasoning` / `input` / `cost` / `contextWindow` / `maxTokens`, which no -`/v1/models` response carries and `contextWindow` feeds pi's compaction math, so the metadata source -is a real decision. Until it lands, endpoints without a known provider keep the pre-existing -behavior (baseUrl override on a guessed provider). +`provider..{npm, models}`, pi `registerProvider` with `models[]` — is unimplemented. Endpoints +without a known provider keep the pre-existing behavior (baseUrl override on a guessed provider). + +Metadata is **not** the blocker it was once recorded as: both agents accept a bare id and fill the +rest themselves (checked against opencode's config schema, where every `Model` field is optional in +v1 and v2, and pi's `modelFromJson`, which defaults `contextWindow` to 128000 and `maxTokens` to +16384). The reason to still avoid declaring models is the opposite one — **declaring a model the +agent already knows destroys good metadata.** pi's `applyModelsJson` replaces on id match, so +redeclaring `deepseek-v4-pro` overwrites its real 1M context window with that 128000 default and +makes the session compact constantly, silently. If custom registration is ever built, it must +declare only ids the agent's own catalog lacks, and reach for pi's `modelOverrides` (a field-level +patch that does not replace) whenever a known model needs one value changed. **Do not fake the gap by passing a wire hint.** pi's `ProviderConfigInput` accepts `api`, so `registerProvider({ baseUrl, api })` typechecks — and the SDK discards it on any call without diff --git a/packages/foundation/providers/src/__tests__/resolve.test.ts b/packages/foundation/providers/src/__tests__/resolve.test.ts index 78e4c50b..fae1809b 100644 --- a/packages/foundation/providers/src/__tests__/resolve.test.ts +++ b/packages/foundation/providers/src/__tests__/resolve.test.ts @@ -1,7 +1,7 @@ import type { Account, AgentKind, AgentRuntimes } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; -import { serviceById } from '../catalog'; +import { endpointServiceById, modelListSource, serviceById } from '../catalog'; import { detectedLoginSuggestions } from '../detected-logins'; import { resolveBinding, serviceProtocols } from '../resolve'; import { fillTemplate, templatePlaceholders } from '../template'; @@ -276,6 +276,33 @@ describe('catalog helpers', () => { expect(serviceProtocols(undefined)).toEqual([]); }); + it('resolves a model-list source only for services that serve one', () => { + // Service root, deliberately not the `/anthropic` variant's path. + expect(modelListSource('deepseek')).toEqual({ + url: 'https://api.deepseek.com/models', + wire: 'openai', + }); + expect(modelListSource('anthropic-api')?.wire).toBe('anthropic'); + // Both Cloudflare routes serve no list, and oauth services have no secret to ask with. + expect(modelListSource('cloudflare-gateway')).toBeUndefined(); + expect(modelListSource('cloudflare-anthropic')).toBeUndefined(); + expect(modelListSource('claude-sub')).toBeUndefined(); + expect(modelListSource('custom')).toBeUndefined(); + expect(modelListSource(undefined)).toBeUndefined(); + }); + + it('keeps the model-list url independent of the variant an agent resolves to', () => { + // Deriving from the resolved variant is what this replaced: the anthropic variants of these two + // sit on different paths, so appending would ask a route that does not exist. + for (const id of ['deepseek', 'vercel-gateway']) { + const service = nullthrow(endpointServiceById(id), `${id} missing`); + const anthropic = nullthrow(service.variants.anthropic, `${id} anthropic variant missing`); + expect(nullthrow(service.models, `${id} model list missing`).url).not.toBe( + `${anthropic.baseUrl}/models`, + ); + } + }); + it('extracts and fills endpoint template placeholders', () => { const cloudflare = serviceById('cloudflare-anthropic'); if (cloudflare?.kind !== 'endpoint') throw new Error('cloudflare descriptor missing'); diff --git a/packages/foundation/providers/src/catalog.ts b/packages/foundation/providers/src/catalog.ts index b6cdf9bc..5287aed5 100644 --- a/packages/foundation/providers/src/catalog.ts +++ b/packages/foundation/providers/src/catalog.ts @@ -23,6 +23,21 @@ export interface ServiceVariant { knownProvider?: Partial>; } +/** + * Where to read the ids this service serves. Service-level, not per variant: one secret reaches one + * model list, and the ids are the same whichever protocol shape an agent ends up using. + * + * The URL is spelled out rather than derived from a variant's `baseUrl` + protocol, because + * derivation is wrong for any service whose variants sit on different paths — DeepSeek's + * `/anthropic` variant would yield `/anthropic/v1/models`, and Vercel's bare-origin one a root + * `/models`. Absent means the service serves no list and the account is freeform-only. + */ +export interface ServiceModelList { + url: string; + /** Decides auth header and response shape only; the chat/responses split is irrelevant here. */ + wire: 'anthropic' | 'openai'; +} + export type ServiceDescriptor = /** Delegates to an agent CLI's own login store — no secret handled by LinkCode. */ | { id: string; label: string; group: 'subscription'; kind: 'oauth'; agent: AgentKind } @@ -35,6 +50,7 @@ export type ServiceDescriptor = /** How the one secret authenticates. Service-level: every variant accepts the same secret. */ credentialType: 'api-key' | 'auth-token'; variants: Partial>; + models?: ServiceModelList; secretPlaceholder?: string; } /** Free-form endpoint — the full account form. */ @@ -55,6 +71,8 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ knownProvider: { opencode: 'anthropic', pi: 'anthropic' }, }, }, + // `limit` defaults to 20, so it must be asked for explicitly to get the whole list. + models: { url: 'https://api.anthropic.com/v1/models?limit=1000', wire: 'anthropic' }, secretPlaceholder: 'sk-ant-…', }, { @@ -72,6 +90,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // resolve to the Responses adapter, so reaching chat here needs a custom registration. 'openai-chat': { baseUrl: 'https://api.openai.com/v1' }, }, + models: { url: 'https://api.openai.com/v1/models', wire: 'openai' }, secretPlaceholder: 'sk-…', }, { @@ -89,6 +108,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // own `xai` entries are chat-shaped, and opencode/pi prefer those. 'openai-responses': { baseUrl: 'https://api.x.ai/v1' }, }, + models: { url: 'https://api.x.ai/v1/models', wire: 'openai' }, secretPlaceholder: 'xai-…', }, { @@ -107,6 +127,8 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ knownProvider: { opencode: 'deepseek', pi: 'deepseek' }, }, }, + // Service root, not the `/anthropic` variant's path — that one serves no list. + models: { url: 'https://api.deepseek.com/models', wire: 'openai' }, secretPlaceholder: 'sk-…', }, { @@ -124,6 +146,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // The "Anthropic skin" is guaranteed only for Claude models. anthropic: { baseUrl: 'https://openrouter.ai/api' }, }, + models: { url: 'https://openrouter.ai/api/v1/models', wire: 'openai' }, secretPlaceholder: 'sk-or-v1-…', }, { @@ -141,6 +164,7 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ // Anthropic-shaped endpoint; translates server-side, so it also serves non-Anthropic models. anthropic: { baseUrl: 'https://ai-gateway.vercel.sh' }, }, + models: { url: 'https://ai-gateway.vercel.sh/v1/models', wire: 'openai' }, }, { id: 'cloudflare-gateway', @@ -148,6 +172,8 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ group: 'gateway', kind: 'endpoint', credentialType: 'auth-token', + // No `models`: `/compat` has no model-list route at all (docs + verified live), so a Cloudflare + // gateway account is freeform-only. variants: { // `/compat` serves chat completions only — Cloudflare's Responses route is a different path // (`/openai/responses`), so there is deliberately no responses variant here. @@ -185,3 +211,8 @@ export function endpointServiceById(id: string | undefined): EndpointService | u const service = serviceById(id); return service?.kind === 'endpoint' ? service : undefined; } + +/** Where to read this service's model ids, or undefined when it serves no list. */ +export function modelListSource(id: string | undefined): ServiceModelList | undefined { + return endpointServiceById(id)?.models; +} diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index d7aebb17..9af0dbc3 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -2,9 +2,15 @@ export type { EndpointService, ServiceDescriptor, ServiceGroup, + ServiceModelList, ServiceVariant, } from './catalog'; -export { endpointServiceById, SERVICE_CATALOG, serviceById } from './catalog'; +export { + endpointServiceById, + modelListSource, + SERVICE_CATALOG, + serviceById, +} from './catalog'; export type { DetectedLoginSuggestion } from './detected-logins'; export { detectedLoginSuggestions } from './detected-logins'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index 0603549f..920ad5c4 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 1a00b8b9..4af26ac1 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 bdc9286f..34b8be5c 100644 --- a/packages/foundation/schema/src/model/provider-config.ts +++ b/packages/foundation/schema/src/model/provider-config.ts @@ -7,13 +7,22 @@ import { AgentKindSchema } from './primitives'; export const ProviderConfigSchema = z.object({ /** Whether the agent is offered in the client's agent picker. */ enabled: z.boolean().default(true), - /** Default model used when the client starts a session without specifying one. */ - defaultModel: z.string().optional(), + /** The model this agent currently runs on, picked by the user from the bound account's set and + * persisted so it survives across sessions. Not a fallback default: unset means no session can + * start, because nothing else resolves a model. */ + model: z.string().optional(), /** Legacy provider API key, superseded by the global account pool (`account.ts`) but kept so * pre-account configs still load; the resolver falls back to it when `activeAccountId` is unset. */ apiKey: z.string().optional(), - /** Id of the pooled `Account` this agent's new sessions use (see `account.ts`). */ + /** Id of the pooled `Account` this agent falls back to when nothing names one: automation, + * schedules, and IM-created threads, plus a new session started without picking a model. + * Sessions started from a picker carry their own account and never consult this. */ activeAccountId: z.string().optional(), + /** The accounts whose models this agent offers in its pickers. **Absent means every bindable + * account**, so an added account is offered without a trip through Settings; an explicit list is + * the user narrowing it. Availability still gates it — listing an account that cannot back this + * agent offers nothing. */ + enabledAccountIds: z.array(z.string().min(1)).optional(), }); export type ProviderConfig = z.infer; diff --git a/packages/foundation/schema/src/model/session/record.ts b/packages/foundation/schema/src/model/session/record.ts index 0717fdfb..a09c4d02 100644 --- a/packages/foundation/schema/src/model/session/record.ts +++ b/packages/foundation/schema/src/model/session/record.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { EffortLevelSchema } from '../agent/input'; import { AgentHistoryCapabilitiesSchema } from '../history'; import { ImPlatformSchema } from '../im'; import { @@ -7,6 +8,7 @@ import { SessionIdSchema, TimestampSchema, } from '../primitives'; +import { ApprovalPolicyIdSchema } from './control'; import { SessionStatusSchema } from './lifecycle'; /** @@ -32,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 eed2644b..00694dac 100644 --- a/packages/foundation/schema/src/wire/config.ts +++ b/packages/foundation/schema/src/wire/config.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import { - AccountEndpointSchema, AccountModelSchema, AccountSchema, AccountSecretSchema, @@ -38,14 +37,18 @@ export const configWireVariants = [ agent: AgentKindSchema, account: AccountSchema, }), - /** Enumerate what an endpoint serves, before the account is saved: the daemon reads the - * endpoint's own model list with the given secret. The client cannot do this itself — the - * renderer's CSP blocks remote fetches, and only the daemon may hold the secret. */ + /** Enumerate the ids a service serves. The daemon resolves the list URL from the service catalog + * and makes the call itself — the renderer's CSP blocks remote fetches, and the secret belongs on + * that side. `inline` carries a secret the add form has not saved yet; `account` names a saved one + * so its stored secret never travels back out to the client. */ z.object({ kind: z.literal('config.probe-models'), clientReqId: WireRequestIdSchema, - endpoint: AccountEndpointSchema, - secret: AccountSecretSchema, + service: z.string().min(1), + credential: z.discriminatedUnion('type', [ + z.object({ type: z.literal('inline'), secret: AccountSecretSchema }), + z.object({ type: z.literal('account'), accountId: z.string().min(1) }), + ]), }), z.object({ kind: z.literal('config.probe-models.result'), diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 8445c3c4..108ccf86 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,11 +9,11 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 73 as const; +export const WIRE_PROTOCOL_VERSION = 75 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ -export const MIN_COMPATIBLE_WIRE_VERSION = 68 as const; +export const MIN_COMPATIBLE_WIRE_VERSION = 75 as const; /** Complete wire message: version + unique id + timestamp + payload. */ export const WireMessageSchema = z.object({ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index a4d0e70f..485a07e0 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -90,7 +90,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they ## opencode & pi - **opencode** — `consumeEvents()` keeps one active `event.subscribe({directory: cwd})` and resubscribes after a clean SSE close at normal turn end (`session.idle`) or on cancel. The directory scope is LOAD-BEARING: events ride a per-directory instance bus, so a bare `subscribe()` silently misses every session event whenever the daemon cwd differs from the session cwd (verified live on 1.17.11). A close is fatal ONLY while a turn is active with no cancel pending, and the fatal path emits status `stopped` (NOT `idle`) so the UI disables the composer — misclassifying it (the pre-fix bug) stranded the composer enabled against a dead adapter. Each event has its own try/catch; the resubscribe delay prevents an empty-response busy loop. -- **opencode control plane** (CODE-224, live-verified on binary 1.18.2 × SDK 1.17.18 — script + readback transcript attached to the issue): `set-model` and `set-approval-policy` are pure store-then-emit — the pick is resent on every `session.promptAsync`/`session.command` as the `model`/`agent` fields, and a mid-session change routes the very next turn (assistant `providerID`/`modelID`/`agent` readback all flip; next-turn semantics, in-flight turns unaffected). User and assistant `message.updated` frames reflect the actually routed `providerID/modelID`, including the native default when no override was sent. No dedicated switched/ack event fires on the legacy bus, so the immediate reflect is the only switch confirmation channel. `set-model` rejects refs that aren't `providerID/modelID` (a stored bare id would emit a "successful" model-update while prompts silently omit the field) and rejects cross-provider switches when a per-account credential was injected at spawn (the injection is spawn-time-only, scoped to one provider). **The approval-policy axis IS opencode's agent axis**: selectable agents from `app.agents({directory})` (`mode === 'primary'|'all'`, non-hidden — hidden primaries like `compaction`/`title` and subagents are excluded) are advertised as policies, default = first primary (the TUI's own default); permission posture stays config-driven (CODE-136). The axis is dynamic: a failed discovery at start hides it for the session (a later `$` shell command retries and re-arms it on success). `$` shell runs under the selected agent. Resume adopts the Session record's last-used `model`/`agent` (both live-verified to update after every turn) unless `StartOptions.model` overrides; a credential-carrying resume without an explicit model pre-reads that record off the shared history server BEFORE the spawn, because the credential injection is spawn-time-only and keyed by the model's provider. There is NO effort axis — opencode's only analogue is per-model `variant` keys (free strings, incompatible with the closed `EffortLevel` enum; a follow-up on the dynamic catalog). **The model catalog is adapter-advertised** (CODE-226): `provider.list({directory})` at start → `available-models-update` (full-replace, engine-cached, attach-replayed — the command-catalog contract), filtered to connected / key-less `api`-source providers and narrowed to the credential-injected provider when one is in play; the composer prefers this catalog over the static `AGENT_MODEL_OPTIONS` table (which deliberately has no opencode entry — its model set is provider-dependent, not a fixed vendor list). +- **opencode control plane** (CODE-224, live-verified on binary 1.18.2 × SDK 1.17.18 — script + readback transcript attached to the issue): `set-model` and `set-approval-policy` are pure store-then-emit — the pick is resent on every `session.promptAsync`/`session.command` as the `model`/`agent` fields, and a mid-session change routes the very next turn (assistant `providerID`/`modelID`/`agent` readback all flip; next-turn semantics, in-flight turns unaffected). User and assistant `message.updated` frames reflect the actually routed `providerID/modelID`, including the native default when no override was sent. No dedicated switched/ack event fires on the legacy bus, so the immediate reflect is the only switch confirmation channel. `set-model` rejects refs that aren't `providerID/modelID` **and cannot be qualified** — a picked model id comes from the service's own model list and carries no provider, so `config.knownProvider` supplies the missing half (`resolveModelRef`); with neither, a stored bare id would emit a "successful" model-update while prompts silently omit the field. Both reflection paths compare *resolved* refs and then emit the id the user picked, not opencode's prefixed readback, so the client's selected set still matches and rejects cross-provider switches when a per-account credential was injected at spawn (the injection is spawn-time-only, scoped to one provider). **The approval-policy axis IS opencode's agent axis**: selectable agents from `app.agents({directory})` (`mode === 'primary'|'all'`, non-hidden — hidden primaries like `compaction`/`title` and subagents are excluded) are advertised as policies, default = first primary (the TUI's own default); permission posture stays config-driven (CODE-136). The axis is dynamic: a failed discovery at start hides it for the session (a later `$` shell command retries and re-arms it on success). `$` shell runs under the selected agent. Resume adopts the Session record's last-used `model`/`agent` (both live-verified to update after every turn) unless `StartOptions.model` overrides; a credential-carrying resume without an explicit model pre-reads that record off the shared history server BEFORE the spawn, because the credential injection is spawn-time-only and keyed by the model's provider. There is NO effort axis — opencode's only analogue is per-model `variant` keys (free strings, incompatible with the closed `EffortLevel` enum; a follow-up on the dynamic catalog). **The model catalog is adapter-advertised** (CODE-226): `provider.list({directory})` at start → `available-models-update` (full-replace, engine-cached, attach-replayed — the command-catalog contract), filtered to connected / key-less `api`-source providers and narrowed to the credential-injected provider when one is in play; the composer prefers this catalog over the static `AGENT_MODEL_OPTIONS` table (which deliberately has no opencode entry — its model set is provider-dependent, not a fixed vendor list). - **opencode turn lifecycle** (all verified live on 1.17.11, CODE-136): prompts go through `session.promptAsync` — the blocking `session.prompt` holds its HTTP response open for the whole turn, so `send()` would not return until the turn ended. `session.status {busy|retry}` is the on-stream acknowledgement that the active turn is running, and it ALWAYS precedes the turn's own error/idle — the `turnStarted` gate built on it is what keeps the previous turn's post-settle stragglers (an abort's DUPLICATE idle; the error re-fired with a stack after the settle) from falsely settling or poisoning a next turn that was already dispatched. An abort delivers `session.error {MessageAbortedError}` + `session.idle` — the error folds into the cancel path (stop `cancelled`), never surfaces as an error. Other `session.error`s fail the turn: `ProviderAuthError` → `AUTH_FAILED_ERROR_CODE` (non-recoverable, triggers the daemon login re-probe), everything else recoverable; `sessionID` is OPTIONAL on this one event — an unattributed error still counts as ours. A failed turn's idle settle emits status `idle` but NO `end_turn` stop. An idle absorbed before the busy acknowledgement logs a `console.warn` — the one trace if a server never emits `session.status` (the turn would then hang at `running`). - **opencode RPC results resolve, they don't reject**: the generated client returns `{error}` for HTTP and network failures alike (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). - **opencode permissions & questions** (CODE-136): opencode's default posture is allow-all — asks only fire when the user's own config (or a future preset) says `ask`. `permission.asked` → the shared `requestPermission` round-trip → `permission.reply({reply: 'once'|'always'|'reject'})`; `always` is persisted server-side as a saved rule. `question.asked` → `requestQuestion` → `question.reply({answers})` (one label array per question) or `question.reject`. An UNANSWERED ask gates the turn server-side forever, so a teardown-cancelled permission replies `reject` and a cancelled question calls `reject` — reply failures after a cancel are swallowed (the abort already discarded the ask). Asks cite their tool via `tool.callID`, but tool cards are announced under the PART id — `toolPartIdByCallId` re-joins them. A custom "Other" answer rides as an extra label: upstream `Question.reply` hands the answer arrays to the asking tool verbatim, with no validation against option labels (verified in anomalyco/opencode source). @@ -106,13 +106,14 @@ Product code must branch on `historyCapabilities` — never assume an op is supp | claude-code | ✓ | ✓ (live) | ✓ (live) | ✓ | ✓ | ✗ | detected user install / managed dir | | codex | ✓ | ✓ (next turn) | ✓ (next turn, low–max; Sol/Terra ultra) | ✓ (3 tiers) | ✓ | ✓ | detected user install / managed dir | | opencode | ✓ | ✓ (next turn, same provider) | ✗ | ✓ (agent axis: build/plan/custom; hidden if discovery fails) | ✓ | ✓ | detected user install / managed dir (CODE-76; PATH-name fallback for unprobed hosts) | -| pi | ✗ | ✗ | ✗ | fixed bypass | ✗ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | +| pi | ✓ | ✗ | ✗ | fixed bypass | ✗ | ✗ | in-process JS: managed npm-closure import (CODE-219) / dev node_modules | | grok-build | ✗ | ✓ (next turn) | ✓ (next turn, low–high) | fixed bypass | ✗ | ✗ | detected user install | `StartOptions.effort` enters through the same `onSetEffort` hook before `onStart`, so startup-only levels (Claude `max`) and live-switchable levels share validation and reflection behavior. The engine caches the emitted effort and replays it when the newly created session attaches. +- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one); which it *falls back to* when a session names none is `activeAccountId`, used by automation, schedules, and IM threads. Sessions started from a picker carry `StartOptions.accountId` and ignore the fallback; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to the agent's default instead of starting a session with no credential. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the fallback moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and the agent's configured default could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env. - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** Precedence: model-ref (`providerID/modelID`, which decides routing) → for pi, the resumed session's own last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in the agent's own catalog, from `@linkcode/providers`) → for pi, its first available provider. Before `knownProvider` existed a bare model id left the credential uninjected entirely; putting it ahead of the resumed provider instead strands a resumed session on a provider that never got the key. - **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built). diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index 06a505c0..e1747a49 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -1628,7 +1628,29 @@ describe('OpenCodeAdapter control plane (CODE-224)', () => { }); }); - it('rejects a set-model ref that is not providerID/modelID', async () => { + it('qualifies a bare picked id with the endpoint’s known provider', async () => { + // A picked id comes from the service's model list and carries no provider; opencode routes + // only by providerID/modelID, so `knownProvider` supplies the missing half. + const adapter = new OpenCodeAdapter(); + const events: AgentEvent[] = []; + adapter.onEvent((e) => events.push(e)); + await adapter.start({ + kind: 'opencode', + cwd: '/tmp/repo', + config: { knownProvider: 'deepseek' }, + }); + + await adapter.send({ type: 'set-model', model: 'deepseek-v4-pro' }); + // Reflected as the user picked it, so the client's own selected set matches. + expect(events).toContainEqual({ type: 'model-update', model: 'deepseek-v4-pro' }); + + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hi' }] }); + expect(client.session.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ model: { providerID: 'deepseek', modelID: 'deepseek-v4-pro' } }), + ); + }); + + it('rejects a bare set-model ref when no known provider can qualify it', async () => { const { adapter, events } = await makeAdapter(); events.length = 0; diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 4d91fc8b..c76571fe 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -148,6 +148,17 @@ function parseModelRef(model: string): { providerID: string; modelID: string } | return { providerID, modelID }; } +/** A selected model id is the vendor's own, taken from the service's model list and carrying no + * provider — opencode routes only by `providerID/modelID`, so qualify it with the endpoint's id in + * opencode's catalog. Without a known provider a bare id stays unroutable and is rejected. */ +function resolveModelRef( + model: string, + knownProvider: string | undefined, +): { providerID: string; modelID: string } | undefined { + if (model.includes('/')) return parseModelRef(model); + return knownProvider === undefined ? undefined : { providerID: knownProvider, modelID: model }; +} + type OpencodeModule = typeof import('@opencode-ai/sdk/v2'); type OpencodeClient = ReturnType; type OpencodeProviderList = NonNullable< @@ -384,7 +395,14 @@ export class OpenCodeAdapter extends BaseAgentAdapter { } // A resumed session record confirms its current model. A fresh override is confirmed only when // the running server advertises that exact ref; a request or failed catalog is not acceptance. - if (opts.model && (opts.model === resumedModel || availableModels?.has(opts.model))) { + // Compare as resolved refs: a picked id is the vendor's own and carries no provider, while the + // catalog and the session record are always `providerID/modelID`. + const requested = this.model(); + const advertised = + requested === undefined + ? false + : availableModels?.has(`${requested.providerID}/${requested.modelID}`) === true; + if (opts.model && (advertised || opts.model === resumedModel)) { this.emitModel(opts.model); } void this.consumeEvents(); @@ -760,9 +778,9 @@ export class OpenCodeAdapter extends BaseAgentAdapter { * the legacy bus — the immediate reflect below is the only confirmation channel there is. */ protected override onSetModel(model: string): Promise { invariant(this.opts, 'opencode: session not started'); - const parsed = parseModelRef(model); + const parsed = resolveModelRef(model, readAgentCredential(this.opts.config).knownProvider); if (!parsed) { - // Storing an unparseable ref would emit a "successful" model-update while every following + // Storing an unroutable ref would emit a "successful" model-update while every following // prompt silently omits the model field and keeps running on the previous one. return Promise.reject( new Error(`opencode: model must be 'providerID/modelID' (got '${model}')`), @@ -821,7 +839,8 @@ export class OpenCodeAdapter extends BaseAgentAdapter { } private model(): { providerID: string; modelID: string } | undefined { - return this.opts?.model ? parseModelRef(this.opts.model) : undefined; + if (!this.opts?.model) return undefined; + return resolveModelRef(this.opts.model, readAgentCredential(this.opts.config).knownProvider); } /** Runs for the whole session, dispatching every SSE event and replacing a stream the server @@ -964,7 +983,12 @@ export class OpenCodeAdapter extends BaseAgentAdapter { ) { return; } - this.emitModel(model); + // opencode always reports `providerID/modelID`. When that is the pick resolved, reflect the + // pick verbatim instead — the client matches against the ids the user selected, which are bare. + const requested = this.model(); + const routedThePick = + requested !== undefined && `${requested.providerID}/${requested.modelID}` === model; + this.emitModel(routedThePick && this.opts?.model ? this.opts.model : model); } /** Turn settle on `session.idle`, guarded on liveness AND `turnStarted`: an abort's duplicate diff --git a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts index 06cd6bfa..d587fdec 100644 --- a/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts +++ b/packages/host/engine/src/__tests__/engine-agent-catalog.test.ts @@ -32,12 +32,12 @@ describe('engine agent catalog', () => { label: 'Catalog account', credential: { type: 'api-key', key: 'catalog-key' }, endpoint: { baseUrl: 'https://catalog.example.test', protocol: 'openai-chat' }, - model: 'provider/model', + models: [{ id: 'provider/model' }], createdAt: 0, }; providers.update({ providers: { - 'claude-code': { enabled: true, activeAccountId: account.id }, + 'claude-code': { enabled: true, activeAccountId: account.id, model: 'provider/model' }, }, accounts: [account], }); diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index 35803127..9a9e0832 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 969706d8..63113959 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,487 @@ describe('engine session records', () => { expect(await inner.load()).toHaveLength(1); }); }); + +describe('session account attribution', () => { + function storeBoundTo(accountId: string, model: string): InMemoryProviderConfigStore { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { 'claude-code': { enabled: true, activeAccountId: accountId, model } }, + accounts: [ + { + id: accountId, + label: 'Bound', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-test' }, + models: [{ id: model }], + createdAt: 0, + }, + ], + }); + return providers; + } + + it("records the account a run resolved to and reports the latest run's", async () => { + const providers = storeBoundTo('acc_bound', 'claude-opus-5'); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + await h.inject({ kind: 'session.list', clientReqId: 'r2' }); + + expect(listedSessions(h.sent, 'r2')[0]?.accountId).toBe('acc_bound'); + // Persisted per run, so a restart still knows what the session is talking to. + expect((await store.load())[0].runs[0].accountId).toBe('acc_bound'); + }); + + it('honours an account the client pinned over the bound one', async () => { + const providers = storeBoundTo('acc_bound', 'claude-opus-5'); + const pool = providers.getAccounts(); + providers.update({ + accounts: [ + ...pool, + { + id: 'acc_pinned', + label: 'Pinned', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-other' }, + models: [{ id: 'claude-sonnet-5' }], + createdAt: 0, + }, + ], + }); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + providers, + ); + await h.engine.start(); + + // This is how picking a model that belongs to another account reaches the daemon. + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { + kind: 'claude-code', + cwd: '/repo', + model: 'claude-sonnet-5', + 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, activeAccountId: 'acc_one', model: 'model-a' }, + }, + accounts: [ + { + id: 'acc_one', + label: 'One', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-one' }, + models: [{ id: 'model-a' }, { id: 'model-b' }], + createdAt: 0, + }, + ], + }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + + // Same account, so this switches in place and launches nothing — the ordinary way a user + // changes model. The 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 agent's default + // could never reach it again. + providers.update({ providers: { 'claude-code': { enabled: true, model: 'configured' } } }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + expect(nullthrow(h.adapters.at(-1)).resumedWith?.model).toBe('configured'); + expect((await store.load())[0].runs[0].model).toBeUndefined(); + }); + + it('replays the effort and approval tier picked on the live session', async () => { + const h = harness(new InMemorySessionStore()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + // Both axes live on the adapter, so a relaunch is where they get lost. + await h.inject({ + kind: 'agent.input', + clientReqId: 'effort', + sessionId, + input: { type: 'set-effort', effort: 'xhigh' }, + }); + await h.inject({ + kind: 'agent.input', + clientReqId: 'policy', + sessionId, + input: { type: 'set-approval-policy', policyId: 'acceptEdits' }, + }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.effort).toBe('xhigh'); + expect(resumed.resumedWith?.approvalPolicyId).toBe('acceptEdits'); + }); + + it('records nothing when the session refuses the pick', async () => { + const store = new InMemorySessionStore(); + const h = harness(store, () => new PickyAdapter()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + + await h.inject({ + kind: 'agent.input', + clientReqId: 'effort', + sessionId, + input: { type: 'set-effort', effort: 'max' }, + }); + await tick(); + + expect(h.adapters[0].sentInputs).toContainEqual({ type: 'set-effort', effort: 'max' }); + expect((await store.load())[0].runs[0].effort).toBeUndefined(); + }); + + it('falls back to the agent’s default when the pinned account has been deleted', async () => { + const providers = new InMemoryProviderConfigStore(); + const surviving = { + id: 'acc_default', + label: 'Default', + service: 'anthropic-api' as const, + credential: { type: 'api-key' as const, key: 'sk-default' }, + models: [{ id: 'model-default' }], + createdAt: 0, + }; + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_default', model: 'model-default' }, + }, + accounts: [ + surviving, + { + id: 'acc_doomed', + label: 'Doomed', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-doomed' }, + models: [{ id: 'model-doomed' }], + createdAt: 0, + }, + ], + }); + const h = harness( + new InMemorySessionStore(), + undefined, + undefined, + undefined, + undefined, + providers, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { + kind: 'claude-code', + cwd: '/repo', + model: 'model-doomed', + 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, activeAccountId: 'acc_first', model: 'model-first' }, + }, + accounts: [ + { + id: 'acc_first', + label: 'First', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-first' }, + models: [{ id: 'model-first' }], + createdAt: 0, + }, + { + id: 'acc_second', + label: 'Second', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-second' }, + models: [{ id: 'model-second' }], + createdAt: 0, + }, + ], + }); + const store = new InMemorySessionStore(); + const h = harness(store, undefined, undefined, undefined, undefined, providers); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + await tick(); + await h.inject({ kind: 'session.stop', clientReqId: 'r2', sessionId }); + + // Settings moves the agent's default to the other account while the thread sleeps. + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_second', model: 'model-second' }, + }, + }); + await h.inject({ kind: 'session.resume', clientReqId: 'r3', sessionId }); + + const resumed = nullthrow(h.adapters.at(-1)); + expect(resumed.resumedWith?.model).toBe('model-first'); + expect(resumed.resumedWith?.config?.apiKey).toBe('sk-first'); + expect((await store.load())[0].runs.at(-1)?.accountId).toBe('acc_first'); + }); +}); + +class ResumelessAdapter extends FakeAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities = { + list: false, + read: true, + resume: false, + }; +} + +describe('live account switching', () => { + /** Two accounts an agent can bind, one of them currently bound. */ + function twoAccountStore(): InMemoryProviderConfigStore { + const providers = new InMemoryProviderConfigStore(); + providers.update({ + providers: { + 'claude-code': { enabled: true, activeAccountId: 'acc_first', model: 'model-first' }, + }, + accounts: [ + { + id: 'acc_first', + label: 'First', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-first' }, + models: [{ id: 'model-first' }], + createdAt: 0, + }, + { + id: 'acc_second', + label: 'Second', + service: 'anthropic-api', + credential: { type: 'api-key', key: 'sk-second' }, + models: [{ id: 'model-second' }], + createdAt: 0, + }, + ], + }); + return providers; + } + + async function liveSession( + makeAdapter?: () => FakeAdapter, + options: { withTranscript?: boolean } = {}, + ) { + const { withTranscript = true } = options; + const store = new InMemorySessionStore(); + const h = harness(store, makeAdapter, undefined, undefined, undefined, twoAccountStore()); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + if (withTranscript) { + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-live') }); + await tick(); + } + return { ...h, store, sessionId }; + } + + function switchTo( + h: Awaited>, + accountId: string, + model: string, + clientReqId = 'switch', + ) { + return h.inject({ + kind: 'agent.input', + clientReqId, + sessionId: h.sessionId, + input: { type: 'set-model', model, accountId }, + }); + } + + it('relaunches on the new account, resuming the transcript under the same id', async () => { + const h = await liveSession(); + + await switchTo(h, 'acc_second', 'model-second'); + + expect(h.sent).toContainEqual({ kind: 'request.succeeded', replyTo: 'switch' }); + // A fresh adapter, resumed from the transcript the old one had established. + expect(h.adapters).toHaveLength(2); + expect(h.adapters[0].stopped).toBe(true); + expect(h.adapters[1].resumedFrom).toBe('native-live'); + expect(h.adapters[1].resumedWith?.model).toBe('model-second'); + expect(h.adapters[1].resumedWith?.config?.apiKey).toBe('sk-second'); + + const runs = (await h.store.load())[0].runs; + expect(runs).toHaveLength(2); + expect(runs[1].accountId).toBe('acc_second'); + + // 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 be9127b0..9226112e 100644 --- a/packages/host/engine/src/__tests__/fixtures/session-harness.ts +++ b/packages/host/engine/src/__tests__/fixtures/session-harness.ts @@ -37,6 +37,9 @@ export class FakeAdapter implements AgentAdapter { startedWith: StartOptions | null = null; resumedFrom: string | null = null; + /** The options a resume was spawned with — a relaunch carries its model/credentials here, not + * through `startedWith`. */ + resumedWith: StartOptions | null = null; stopped = false; readonly sentInputs: AgentInput[] = []; private readonly listeners = new Set<(event: AgentEvent) => void>(); @@ -67,8 +70,9 @@ export class FakeAdapter implements AgentAdapter { }); } - resumeHistory(opts: AgentHistoryResumeOptions): Promise { + resumeHistory(opts: AgentHistoryResumeOptions, startOpts: StartOptions): Promise { this.resumedFrom = opts.historyId; + this.resumedWith = startOpts; return Promise.resolve(); } diff --git a/packages/host/engine/src/__tests__/model-probe.test.ts b/packages/host/engine/src/__tests__/model-probe.test.ts index 5d7ab454..d55b58dc 100644 --- a/packages/host/engine/src/__tests__/model-probe.test.ts +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -1,18 +1,27 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; +import type { ServiceModelList } from '@linkcode/providers'; import type { AccountEndpoint } from '@linkcode/schema'; import { describe, expect, it, vi } from 'vitest'; import type { ModelListRequest } from '../agent/model-probe'; import { modelListHeaders, - modelListUrl, + modelListUrlFromEndpoint, probeEndpointModels, requestModelListAtAddress, resolvePublicEndpoint, } from '../agent/model-probe'; -const anthropic: AccountEndpoint = { baseUrl: 'https://relay.test/', protocol: 'anthropic' }; -const openai: AccountEndpoint = { baseUrl: 'https://relay.test/v1', protocol: 'openai-chat' }; +const anthropicEndpoint: AccountEndpoint = { + baseUrl: 'https://relay.test/', + protocol: 'anthropic', +}; +const openaiEndpoint: AccountEndpoint = { + baseUrl: 'https://relay.test/v1', + protocol: 'openai-chat', +}; +const anthropic: ServiceModelList = { url: 'https://relay.test/v1/models', wire: 'anthropic' }; +const openai: ServiceModelList = { url: 'https://relay.test/v1/models', wire: 'openai' }; const REJECTION_PATTERN = /401.*invalid api key/; const NOT_A_LIST_PATTERN = /did not answer a model list/; const HTTP_PATTERN = /HTTP/; @@ -25,31 +34,42 @@ function jsonResponse(body: unknown): Awaited> { return { status: 200, statusText: 'OK', body: JSON.stringify(body) }; } -describe('endpoint model list addressing', () => { +describe('custom endpoint model list addressing', () => { it('appends /v1 for Anthropic-shaped base URLs and only /models for OpenAI-shaped ones', () => { - expect(modelListUrl(anthropic)).toBe('https://relay.test/v1/models?limit=1000'); - expect(modelListUrl(openai)).toBe('https://relay.test/v1/models'); + // Only custom accounts reach this: catalog services carry an explicit URL instead. + expect(modelListUrlFromEndpoint(anthropicEndpoint)).toBe( + 'https://relay.test/v1/models?limit=1000', + ); + expect(modelListUrlFromEndpoint(openaiEndpoint)).toBe('https://relay.test/v1/models'); }); it('rejects URL components that string-appending could misaddress', () => { expect(() => - modelListUrl({ baseUrl: 'https://relay.test/v1?tenant=x', protocol: 'openai-chat' }), + modelListUrlFromEndpoint({ + baseUrl: 'https://relay.test/v1?tenant=x', + protocol: 'openai-chat', + }), ).toThrow(INVALID_ENDPOINT_PATTERN); expect(() => - modelListUrl({ baseUrl: 'https://user:secret@relay.test/v1', protocol: 'openai-chat' }), + modelListUrlFromEndpoint({ + baseUrl: 'https://user:secret@relay.test/v1', + protocol: 'openai-chat', + }), ).toThrow(INVALID_ENDPOINT_PATTERN); }); - it('authenticates per protocol and credential shape', () => { - expect(modelListHeaders(anthropic, { type: 'api-key', key: 'k' })).toMatchObject({ + it('authenticates per wire and credential shape', () => { + expect(modelListHeaders('anthropic', { type: 'api-key', key: 'k' })).toMatchObject({ 'x-api-key': 'k', 'anthropic-version': '2023-06-01', }); - expect(modelListHeaders(anthropic, { type: 'auth-token', token: 't' })).toMatchObject({ + expect(modelListHeaders('anthropic', { type: 'auth-token', token: 't' })).toMatchObject({ authorization: 'Bearer t', 'anthropic-version': '2023-06-01', }); - expect(modelListHeaders(openai, { type: 'api-key', key: 'k' }).authorization).toBe('Bearer k'); + expect(modelListHeaders('openai', { type: 'api-key', key: 'k' }).authorization).toBe( + 'Bearer k', + ); }); }); diff --git a/packages/host/engine/src/__tests__/provider-config.test.ts b/packages/host/engine/src/__tests__/provider-config.test.ts index 363672d0..b82264a9 100644 --- a/packages/host/engine/src/__tests__/provider-config.test.ts +++ b/packages/host/engine/src/__tests__/provider-config.test.ts @@ -7,16 +7,15 @@ const baseOpts: StartOptions = { kind: 'codex', cwd: '/repo' }; describe('applyProviderDefaults', () => { it('returns the input untouched when no config exists for the kind', () => { const providers: ProvidersConfig = { 'claude-code': { enabled: true, apiKey: 'sk-x' } }; - expect(applyProviderDefaults(baseOpts, providers).options).toBe(baseOpts); + expect(applyProviderDefaults(baseOpts, providers).options).toEqual(baseOpts); }); - it('fills the default model only when the client did not specify one', () => { - const providers: ProvidersConfig = { codex: { enabled: true, defaultModel: 'o4-mini' } }; + it('fills the persisted pick only when the client did not specify one', () => { + const providers: ProvidersConfig = { codex: { enabled: true, model: 'o4-mini' } }; expect(applyProviderDefaults(baseOpts, providers).options.model).toBe('o4-mini'); expect(applyProviderDefaults({ ...baseOpts, model: 'gpt-4o' }, providers).options.model).toBe( 'gpt-4o', ); - expect(applyProviderDefaults({ ...baseOpts, model: null }, providers).options.model).toBeNull(); }); it('injects the api key into config, preserving existing config keys', () => { @@ -27,7 +26,7 @@ describe('applyProviderDefaults', () => { it('does not mutate the input options', () => { const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'o4-mini', apiKey: 'sk' }, + codex: { enabled: true, model: 'o4-mini', apiKey: 'sk' }, }; const opts: StartOptions = { kind: 'codex', cwd: '/repo' }; applyProviderDefaults(opts, providers); @@ -45,12 +44,14 @@ describe('applyProviderDefaults account pool', () => { it('injects the credential from the account bound via activeAccountId', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; - expect(applyProviderDefaults(baseOpts, providers, [account]).options.config).toEqual({ - apiKey: 'sk-acc', - }); + const merged = applyProviderDefaults(baseOpts, providers, [account]); + expect(merged.options.config).toEqual({ apiKey: 'sk-acc' }); + // Reported, not echoed into the adapter-facing config: the caller records what actually backed + // the run, and nothing downstream can mistake a request for a resolution. + expect(merged.accountId).toBe('acc_1'); }); - it('lets an explicit opts.config.accountId override activeAccountId', () => { + it('lets an explicit opts.accountId override activeAccountId, and consumes it', () => { const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'acc_1' } }; const other: Account = { id: 'acc_2', @@ -58,12 +59,24 @@ describe('applyProviderDefaults account pool', () => { credential: { type: 'api-key', key: 'sk-other' }, createdAt: 0, }; - const merged = applyProviderDefaults( - { ...baseOpts, config: { accountId: 'acc_2' } }, - providers, - [account, other], - ); + const merged = applyProviderDefaults({ ...baseOpts, accountId: 'acc_2' }, providers, [ + account, + other, + ]); expect(merged.options.config).toMatchObject({ apiKey: 'sk-other' }); + expect(merged.accountId).toBe('acc_2'); + expect(merged.options.accountId).toBeUndefined(); + }); + + it('reports no account for a requested id that no longer resolves, whatever the agent has', () => { + const stale: StartOptions = { ...baseOpts, accountId: 'deleted' }; + // No entry for the kind at all: the request's own id is the only account-shaped thing in play, + // and it must not survive as one. + for (const providers of [{}, { codex: { enabled: true } }] satisfies ProvidersConfig[]) { + const merged = applyProviderDefaults(stale, providers, [account]); + expect(merged.accountId).toBeUndefined(); + expect(merged.options.accountId).toBeUndefined(); + } }); it('injects authToken, baseUrl and protocol for an auth-token account with an endpoint', () => { @@ -120,13 +133,16 @@ describe('applyProviderDefaults account pool', () => { expect(forOpencode.options.config).toMatchObject({ knownProvider: 'openai' }); }); - it('prefers the account model over the provider default model', () => { + it('takes the model from the agent, never from the bound account', () => { + // The account holds the set the pick came from; only `providers[kind].model` names the pick. const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'o4-mini', activeAccountId: 'acc_1' }, + codex: { enabled: true, model: 'o4-mini', activeAccountId: 'acc_1' }, }; expect( - applyProviderDefaults(baseOpts, providers, [{ ...account, model: 'gpt-5' }]).options.model, - ).toBe('gpt-5'); + applyProviderDefaults(baseOpts, providers, [ + { ...account, models: [{ id: 'gpt-5' }, { id: 'o4-mini' }] }, + ]).options.model, + ).toBe('o4-mini'); }); it('falls back to the legacy apiKey when the bound account id is stale', () => { @@ -146,7 +162,10 @@ describe('applyProviderDefaults account pool', () => { createdAt: 0, }; const providers: ProvidersConfig = { codex: { enabled: true, activeAccountId: 'oauth_1' } }; - expect(applyProviderDefaults(baseOpts, providers, [oauth]).options.config).toEqual({}); + // The account still resolves — it just contributes nothing for the adapter to read. + const merged = applyProviderDefaults(baseOpts, providers, [oauth]); + expect(merged.options.config).toEqual({}); + expect(merged.accountId).toBe('oauth_1'); }); }); @@ -160,7 +179,7 @@ describe('accountBinding', () => { it('preserves unrelated providers and accounts while binding the selected agent', () => { const providers: ProvidersConfig = { - codex: { enabled: true, defaultModel: 'gpt-5' }, + codex: { enabled: true, model: 'gpt-5' }, opencode: { enabled: false, activeAccountId: 'acc_2' }, }; const other: Account = { @@ -172,7 +191,7 @@ describe('accountBinding', () => { expect(accountBinding(providers, [other], 'codex', account)).toEqual({ providers: { - codex: { enabled: true, defaultModel: 'gpt-5', activeAccountId: 'acc_1' }, + codex: { enabled: true, model: 'gpt-5', activeAccountId: 'acc_1' }, opencode: { enabled: false, activeAccountId: 'acc_2' }, }, accounts: [other, account], diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 74e7114f..e4639024 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -160,6 +160,11 @@ describe('account binding at session start', () => { function storeWith(account: Account, agent: AgentKind): InMemoryProviderConfigStore { const store = new InMemoryProviderConfigStore(); store.createAndBindAccount(agent, account); + // A bound agent without a picked model refuses to start; these cases are about endpoints. + const providers = store.get(); + store.update({ + providers: { ...providers, [agent]: { ...providers[agent], model: 'picked-model' } }, + }); return store; } @@ -171,6 +176,22 @@ describe('account binding at session start', () => { ...overrides, }); + it('refuses a bound agent with no picked model, but lets an unbound one resolve its own', async () => { + const store = new InMemoryProviderConfigStore(); + store.createAndBindAccount('codex', account({ service: 'openai-api' })); + const bound = new SessionStartOptionsResolver(store, undefined); + await expect( + Effect.runPromise(bound.resolve({ kind: 'codex', cwd: '/repo' }, SESSION)), + ).rejects.toThrow('No model selected for codex'); + + // Nothing bound: the agent still runs on whatever it resolves for itself. + const unbound = new SessionStartOptionsResolver(new InMemoryProviderConfigStore(), undefined); + const { options } = await Effect.runPromise( + unbound.resolve({ kind: 'codex', cwd: '/repo' }, SESSION), + ); + expect(options.model).toBeUndefined(); + }); + it('refuses the session when the bound account has no endpoint the agent speaks', async () => { const anthropicOnly = account({ endpoint: { baseUrl: 'https://api.anthropic.com', protocol: 'anthropic' }, diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts index 02a3a859..79411e66 100644 --- a/packages/host/engine/src/agent/model-probe.ts +++ b/packages/host/engine/src/agent/model-probe.ts @@ -3,6 +3,7 @@ import { lookup as dnsLookup } from 'node:dns/promises'; import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { BlockList, isIP } from 'node:net'; +import type { ServiceModelList } from '@linkcode/providers'; import type { AccountEndpoint, AccountModel, AccountSecret } from '@linkcode/schema'; import { extractErrorMessage } from 'foxts/extract-error-message'; import { z } from 'zod'; @@ -88,7 +89,9 @@ export type ModelListRequest = ( ) => Promise; export type ModelProbe = typeof probeEndpointModels; -export function modelListUrl(endpoint: AccountEndpoint): string { +/** A custom account names its own endpoint, so its list path can only be guessed from the protocol. + * Catalog services never come through here — they carry an explicit URL (`@linkcode/providers`). */ +export function modelListUrlFromEndpoint(endpoint: AccountEndpoint): string { const url = new URL(endpoint.baseUrl); if (url.username || url.password || url.search || url.hash) { throw new Error('Model detection endpoint cannot contain credentials, a query, or a fragment'); @@ -100,11 +103,11 @@ export function modelListUrl(endpoint: AccountEndpoint): string { } export function modelListHeaders( - endpoint: AccountEndpoint, + wire: ServiceModelList['wire'], secret: AccountSecret, ): Record { const headers: Record = { accept: 'application/json' }; - if (endpoint.protocol === 'anthropic') { + if (wire === 'anthropic') { headers['anthropic-version'] = ANTHROPIC_VERSION; // An Anthropic-shaped gateway authenticates with whichever header its own credential is. if (secret.type === 'api-key') headers['x-api-key'] = secret.key; @@ -246,21 +249,21 @@ export function requestModelListAtAddress( }); } -/** Model ids the endpoint advertises, deduped, in the order it listed them. Rejects with a +/** Model ids the source advertises, deduped, in the order it listed them. Rejects with a * user-facing message (status + the vendor's own reason) — the dialog shows it verbatim. */ export async function probeEndpointModels( - endpoint: AccountEndpoint, + source: ServiceModelList, secret: AccountSecret, request: ModelListRequest = requestPublicModelList, ): Promise { - const url = new URL(modelListUrl(endpoint)); + const url = new URL(source.url); const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(new Error('Model detection timed out')); }, PROBE_TIMEOUT_MS); let response: ModelListResponse; try { - response = await request(url, modelListHeaders(endpoint, secret), controller.signal); + response = await request(url, modelListHeaders(source.wire, secret), controller.signal); } finally { clearTimeout(timeout); } diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index b2872f02..6ecf5bb6 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -76,20 +76,23 @@ export function accountBinding( } /** - * Resolve the session's account: explicit `opts.config.accountId`, else the agent's - * `activeAccountId`. Undefined when neither resolves or the id is stale (account deleted) — - * the caller then falls back to the legacy `providers[kind].apiKey`. + * Resolve the session's account: explicit `opts.accountId`, else the agent's `activeAccountId`. A + * requested id that no longer resolves falls through to that default rather than stranding the + * session — a relaunch replays a pin recorded on the run, and the account it names can be deleted in + * between. Undefined when neither resolves, which leaves the caller on the legacy + * `providers[kind].apiKey`. */ function resolveAccount( opts: StartOptions, config: ProviderConfig | undefined, accounts: Accounts, ): Account | undefined { - const requestedId = - typeof opts.config?.accountId === 'string' ? opts.config.accountId : undefined; - const id = requestedId ?? config?.activeAccountId; - if (id === undefined) return undefined; - return accounts.find((account) => account.id === id); + for (const id of [opts.accountId, config?.activeAccountId]) { + if (id === undefined) continue; + const account = accounts.find((candidate) => candidate.id === id); + if (account) return account; + } + return undefined; } /** The adapter-facing bundle an account contributes to `StartOptions.config`; each adapter maps @@ -115,14 +118,18 @@ 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 the agent's persisted + * model pick. Returns a new object; never mutates the input. */ export function applyProviderDefaults( opts: StartOptions, providers: ProvidersConfig, @@ -130,20 +137,21 @@ export function applyProviderDefaults( ): AppliedProviderDefaults { const config = providers[opts.kind]; const account = resolveAccount(opts, config, accounts); - if (!config && !account) return { options: opts }; - - const next: StartOptions = { ...opts }; - if (next.model === undefined) { - const model = account?.model ?? config?.defaultModel; - if (model !== undefined) next.model = model; - } + // The request's pick never survives resolution: `config` carries what the adapter reads, and the + // account that actually resolved is this function's answer. A stale id therefore cannot travel + // downstream and read back as an account the session does not have. + const { accountId: _requested, ...next } = { ...opts }; + // The account holds the models the user may pick from; the pick itself is per agent. + if (next.model === undefined && config?.model !== undefined) next.model = config.model; if (account) { const resolved = accountConfigBundle(account, opts.kind); if ('unavailable' in resolved) return { options: next, unavailable: resolved.unavailable }; - next.config = { ...next.config, ...resolved.bundle }; - } else 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 76a1d223..8e673ca6 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -1,5 +1,6 @@ import type { AdapterFactory } from '@linkcode/agent-adapter'; -import type { WirePayload } from '@linkcode/schema'; +import { modelListSource } from '@linkcode/providers'; +import type { AccountSecret, WirePayload } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; import { Effect } from 'effect'; @@ -150,7 +151,14 @@ export class AgentRequestHandler { payload.clientReqId, Effect.tryPromise({ try: async () => { - const models = await this.probeModels(payload.endpoint, payload.secret); + const source = modelListSource(payload.service); + if (!source) { + throw new Error(`${payload.service} serves no model list`); + } + const models = await this.probeModels( + source, + this.probeSecret(payload.service, payload.credential), + ); this.transport.send( createWireMessage({ kind: 'config.probe-models.result', @@ -205,6 +213,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/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index 67eb6d78..45293e9d 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 813e172c..24b093fd 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -2,6 +2,7 @@ import type { AdapterFactory, AgentAdapter, BrowserToolsetFactory } from '@linkc import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentEvent, + AgentHistoryCapabilities, AgentInput, ContentBlock, McpWarning, @@ -73,6 +74,12 @@ export class SessionOrchestrator { return session !== undefined && (session.turnInputActive || session.status === 'running'); } + /** The running adapter's history capabilities — asked of the live instance rather than a fresh + * one, so a caller about to tear it down learns what *this* session can do. */ + historyCapabilities(sessionId: SessionId): AgentHistoryCapabilities | undefined { + return this.sessions.get(sessionId)?.adapter.historyCapabilities; + } + replay(sessionId: SessionId): void { const session = this.sessions.get(sessionId); if (session) this.events.broadcast(sessionId, session.replay()); diff --git a/packages/host/engine/src/session/request-handler.ts b/packages/host/engine/src/session/request-handler.ts index 21725d99..bbdeb323 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 39fa3c9f..2f9f00ad 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 91566d02..fe1b1637 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/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 9ddcecb9..184eb6d1 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -333,6 +333,7 @@ export const en = { effortDefault: 'Default', effortShort: 'Def.', model: 'Model', + modelSwitchRestarts: 'Switching account restarts this thread and resumes it', effort: 'Effort', resetToDefault: 'Reset to default', add: 'Add', @@ -348,7 +349,7 @@ export const en = { attachmentUnsupportedAgent: "This agent doesn't support image attachments yet", attachmentReadFailed: 'Failed to read the file', approvalTitle: 'How should {agent} actions be approved?', - provider: 'Provider', + harness: 'Harness', }, mode: { label: 'Mode', @@ -768,9 +769,9 @@ export const en = { tabMarket: 'Market', tabMcp: 'MCP', tabSkills: 'Skills', - discoveryFailed: 'Could not read {provider} plugins: {reason}', + discoveryFailed: 'Could not read {harness} plugins: {reason}', discoveryFailedUnknown: 'discovery failed', - runtimeMissing: '{provider} was not detected; install it to manage its plugins here.', + runtimeMissing: '{harness} was not detected; install it to manage its plugins here.', installedEmptyHint: 'No plugins installed for this agent yet — pick one from Market.', marketEmptyHint: 'No installable entries in this agent’s plugin marketplace.', marketCount: '{count} available', @@ -867,7 +868,7 @@ export const en = { }, historyImport: { portalLabel: 'Import chat history', - panelTitle: 'Import chat history from {provider}', + panelTitle: 'Import chat history from {harness}', conversationCount: '{count, plural, one {# conversation} other {# conversations}}', refresh: 'Refresh', sortLabel: 'Sort order', @@ -880,7 +881,7 @@ export const en = { importedBadge: 'Imported', open: 'Open', emptyTitle: 'No history yet', - emptyHint: 'This provider has no local conversation history on this machine.', + emptyHint: 'This harness has no local conversation history on this machine.', loadFailedTitle: 'Failed to load history', retry: 'Retry', showingLatest: @@ -1025,16 +1026,29 @@ export const en = { copySecret: 'Copy', endpoint: 'Endpoint', protocols: 'Protocol shapes', - accountModel: 'Default model', + accountModel: 'Models', + models: { + label: 'Models', + hint: 'Fetch this service’s model list and tick the ones you want; only ticked models are offered in the composer.', + hintUnlistable: 'This endpoint serves no model list — add model ids by hand.', + refresh: 'Fetch list', + fetchFailed: 'Could not read the model list', + secretFirst: 'Enter the key first, then fetch the model list', + add: 'Add', + addPlaceholder: 'Add a model id by hand', + }, loginState: 'Login', loggedIn: 'Signed in', loggedOut: 'Signed out', oauthDelegate: 'Follows the {agent} CLI login', connections: 'Connected agents', connectionsEnabled: '{bound} / {available} enabled', - boundNote: 'Active provider for this agent', - boundElsewhere: 'Currently provided by “{label}”', - noProvider: 'No provider — follows the CLI login', + accountDefault: 'Shown in the model menu · used when no account is named', + accountEnabled: 'Shown in the model menu · “{label}” used when none is named', + accountEnabledNoDefault: 'Shown in the model menu · follows the CLI login when none is named', + accountDisabled: 'Hidden from this agent’s model menu', + setDefaultAccount: 'Use as default account', + clearDefaultAccount: 'Stop using as default account', translateBadge: 'Translated', translateNote: 'A local gateway translates Anthropic wire to OpenAI Chat', unavailableOauth: 'Only connects to {agent}', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6978c123..a6002ec6 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -323,6 +323,7 @@ export const zhCN = { effortDefault: '默认', effortShort: '默认', model: '模型', + modelSwitchRestarts: '切换账号将重启并恢复此对话', effort: '推理强度', resetToDefault: '恢复默认设置', add: '添加', @@ -338,7 +339,7 @@ export const zhCN = { attachmentUnsupportedAgent: '当前 agent 暂不支持图片附件', attachmentReadFailed: '读取文件失败', approvalTitle: '如何审批 {agent} 的操作?', - provider: '提供方', + harness: '编码助手', }, mode: { label: '模式', @@ -753,9 +754,9 @@ export const zhCN = { tabMarket: '市场', tabMcp: 'MCP', tabSkills: '技能', - discoveryFailed: '无法读取 {provider} 的插件:{reason}', + discoveryFailed: '无法读取 {harness} 的插件:{reason}', discoveryFailedUnknown: '扫描失败', - runtimeMissing: '未检测到 {provider},安装后即可在这里管理它的插件。', + runtimeMissing: '未检测到 {harness},安装后即可在这里管理它的插件。', installedEmptyHint: '该智能体还没有安装任何插件;到「市场」里挑一个。', marketEmptyHint: '该智能体的插件市场里没有可安装的条目。', marketCount: '{count} 个可安装', @@ -850,7 +851,7 @@ export const zhCN = { }, historyImport: { portalLabel: '导入聊天历史', - panelTitle: '从 {provider} 导入聊天历史', + panelTitle: '从 {harness} 导入聊天历史', conversationCount: '{count} 条对话', refresh: '刷新', sortLabel: '排序方式', @@ -863,7 +864,7 @@ export const zhCN = { importedBadge: '已导入', open: '打开', emptyTitle: '暂无历史对话', - emptyHint: '该提供方在本机还没有历史对话。', + emptyHint: '该编码助手在本机还没有历史对话。', loadFailedTitle: '无法加载历史记录', retry: '重试', showingLatest: '仅显示最近 {count} 条对话', @@ -999,16 +1000,29 @@ export const zhCN = { copySecret: '复制', endpoint: '端点', protocols: '协议形态', - accountModel: '默认模型', + accountModel: '可用模型', + models: { + label: '可用模型', + hint: '获取该服务的模型列表后勾选;只有勾选的模型会出现在输入框的模型选择里。', + hintUnlistable: '该端点不提供模型列表,请手动填写模型 ID。', + refresh: '获取列表', + fetchFailed: '获取模型列表失败', + secretFirst: '请先填写密钥,再获取模型列表', + add: '添加', + addPlaceholder: '手动添加模型 ID', + }, loginState: '登录状态', loggedIn: '已登录', loggedOut: '未登录', oauthDelegate: '跟随 {agent} CLI 登录', connections: '接入的智能体', connectionsEnabled: '{bound} / {available} 已启用', - boundNote: '此账号为当前 Provider', - boundElsewhere: '当前由「{label}」提供', - noProvider: '当前无 Provider · 跟随 CLI 登录', + accountDefault: '在模型菜单中显示 · 未指定账号时使用此账号', + accountEnabled: '在模型菜单中显示 · 未指定时使用「{label}」', + accountEnabledNoDefault: '在模型菜单中显示 · 未指定账号时跟随 CLI 登录', + accountDisabled: '不在此智能体的模型菜单中显示', + setDefaultAccount: '设为默认账号', + clearDefaultAccount: '取消默认账号', translateBadge: '经转换', translateNote: '本地网关将 Anthropic 协议转为 OpenAI Chat', unavailableOauth: '仅可接入 {agent}', diff --git a/packages/presentation/ui/src/__tests__/agent-models.test.ts b/packages/presentation/ui/src/__tests__/agent-models.test.ts index 58ed7e47..cb69d947 100644 --- a/packages/presentation/ui/src/__tests__/agent-models.test.ts +++ b/packages/presentation/ui/src/__tests__/agent-models.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import { effortOptionsForModel } from '../shell/agent-efforts'; -import { AGENT_MODEL_OPTIONS, groupModelsByProvider, resolveModel } from '../shell/agent-models'; +import { + AGENT_MODEL_OPTIONS, + groupModelsByProvider, + pickableModels, + resolveModel, + switchesAccount, +} from '../shell/agent-models'; const claude = AGENT_MODEL_OPTIONS['claude-code']; const codex = AGENT_MODEL_OPTIONS.codex; @@ -26,6 +32,40 @@ describe('resolveModel', () => { }); }); +describe('pickableModels', () => { + const account = [{ id: 'deepseek-v4-pro', label: 'DeepSeek', accountId: 'acc_x' }]; + const own = [{ id: 'opencode/native', label: 'Native' }]; + + it('keeps an agent’s own catalog when it does not resolve through an account', () => { + // opencode and pi accept any endpoint, so a key added for another agent is "bindable" to them; + // replacing here would hand an unrelated vendor's models to a thread on its own CLI login. + expect(pickableModels(account, own, { throughAccount: false })).toEqual([...account, ...own]); + expect(pickableModels(undefined, own, { throughAccount: false })).toEqual(own); + expect(pickableModels(account, null, { throughAccount: false })).toEqual(account); + }); + + it('lets the account world stand alone once one resolves', () => { + expect(pickableModels(account, own, { throughAccount: true })).toEqual(account); + // Present-and-empty is a real answer — "this account offers nothing" — not a missing one. + expect(pickableModels([], own, { throughAccount: true })).toEqual([]); + }); +}); + +describe('switchesAccount', () => { + const onSecond = { id: 'model-a', label: 'A', accountId: 'acc_second' }; + + it('flags an entry from an account the session is not running on', () => { + expect(switchesAccount(onSecond, 'acc_first')).toBe(true); + expect(switchesAccount(onSecond, 'acc_second')).toBe(false); + }); + + it('stays false when either side has no account to compare', () => { + // A draft has no running account, and a curated-table entry belongs to none. + expect(switchesAccount(onSecond, undefined)).toBe(false); + expect(switchesAccount({ id: 'model-a', label: 'A' }, 'acc_first')).toBe(false); + }); +}); + describe('groupModelsByProvider', () => { const multiProvider = [ { id: 'opencode/hy3', label: 'Hy3', description: 'OpenCode Zen' }, diff --git a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx index 5f7cb78f..e5032cd4 100644 --- a/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/new-session-surface.test.tsx @@ -4,6 +4,7 @@ import type { AgentStartCatalog } from '@linkcode/schema'; import { WorkspaceIdSchema } from '@linkcode/schema'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { wait } from 'foxts/wait'; import { useState } from 'react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import type { NewSessionBranchPickerComponentProps } from '../new-session-branch-picker'; @@ -43,6 +44,7 @@ const PROJECT_WORKSPACE = { }; const RE_MODEL_DEFAULT = /modelDefault/; const RE_SONNET_5 = /Sonnet 5/; +const RE_DEEPSEEK_PRO = /DeepSeek V4 Pro/; const RE_CONFIGURED_CLAUDE_MODEL = /configured\/claude-model/; const RE_OPUS_4_8 = /Opus 4.8/; const RE_MEDIUM_EFFORT = /Medium/; @@ -54,8 +56,10 @@ const RE_PI_WIDE = /Pi Wide/; const RE_HIGH_EFFORT = /High/; const RE_LOW_EFFORT = /Low/; const RE_GPT_56_SOL = /GPT-5.6-Sol/; -const RE_PROVIDER_CLAUDE_CODE_MENU = /provider.*Claude Code/; +const RE_HARNESS_CLAUDE_CODE_MENU = /harness.*Claude Code/; const RE_MODEL_SONNET_5_MENU = /model.*Sonnet 5/; +const RE_OPUS_5 = /Opus 5/; +const RE_MODEL_MENU = /^model/; const RE_MODEL_GPT_56_SOL_MENU = /model.*GPT-5\.6-Sol/; const RE_MODEL_DEFAULT_MENU = /model.*modelDefault/; const RE_MODEL_PI_SONNET_MENU = /model.*Pi Sonnet/; @@ -118,7 +122,7 @@ describe('NewSessionSurface', () => { render( { { { { { render( { render( { render( { render( { render( { { { { render( { render( { render( { await user.click(screen.getByRole('button', { name: RE_SONNET_5 })); const providerItem = await screen.findByRole('menuitem', { - name: RE_PROVIDER_CLAUDE_CODE_MENU, + name: RE_HARNESS_CLAUDE_CODE_MENU, }); expect(screen.getByRole('menuitem', { name: RE_MODEL_SONNET_5_MENU })).toBeTruthy(); providerItem.focus(); @@ -608,7 +616,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} preferredEfforts={{ 'claude-code': 'medium' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -635,7 +643,7 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} defaultModels={{ 'claude-code': 'custom/claude-model' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -660,7 +668,7 @@ describe('NewSessionSurface', () => { const props = { chatWorkspace: CHAT_WORKSPACE, draft: { - initialProvider: 'claude-code' as const, + initialHarness: 'claude-code' as const, initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }, mentionItems: [], @@ -686,15 +694,16 @@ describe('NewSessionSurface', () => { expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); }); - it('shows and explicitly submits the last successful provider model without reselection', async () => { + it('shows and explicitly submits the configured model without reselection', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { typeInComposer('use my last model'); await pressInComposer('Enter'); - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-opus-4-8' })), - ); + // Shown but not re-sent: the daemon resolves the configured model, so specifying it again would + // only risk the two disagreeing. + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); it('drops remembered Codex ultra when the fallback model switches to Luna', async () => { @@ -721,8 +731,8 @@ describe('NewSessionSurface', () => { { expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('effort'); }); - it('submits a remembered dynamic-provider model even without a draft catalog', async () => { + it('shows a configured dynamic-provider model even without a draft catalog', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( { typeInComposer('use remembered dynamic model'); await pressInComposer('Enter'); - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ model: 'anthropic/claude-sonnet-4-6' }), - ), - ); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); - it('can return remembered model and effort choices to provider defaults', async () => { + it('can return remembered model and effort choices to the configured ones', async () => { const user = userEvent.setup(); const onSubmit = vi.fn().mockResolvedValue(undefined); render( @@ -776,9 +783,8 @@ describe('NewSessionSurface', () => { chatWorkspace={CHAT_WORKSPACE} defaultModels={{ 'claude-code': 'configured/claude-model' }} preferredEfforts={{ 'claude-code': 'high' }} - preferredModels={{ 'claude-code': 'claude-opus-4-8' }} draft={{ - initialProvider: 'claude-code', + initialHarness: 'claude-code', initialWorkspaceId: CHAT_WORKSPACE.workspaceId, }} mentionItems={[]} @@ -789,6 +795,11 @@ describe('NewSessionSurface', () => { />, ); + // Pick a model locally, so there is something to reset back to the configured one. + await user.click(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Opus 4.8' })); + await user.click(screen.getByRole('button', { name: RE_OPUS_4_8 })); await user.click(await screen.findByRole('menuitem', { name: 'resetToDefault' })); expect(screen.getByRole('button', { name: RE_CONFIGURED_CLAUDE_MODEL })).toBeTruthy(); @@ -799,9 +810,203 @@ describe('NewSessionSurface', () => { typeInComposer('use provider defaults'); await pressInComposer('Enter'); await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); - expect(onSubmit.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ model: null, effort: null }), + const submitted = onSubmit.mock.calls[0]?.[0]; + expect(submitted).toEqual(expect.objectContaining({ effort: null })); + // No "back to the provider's own model" tier any more: an absent model defers to the + // agent's persisted pick, which the daemon resolves. + expect(submitted?.model).toBeUndefined(); + }); + + it('starts on the account the picked model belongs to, not the one bound', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + // Two accounts → one submenu each. Submenu triggers are keyboard-driven here: base-ui leaves + // them `pointer-events: none` in jsdom. + await user.click(screen.getByRole('button', { name: RE_OPUS_5 })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + (await screen.findByRole('menuitem', { name: 'DeepSeek' })).focus(); + await user.keyboard('{ArrowRight}'); + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })); + typeInComposer('hello'); + await pressInComposer('Enter'); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ model: 'deepseek-v4-pro', accountId: 'acc_ds' }), + ), + ); + }); + + it("offers only the account world's picked models once an agent resolves through one", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_DEEPSEEK_PRO })); + await user.click(await screen.findByRole('menuitem', { name: RE_DEEPSEEK_PRO })); + expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); + // The curated Anthropic table would otherwise supply these for claude-code. + expect(screen.queryByRole('menuitemradio', { name: 'Opus 5' })).toBeNull(); + }); + + it('refuses to send when an agent resolves through an account but no model is picked', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('keeps an agent’s own catalog on offer when no account resolves for it', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + await user.click(screen.getByRole('button', { name: RE_PI_SONNET })); + await user.click(await screen.findByRole('menuitem', { name: RE_MODEL_MENU })); + expect(await screen.findByRole('menuitemradio', { name: 'DeepSeek V4 Pro' })).toBeTruthy(); + expect(screen.getByRole('menuitemradio', { name: RE_PI_SONNET })).toBeTruthy(); + + // And it stays sendable: with no account resolving, the agent picks its own model. + await user.keyboard('{Escape}'); + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('does not let an adapter default unblock a send the daemon would refuse', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('pins no account on an untouched draft, leaving the agent’s default authoritative', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const shared = (accountId: string) => ({ id: 'shared-model', label: 'Shared', accountId }); + render( + , ); + + typeInComposer('hello'); + await pressInComposer('Enter'); + await wait(0); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('accountId'); }); it('submits a model only after the user explicitly selects it', async () => { @@ -810,8 +1015,10 @@ describe('NewSessionSurface', () => { render( { render( { }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -908,7 +1115,7 @@ describe('NewSessionSurface', () => { { pi: { ...PI_CONFIGURED_CATALOG, defaultModel: 'pi/basic' }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -954,7 +1161,7 @@ describe('NewSessionSurface', () => { agentCatalogs={{ pi: PI_CONFIGURED_CATALOG }} chatWorkspace={CHAT_WORKSPACE} defaultModels={{ pi: 'pi/wide' }} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -977,7 +1184,7 @@ describe('NewSessionSurface', () => { agentCatalogs={{ pi: modelless }} chatWorkspace={CHAT_WORKSPACE} defaultModels={{ pi: 'pi/wide' }} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -997,7 +1204,7 @@ describe('NewSessionSurface', () => { agentCatalogs={{ pi: PI_CONFIGURED_CATALOG }} chatWorkspace={CHAT_WORKSPACE} defaultModels={{ pi: 'pi/basic' }} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1009,29 +1216,29 @@ describe('NewSessionSurface', () => { expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); }); - it("lets a remembered pick outrank the agent's own default", async () => { + it("lets the configured model outrank the agent's own default", async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render( , ); + // The configured model wins the display over `catalog.defaultModel`; neither travels, since the + // daemon resolves the configured one itself. expect(screen.getByRole('button', { name: RE_PI_BASIC })).toBeTruthy(); typeInComposer('use my last model'); await pressInComposer('Enter'); - // A remembered pick is an explicit choice, so unlike the catalog default it does travel. - await waitFor(() => - expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ model: 'pi/basic' })), - ); + await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); + expect(onSubmit.mock.calls[0]?.[0]?.model).toBeUndefined(); }); it('submits compatible Pi catalog choices and suppresses stale effort for models without it', async () => { @@ -1053,7 +1260,7 @@ describe('NewSessionSurface', () => { }, }} chatWorkspace={CHAT_WORKSPACE} - draft={{ initialProvider: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} + draft={{ initialHarness: 'pi', initialWorkspaceId: CHAT_WORKSPACE.workspaceId }} mentionItems={[]} onMentionQueryChange={vi.fn()} onRegisterWorkspace={vi.fn().mockResolvedValue(CHAT_WORKSPACE)} @@ -1102,7 +1309,7 @@ describe('NewSessionSurface', () => { render( ; } +/** + * Identity of one entry in a model menu. The model id alone is not unique once a list spans + * accounts — a direct DeepSeek account and an OpenRouter one both serve `deepseek-v4-pro` — and + * reusing it as a React key or a radio value collapses the two into one unselectable row. + */ +export function modelChoiceKey(option: ModelOption): string { + return `${option.accountId ?? ''}:${option.id}`; +} + +/** + * The models a surface may offer. An agent resolving *through* an account offers that account world + * alone. One running on its own login keeps its own catalog and merely *gains* the accounts as extra + * options — replacing it would let a key added for one agent hijack another's menu, since agents + * that accept any endpoint (opencode, pi) treat every account as bindable. + */ +export function pickableModels( + accountSet: ModelOption[] | null | undefined, + ownCatalog: ModelOption[] | null | undefined, + { throughAccount }: { throughAccount: boolean }, +): ModelOption[] | undefined { + // Null and absent both mean "this source offers nothing"; only present-and-empty is a real set. + if (throughAccount) return accountSet ?? undefined; + if (accountSet === null || accountSet === undefined) return ownCatalog ?? undefined; + if (ownCatalog === null || ownCatalog === undefined) return accountSet; + return [...accountSet, ...ownCatalog]; +} + +/** Whether picking this entry leaves the account a session is currently running on. Credentials and + * base URL are injected once at spawn, so such a pick relaunches the agent rather than rebinding it + * in place. Unknown accounts on either side mean the question doesn't apply. */ +export function switchesAccount( + option: ModelOption, + currentAccountId: string | undefined, +): boolean { + return ( + currentAccountId !== undefined && + option.accountId !== undefined && + option.accountId !== currentAccountId + ); +} + /** Group a catalog by its provider subtitle (`description`, per the adapter convention above), * preserving catalog order within groups and first-appearance order across them. Returns null * below two distinct providers — a single-provider list reads better flat. */ @@ -44,26 +88,23 @@ export function groupModelsByProvider( /** Resolve a reflected model id (from `model-update`) to its catalog entry. The daemon emits the * *served* id, which may be a pinned snapshot of an alias (e.g. `claude-haiku-4-5-20251001`); - * prefix-match only after an exact match fails so `gpt-5.4-mini` never mis-resolves to `gpt-5.4`. */ + * prefix-match only after an exact match fails so `gpt-5.4-mini` never mis-resolves to `gpt-5.4`. + * `accountId` narrows first where known, so a list spanning accounts labels the right entry. */ export function resolveModel( options: readonly ModelOption[] | undefined, id: string | null, + accountId?: string, ): ModelOption | undefined { if (id === null) return undefined; + const scoped = + accountId === undefined ? options : options?.filter((option) => option.accountId === accountId); + const candidates = scoped?.length ? scoped : options; return ( - options?.find((option) => option.id === id) ?? - options?.find((option) => id.startsWith(`${option.id}-`)) + candidates?.find((option) => option.id === id) ?? + candidates?.find((option) => id.startsWith(`${option.id}-`)) ); } -/** Verified provider defaults used before a session exists to reflect its served model. A saved - * account/provider default supplied by the workbench takes precedence over these values. */ -export const AGENT_DEFAULT_MODELS: Readonly>> = { - 'claude-code': 'claude-sonnet-5', - codex: 'gpt-5.6-sol', - 'grok-build': 'grok-4.5', -}; - const CODEX_BASE_EFFORTS = ['low', 'medium', 'high', 'xhigh'] satisfies EffortLevel[]; /** diff --git a/packages/presentation/ui/src/shell/composer-controls.tsx b/packages/presentation/ui/src/shell/composer-controls.tsx index 7860235a..2462bf7e 100644 --- a/packages/presentation/ui/src/shell/composer-controls.tsx +++ b/packages/presentation/ui/src/shell/composer-controls.tsx @@ -30,7 +30,12 @@ import { AGENT_LABELS, AgentIcon } from '../chat/agent-icon'; import type { EffortOption } from './agent-efforts'; import { EFFORT_OPTIONS_BY_ID } from './agent-efforts'; import type { ModelOption } from './agent-models'; -import { groupModelsByProvider, resolveModel } from './agent-models'; +import { + groupModelsByProvider, + modelChoiceKey, + resolveModel, + switchesAccount, +} from './agent-models'; import type { AgentRuntimeCue, AgentRuntimeCues } from './agent-onboarding-card'; // Linear lookup: the policy/effort lists are a handful of entries at most. @@ -145,7 +150,7 @@ export function ApprovalPolicyMenu({ ); } -// Known workflow-mode glyphs; unknown provider modes fall back to a generic one. +// Known workflow-mode glyphs; unknown harness modes fall back to a generic one. const MODE_CHIP_ICONS: Record = { plan: ListTodoIcon, goal: TargetIcon, @@ -182,7 +187,7 @@ export function SessionModeChip({ ); } -/** Availability badge on a provider submenu item; nothing renders for a ready runtime. */ +/** Availability badge on a harness submenu item; nothing renders for a ready runtime. */ function RuntimeCueBadge({ cue }: { cue?: AgentRuntimeCue }): React.ReactNode { const t = useTranslations('workbench.agentRuntime'); if (!cue) return null; @@ -211,57 +216,91 @@ function RuntimeCueBadge({ cue }: { cue?: AgentRuntimeCue }): React.ReactNode { ); } +/** One model entry. `description` is the account label the flat list needs to disambiguate; a + * provider submenu already names it and passes none. */ +function ModelMenuItem({ + option, + description, + restartHint, +}: { + option: ModelOption; + description?: string; + restartHint?: string; +}): React.ReactNode { + return ( + + + {option.label} + {description ? {description} : null} + {restartHint ? {restartHint} : null} + + + ); +} + export function ModelSelectorMenu({ disabled, - provider, - selectableProviders, + harness, + selectableHarnesses, runtimeCues, modelOptions, effortOptions, selectedModelId, + selectedAccountId, + accountSwitchRestarts = false, selectedEffortId, onSelectModel, onSelectEffort, onResetModel, onResetEffort, - onSelectProvider, + onSelectHarness, }: { disabled: boolean; - provider?: AgentKind; - /** Providers offered for selection; absent/empty when the session's provider is fixed. */ - selectableProviders?: AgentKind[]; - /** Runtime availability per provider: a cue renders as a muted badge on the submenu item. */ + harness?: AgentKind; + /** Harnesses offered for selection; absent/empty when the session's harness is fixed. */ + selectableHarnesses?: AgentKind[]; + /** Runtime availability per harness: a cue renders as a muted badge on the submenu item. */ runtimeCues?: AgentRuntimeCues; modelOptions?: ModelOption[]; effortOptions?: EffortOption[]; selectedModelId: string | null; + /** Disambiguates the selection when the list spans accounts serving the same model id. */ + selectedAccountId?: string; + /** Live sessions only: credentials are injected at spawn, so leaving `selectedAccountId` relaunches + * the agent. Entries from another account say so; a draft has nothing to restart. */ + accountSwitchRestarts?: boolean; selectedEffortId: EffortLevel | null; - onSelectModel: (model: string) => void; + /** Carries the whole entry: a cross-account list needs the account alongside the id. */ + onSelectModel: (model: ModelOption) => void; onSelectEffort: (effort: EffortLevel) => void; - /** Draft-only escape hatch back to the provider/configured model default. */ + /** Draft-only escape hatch back to the harness/configured model default. */ onResetModel?: () => void; - /** Draft-only escape hatch back to the provider effort default. */ + /** Draft-only escape hatch back to the harness effort default. */ onResetEffort?: () => void; - onSelectProvider?: (provider: AgentKind) => void; + onSelectHarness?: (harness: AgentKind) => void; }): React.ReactNode { const t = useTranslations('workbench.composer'); - const selectedModel = resolveModel(modelOptions, selectedModelId); + const selectedModel = resolveModel(modelOptions, selectedModelId, selectedAccountId); const providerGroups = groupModelsByProvider(modelOptions); + const restartHintFor = (option: ModelOption): string | undefined => + accountSwitchRestarts && switchesAccount(option, selectedAccountId) + ? t('modelSwitchRestarts') + : undefined; const selectedEffort = optionById(effortOptions, selectedEffortId) ?? (selectedEffortId ? EFFORT_OPTIONS_BY_ID[selectedEffortId] : undefined); - const providers = selectableProviders ?? []; + const harnesses = selectableHarnesses ?? []; const hasEfforts = Boolean(effortOptions?.length); const hasModels = Boolean(modelOptions?.length); const modelLabel = selectedModel?.label ?? selectedModelId ?? t('modelDefault'); const effortLabel = selectedEffort?.label ?? t('effortDefault'); - // A draft provider picker must keep the model axis visible even when that provider discovers + // A draft harness picker must keep the model axis visible even when that harness discovers // its concrete model only after session start (OpenCode/Pi). The live update replaces Default. - const showsModel = providers.length > 0 || hasModels || selectedModelId !== null; + const showsModel = harnesses.length > 0 || hasModels || selectedModelId !== null; - if (!hasEfforts && !showsModel && providers.length === 0) return null; + if (!hasEfforts && !showsModel && harnesses.length === 0) return null; const selectorLabels: string[] = []; - if (provider) selectorLabels.push(AGENT_LABELS[provider]); + if (harness) selectorLabels.push(AGENT_LABELS[harness]); if (showsModel) selectorLabels.push(modelLabel); if (hasEfforts) selectorLabels.push(`${t('effort')}: ${effortLabel}`); @@ -272,7 +311,7 @@ export function ModelSelectorMenu({ disabled={disabled} render={ onSetBinding(binding.kind, checked ? accountId : undefined)} + onCheckedChange={(checked) => onSetAccountEnabled(agent.kind, checked)} /> ); diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 5b3dc521..6e2d3036 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -11,6 +11,7 @@ import type { } from '@linkcode/schema'; import type { ConversationViewModel } from '../chat'; import type { PermissionDecision } from '../chat/conversation-prompts'; +import type { ModelOption } from './agent-models'; import type { AgentRuntimeCues } from './agent-onboarding-card'; import type { MentionItem } from './composer'; import type { ConversationComposerController } from './conversation-surface'; @@ -57,8 +58,11 @@ export interface ShellFrameProps agentCatalogs?: AgentStartCatalogs; /** Effective daemon-configured default models for new sessions; null while unresolved. */ newSessionDefaultModels: Readonly>> | null; - /** Last model accepted by LinkCode per provider, submitted as a new-session override. */ - newSessionPreferredModels: Readonly>>; + /** The account each agent falls back to when a session names none. */ + newSessionDefaultAccounts?: Readonly>>; + /** The models each agent may run on, pooled from the accounts enabled for it. An agent absent here + * has no account that can back it and falls back to its adapter or the curated table. */ + accountModels: Readonly>> | null; /** Last effort accepted by LinkCode per provider for new sessions. */ newSessionPreferredEfforts: Readonly>>; newSessionPreferredBranches: Readonly>; @@ -131,7 +135,8 @@ export function ShellFrame({ attachmentSupport, agentCatalogs, newSessionDefaultModels, - newSessionPreferredModels, + newSessionDefaultAccounts, + accountModels, newSessionPreferredEfforts, newSessionPreferredBranches, NewSessionBranchPickerComponent, @@ -222,7 +227,8 @@ export function ShellFrame({ attachmentSupport={attachmentSupport} agentCatalogs={agentCatalogs} defaultModels={newSessionDefaultModels} - preferredModels={newSessionPreferredModels} + defaultAccounts={newSessionDefaultAccounts} + accountModels={accountModels} preferredEfforts={newSessionPreferredEfforts} preferredBranches={newSessionPreferredBranches} NewSessionBranchPickerComponent={NewSessionBranchPickerComponent} @@ -243,6 +249,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}