From 6ba86414bf326823862bdeb9c1f57ff8533d99a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:20:52 -0700 Subject: [PATCH] Show model and effort choices for every agent before its session starts (#26407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): one saved model catalog for every structured agent Every agent registration now declares how its models are listed without a session (a probe, or explicitly none). Probes share one supervised runner (15 s budget, bounded output, teardown on every path). Grok lists from initialize (or x.ai/models/list), OpenCode from models --verbose, Pi from --list-models; Claude moves onto the shared runner. ACP option reads no longer copy the running model's effort menu or selection onto every model. Desktop and phone pickers read the same catalog for every agent, with a non-pickable provider-default model pill before any list exists. * fix(native-chat): host names a listing's configured model; live listings saved for every agent - The catalog answer carries an optional listingNamesConfiguredModel field, set per registration (Codex, Grok) and per workspace; pickers name the listed default from it instead of a Codex-only client flag (older hosts fall back). - Grok, OpenCode and Pi live option reads save their model list through one host step, keeping the account's known default and per-model efforts. - Phone reads the host catalog like the desktop and seeds every agent. - Saved picks accept any known agent id (OpenCode, Pi). - ACP agents with no session-free listing register as unavailable; Grok's live effort menu falls back to the session's when the advertised one is empty. * fix(native-chat): discovery and live listings kept apart; every agent's live listing saved by the host; phone-created chats name their model * fix(native-chat): phone new-chat mark clears on the chat's first live answer; Claude's startup listing reaches the host catalog * fix(native-chat): live-listing type lives beside the catalog entry so wire types don't pull the store's persistence into the phone * fix(native-chat): the model picker never waits on a listing; a first listing lands in place * feat(native-chat): the host lists every agent's catalog ahead of use and traces each listing * feat(native-chat): a new Claude chat names the model and effort its config resolves to * test(native-chat): catalog service fakes gain prewarm * test(native-chat): prewarm span recorder without a type assertion * fix(native-chat): project agent config overrides the account default only when it sets a model or effort A committed .claude/settings.json for permissions or hooks, or the settings.local.json Claude writes on always-allow, no longer hides the configured Claude model in that project. Unreadable config still counts; Codex config.toml follows the same rule. * test(native-chat): the Codex picker read and the launch it starts resolve one account key, through the real resolvers Runs resolveHostCodexHomePathForLaunchReadOnly and prepareCodexRuntimeHomeForLaunch as the runtime service wires them, for the system account on the real home, on the shared runtime home, and a managed account. Also scopes the prewarm suite to agents in use. * fix(native-chat): Claude's configured model and effort are saved together behind the project check; prewarm lists only agents in use and stops with its host A live Claude chat no longer stamps its effort onto the account catalog; the effort travels with the configured default, which a no-pick chat whose config resolves to no listed model now forgets. Prewarm lists agents with a saved catalog or a chat, and host teardown (quit included) starts no queued listing and stops running ones through an abort signal the listing runners honor. * test(native-chat): new catalog tests use typed hoisted state and type-only namespace imports * test(native-chat): a prewarm probe sets and clears the sign-in verdict like a read's, and re-checks an aged one beside a fresh catalog * fix(native-chat): an aged sign-in verdict re-probes only from probe paths, not a Codex chat's picker The main merge put the held-verdict check into store.shouldRefresh, which also gates the live Codex picker. A chat's own listing never clears the verdict, so a signed-out Codex whose chat works re-listed model/list on every picker read. shouldRefresh is back to main's rule; the catalog read's probe and prewarm ask probeDue instead. * fix(native-chat): Claude project settings with an effort cap, a main-thread agent or an Anthropic cloud provider count as an override maxEffortLevel clamps every effort including the model default, and agent applies that agent's model (both from the bundled SDK settings type). CLAUDE_CODE_USE_ANTHROPIC_AWS and CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD sit in the SDK's provider-switch list beside Bedrock and Vertex. A project that sets only these no longer shows, or saves as the account default, the account's model or effort. * refactor(native-chat): the catalog store's picker waiters live in their own module No behavior change: pendingListing and the waiter notifications move as they were, which keeps the store under its line limit for the fixes that follow. * fix(native-chat): changing an agent's command or environment re-lists its catalogs and re-checks its notice The settings change called prewarm, but prewarm skipped any catalog still fresh (the catalog key is agent + account, not the command), and a held "not installed" verdict kept its 30-second window. The change now marks that agent's catalogs due and ages its held verdicts, so the prewarm it triggers re-lists them and re-checks the notice. Only agents whose command or env actually changed are touched. * test(native-chat): the sign-in notice tests check the model picker is really usable They checked only that a model entry existed, which a disabled or empty pill also passes. They now check it is settable, carries no disabled reason, and offers the catalog's or the seed's model. * fix(native-chat): a probe the host stops keeps the held notice reason, and a stopped prewarm reads nothing A probe aborted because the host is going away says nothing about the account, so it no longer replaces a held sign-in or not-installed reason with a plain failure. A prewarm after stop returns before reading chat records. * test(codex): the catalog account-key test uses the runtime's real Codex launch-home resolvers The resolvers move to codex-launch-preparation as one export the runtime service spreads, so the test runs exactly what the runtime is handed instead of a copy. * feat(native-chat): keep each host's saved model lists in the renderer A chat picker could only learn the host's saved model list by reading it after it mounted, so its first frame was a guess. The renderer now keeps the host's answers per host and agent: this machine's lists are read once its runtime answers, for the agents its chats were used with; a paired host's again when this client regains contact; and an account or launch setting change drops this machine's lists. Every entry is a host answer. * fix(native-chat): render the model pills once, from the host's list A new chat's composer painted its first frame from the built-in model list: a saved pick that list lacks (Claude's `opus[1m]`) showed as a raw id with no effort pill, then jumped to "Opus (1M context) · High" when the host's list landed. Every new chat painted a guess and then corrected it. Now the first frame reads the host's saved list the renderer already holds, so it is the final frame. Without one, the quiet "Model" placeholder stands until the host answers; a host with nothing saved makes the built-in list usable, naming nothing, until its listing lands. A saved pick is named only from a host or session list that names it. The phone follows the same rule and keeps the host's list across a new fence. * feat(native-chat): answer a saved-only model catalog read The app could preload a host's saved model lists only for agents with a saved pick, because the catalog read starts a listing (spawns the agent's CLI) when nothing is saved. Grok, OpenCode and OMP chats with no saved pick were never preloaded, so their pickers showed "Model" before the host's list. `agentSession.modelCatalog` now takes `savedOnly`: the host answers from what it saved (the entry, configured default and held reason) and starts, joins or re-checks no listing. Its params are strict, so the host advertises `agent-session.model-catalog.saved-only.v1` and clients send it only to hosts that do. An answer also says when its named default holds in every workspace (Grok reads no project config for its model), so a new chat in any workspace can use it on its first frame. OMP stays without a session-free listing: `omp models --json` lists every model without the `enabledModels` filter its chats apply. * fix(native-chat): preload every registered agent's saved model list Grok, OpenCode and OMP chats painted the "Model" placeholder, then the host's pills, because the app preloaded only agents with a saved pick. Now the app reads every agent a host registered through the saved-only read: this machine once its runtime and agents are known, a paired host at connect and on each reconnect, and this machine again after an account or agent-setting change. A host that does not advertise the saved-only read keeps the earlier rule (saved picks only; a paired host's answered agents). An agent the host saved nothing for stays unknown. An answer sent before the host's lists were dropped is not recorded. * test(native-chat): drop a type assertion from the snapshot sync test * fix(native-chat): learn an ACP agent's default from a chat started with no pick Grok's session-free listing named the model `initialize` computed (grok-4.7), but chats ran another (grok-4.6), so a new chat painted 4.7 and then jumped. The listing no longer names a default; a new ACP session with no model pick reports what it runs, and the host saves that as the account's configured default behind the workspace check. A catalog read waits for a recording still in flight, so the chat's own follow-up read names it. * feat(native-chat): check OpenCode and OMP project config before naming their default OpenCode reads opencode.json(c) and .opencode/ in every ancestor of the workspace; OMP merges its .omp settings and other agents' project settings. A workspace whose config sets a model, effort or provider key (or can't be parsed) names no default; any other lets a no-pick chat's model become the account's default, so the next new chat names it on its first frame. * fix(native-chat): re-read the host's list once a no-pick chat reports its model The renderer kept the answer it read before the chat started, so the next new chat's first frame still named no model. A new chat that named no model reads the host's list again once it reports what it runs; a default no workspace can replace also serves chats in other worktrees. --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + .../mobile-created-structured-sessions.ts | 30 ++ .../mobile-existing-agent-launch.test.ts | 5 + .../session/mobile-existing-agent-launch.ts | 4 + ...le-structured-agent-session-launch.test.ts | 5 + .../mobile-structured-agent-session-launch.ts | 2 + .../use-mobile-host-model-catalog-upgrade.ts | 113 +++++++ ...ructured-agent-options.first-frame.test.ts | 102 ++++++ ...se-mobile-structured-agent-options.test.ts | 226 ++++++++++++- .../use-mobile-structured-agent-options.ts | 54 +++- ...e-mobile-structured-agent-session.test.tsx | 11 +- src/main/acp/acp-dialects/acp-dialect.ts | 5 +- src/main/acp/acp-dialects/grok-dialect.ts | 2 + .../acp/acp-dialects/grok-model-catalog.ts | 125 ++++++++ src/main/acp/acp-launch-specs.ts | 51 +++ src/main/acp/acp-model-catalog-probe.test.ts | 192 +++++++++++ src/main/acp/acp-model-catalog-probe.ts | 88 +++++ src/main/acp/acp-session-runtime.ts | 10 + src/main/acp/acp-structured-acquire.ts | 4 +- ...structured-host-configured-default.test.ts | 153 +++++++++ .../acp-structured-host-live-listing.test.ts | 34 ++ .../acp/acp-structured-host.test-support.ts | 9 +- .../acp/acp-structured-launch-resolution.ts | 44 ++- src/main/acp/acp-structured-options.test.ts | 174 ++++++++++ src/main/acp/acp-structured-options.ts | 76 ++++- .../acp/acp-structured-session-adapter.ts | 8 +- .../claude/claude-model-catalog-probe.test.ts | 240 ++++++-------- src/main/claude/claude-model-catalog-probe.ts | 63 ++-- .../claude-structured-acquire-catalog.ts | 11 - .../claude-structured-catalog-listing.ts | 108 +++++++ ...aude-structured-configured-default.test.ts | 233 ++++++++++++++ ...-structured-effort-default-at-rest.test.ts | 79 +++-- .../claude-structured-retired-model.test.ts | 130 +++----- .../claude/claude-structured-retired-model.ts | 18 -- .../claude-structured-session-acquisition.ts | 5 - ...e-structured-session-adapter-turns.test.ts | 8 +- .../claude-structured-session-options.ts | 94 +----- .../claude-structured-session-startup.test.ts | 2 + .../claude-structured-session-startup.ts | 30 +- .../claude/claude-structured-session-state.ts | 8 - .../runtime-home-catalog-account-key.test.ts | 129 ++++++++ src/main/codex/codex-app-server-session.ts | 25 +- .../codex/codex-model-catalog-probe.test.ts | 37 ++- src/main/codex/codex-model-catalog-probe.ts | 21 +- ...codex-short-lived-app-server-spawn.test.ts | 2 +- .../codex-structured-background-catalog.ts | 16 +- .../codex/codex-structured-fast-mode.test.ts | 69 ++-- ...structured-session-options-catalog.test.ts | 57 +++- .../agent-model-catalog-account-expiry.ts | 56 ++++ ...-model-catalog-discovery-freshness.test.ts | 145 +++++++++ .../agent-model-catalog-discovery.ts | 17 + .../agent-model-catalog-entry.ts | 253 +++++++++++++++ .../agent-model-catalog-failures.ts | 3 + ...gent-model-catalog-learned-default.test.ts | 168 ++++++++++ .../agent-model-catalog-listing-waiters.ts | 55 ++++ .../agent-model-catalog-persistence.ts | 76 ++++- .../agent-model-catalog-prewarm.test.ts | 303 ++++++++++++++++++ .../agent-model-catalog-probe-runner.test.ts | 167 ++++++++++ .../agent-model-catalog-probe-runner.ts | 194 +++++++++++ ...gent-model-catalog-saved-only-read.test.ts | 107 +++++++ .../agent-model-catalog-service.test.ts | 110 ++++++- .../agent-model-catalog-service.ts | 242 +++++++++++--- .../agent-model-catalog-store.test.ts | 120 +++++-- .../agent-model-catalog-store.ts | 264 ++++++++------- .../agent-model-catalog-unavailable.test.ts | 100 +++++- .../agent-project-model-config-keys.ts | 172 ++++++++++ .../agent-project-model-override.test.ts | 149 ++++++++- .../agent-project-model-override.ts | 174 +++++++++- .../structured-agent-session-adapter.ts | 15 +- .../structured-agent-session-options-read.ts | 15 +- ...t-session-provider-started-catalog.test.ts | 8 +- ...red-agent-session-provider-started.test.ts | 26 ++ ...ructured-agent-session-provider-started.ts | 3 + ...ructured-agent-session-queued-mutations.ts | 1 - ...agent-session-startup-attempt-host.test.ts | 8 +- .../opencode-model-catalog-listing.test.ts | 82 +++++ .../opencode-model-catalog-listing.ts | 88 +++++ src/main/pi/rpc-model-catalog-probe.test.ts | 64 ++++ src/main/pi/rpc-model-catalog-probe.ts | 66 ++++ src/main/pi/rpc-options.ts | 4 + src/main/pi/rpc-runtime-registration.ts | 29 +- src/main/pi/rpc-session-adapter.ts | 7 +- .../managed-provider-process.ts | 8 +- .../client-native-chat-settings.test.ts | 22 +- ...uctured-agent-session-options-read.test.ts | 5 + ...ured-agent-model-catalog-discovery.test.ts | 133 ++++++++ ...tructured-agent-model-catalog-discovery.ts | 66 ++++ ...ed-agent-model-catalog-first-frame.test.ts | 241 ++++++++++++++ .../structured-agent-model-catalog-wiring.ts | 99 +++--- .../structured-agent-runtime-registrations.ts | 21 +- ...ion-runtime-model-catalog-lifetime.test.ts | 61 ++++ .../structured-agent-session-runtime.ts | 16 +- .../structured-claude-runtime-adapter.ts | 4 +- src/main/startup/codex-launch-preparation.ts | 16 + .../startup/main-process-account-services.ts | 25 +- .../startup/main-process-runtime-service.ts | 17 +- .../src/app-shell/use-app-shell-services.ts | 2 + ...SessionOptionPickers.menu-request.test.tsx | 109 ------- .../NativeChatSessionOptionPickers.test.tsx | 38 --- .../NativeChatSessionOptionPickers.tsx | 8 +- .../native-chat/host-model-listing-waits.ts | 17 - ...red-agent-registered-agent-surface.test.ts | 4 +- .../structured-agent-session-seed-catalog.ts | 21 -- .../use-host-model-catalog-upgrade.test.tsx | 136 +++----- .../use-host-model-catalog-upgrade.ts | 79 +++-- ...model-catalog-upgrade.unavailable.test.tsx | 24 +- ...se-native-chat-provisional-launch.test.tsx | 26 +- ...e-structured-agent-session-option-state.ts | 56 +++- ...agent-session-options.first-frame.test.tsx | 271 ++++++++++++++++ ...t-session-options.learned-default.test.tsx | 191 +++++++++++ ...-session-options.preloaded-agents.test.tsx | 174 ++++++++++ ...-structured-agent-session-options.test.tsx | 65 +++- .../use-structured-agent-session-options.ts | 29 +- ...uctured-agent-session-provisional.test.tsx | 11 +- .../host-model-catalog-snapshots-sync.ts | 180 +++++++++++ .../host-model-catalog-snapshots.test.ts | 203 ++++++++++++ .../runtime/host-model-catalog-snapshots.ts | 151 +++++++++ .../agent-session-option-catalog-types.ts | 4 +- src/shared/agent-session-wire.ts | 6 + src/shared/native-chat-session-options.ts | 2 - .../rpc-contract/client-settings-params.ts | 7 +- .../structured-agent-session-params.ts | 5 +- ...d-agent-session-first-frame-picker.test.ts | 132 ++++++++ ...-session-model-catalog-application.test.ts | 66 ++-- .../structured-agent-session-option-view.ts | 53 +++ .../structured-agent-session-options.test.ts | 29 +- .../structured-agent-session-options.ts | 95 +++--- .../structured-agent-session-seed-catalog.ts | 21 ++ ...ured-agent-session-surface-capabilities.ts | 9 +- 129 files changed, 7869 insertions(+), 1294 deletions(-) create mode 100644 mobile/src/session/mobile-created-structured-sessions.ts create mode 100644 mobile/src/session/use-mobile-host-model-catalog-upgrade.ts create mode 100644 mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts create mode 100644 src/main/acp/acp-dialects/grok-model-catalog.ts create mode 100644 src/main/acp/acp-model-catalog-probe.test.ts create mode 100644 src/main/acp/acp-model-catalog-probe.ts create mode 100644 src/main/acp/acp-structured-host-configured-default.test.ts create mode 100644 src/main/acp/acp-structured-host-live-listing.test.ts create mode 100644 src/main/acp/acp-structured-options.test.ts delete mode 100644 src/main/claude/claude-structured-acquire-catalog.ts create mode 100644 src/main/claude/claude-structured-catalog-listing.ts create mode 100644 src/main/claude/claude-structured-configured-default.test.ts create mode 100644 src/main/codex-accounts/runtime-home-catalog-account-key.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts create mode 100644 src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts create mode 100644 src/main/opencode/opencode-model-catalog-listing.test.ts create mode 100644 src/main/opencode/opencode-model-catalog-listing.ts create mode 100644 src/main/pi/rpc-model-catalog-probe.test.ts create mode 100644 src/main/pi/rpc-model-catalog-probe.ts create mode 100644 src/main/runtime/structured-agent-model-catalog-discovery.test.ts create mode 100644 src/main/runtime/structured-agent-model-catalog-discovery.ts create mode 100644 src/main/runtime/structured-agent-model-catalog-first-frame.test.ts create mode 100644 src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts delete mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-seed-catalog.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.preloaded-agents.test.tsx create mode 100644 src/renderer/src/runtime/host-model-catalog-snapshots-sync.ts create mode 100644 src/renderer/src/runtime/host-model-catalog-snapshots.test.ts create mode 100644 src/renderer/src/runtime/host-model-catalog-snapshots.ts create mode 100644 src/shared/structured-agent-session-first-frame-picker.test.ts create mode 100644 src/shared/structured-agent-session-option-view.ts create mode 100644 src/shared/structured-agent-session-seed-catalog.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 6d81a92daf2..5b7ba8aa733 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -3,6 +3,8 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ ...SQLITE_DATABASE_RUNTIME_INCLUDE, + 'src/main/acp/acp-structured-host-live-listing.test.ts', + 'src/main/acp/acp-structured-host-configured-default.test.ts', 'src/main/acp/acp-structured-omp-recordings.test.ts', 'src/main/acp/acp-text-stream-identity.test.ts', 'src/main/acp/acp-text-message-ownership.test.ts', diff --git a/mobile/src/session/mobile-created-structured-sessions.ts b/mobile/src/session/mobile-created-structured-sessions.ts new file mode 100644 index 00000000000..fc1792aefa1 --- /dev/null +++ b/mobile/src/session/mobile-created-structured-sessions.ts @@ -0,0 +1,30 @@ +// Structured chats this phone created and that have not yet answered live, as the desktop latches +// its own launches: only a new chat runs the account's configured model, so only its first frame may +// name it. + +const CREATED_LIMIT = 32 + +const created = new Map() + +export function rememberMobileCreatedStructuredSession(sessionId: string, worktree: string): void { + created.delete(sessionId) + created.set(sessionId, { worktree }) + for (const oldest of created.keys()) { + if (created.size <= CREATED_LIMIT) { + break + } + created.delete(oldest) + } +} + +/** The chat's workspace selector when this phone created it, else undefined. */ +export function mobileCreatedStructuredSession( + sessionId: string +): { worktree: string } | undefined { + return created.get(sessionId) +} + +/** The chat answered live, so a reopened view reads its options rather than the listed default. */ +export function forgetMobileCreatedStructuredSession(sessionId: string): void { + created.delete(sessionId) +} diff --git a/mobile/src/session/mobile-existing-agent-launch.test.ts b/mobile/src/session/mobile-existing-agent-launch.test.ts index 0e5d289ad51..d39f4222405 100644 --- a/mobile/src/session/mobile-existing-agent-launch.test.ts +++ b/mobile/src/session/mobile-existing-agent-launch.test.ts @@ -13,6 +13,7 @@ import { supportsMobileExistingAgentLaunch, AGENT_LAUNCH_TAB_CLOSED_MESSAGE } from './mobile-existing-agent-launch' +import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' // A connected client whose only behaviour is the scripted `sendRequest`. function requestPortRpcClient(sendRequest: RpcClient['sendRequest']): RpcClient { @@ -114,6 +115,10 @@ describe('reserveMobileAgentLaunch', () => { paneKey: `${reservation.pane.tabId}:${reservation.pane.leafId}`, sessionId: reservation.sessionId }) + // The chat it reserved is one this phone created, so its picker names the listed default. + expect(mobileCreatedStructuredSession(reservation.sessionId!)).toMatchObject({ + worktree: expect.stringMatching(/^id:/) + }) }) it('asks for the new tab after the one the user is on', async () => { diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index 3b08bbc5432..a3784fe5e3b 100644 --- a/mobile/src/session/mobile-existing-agent-launch.ts +++ b/mobile/src/session/mobile-existing-agent-launch.ts @@ -28,6 +28,7 @@ import { structuredSessionOperationId, structuredSessionRandomUuid } from './structured-session-operation-id' +import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' // Why: the host waits up to 60s for a terminal agent to become ready before pasting the prompt, // so a prompted launch must outlive that wait or the phone reports a launch that is still running. @@ -122,6 +123,9 @@ export async function launchAgentInExistingWorkspace(args: { } : {}) }) + if (args.reservation?.sessionId) { + rememberMobileCreatedStructuredSession(args.reservation.sessionId, `id:${args.worktreeId}`) + } let sent try { sent = await sendReplayingAmbiguousDelivery( diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts index c5cd130f660..e9291c562e7 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch' +import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' function clientReturning( ...responses: unknown[] @@ -115,6 +116,10 @@ describe('mobile structured agent-session launch', () => { expect(params.agent).toBe('claude') expect(params.envelope.sessionId).toMatch(/^claude_[A-Za-z0-9_]{8,128}$/) expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/) + // Its picker treats it as a new chat, as the desktop does one its own view launched. + expect(mobileCreatedStructuredSession(params.envelope.sessionId)).toEqual({ + worktree: 'id:workspace-1' + }) }) it('names the refusing agent in the failure copy rather than always saying Codex', async () => { diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts index fb9af825617..c22e2558086 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -17,6 +17,7 @@ import { structuredAgentSupportProbe } from './mobile-session-launch-operations' import { structuredSessionRandomUuid } from './structured-session-operation-id' +import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' type StructuredCreateSupport = { supported?: boolean @@ -122,6 +123,7 @@ export async function createMobileStructuredAgentSession( } const params = createParamsFor(agent, worktree) + rememberMobileCreatedStructuredSession(params.envelope.sessionId, worktree) let response try { response = await structuredAgentSessionCreate.request(client, params, { diff --git a/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts new file mode 100644 index 00000000000..e4581c8f311 --- /dev/null +++ b/mobile/src/session/use-mobile-host-model-catalog-upgrade.ts @@ -0,0 +1,113 @@ +import { useEffect, type MutableRefObject } from 'react' +import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' +import type { AgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import type { NativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state' +import { + applyStructuredAgentSessionModelCatalog, + settleStructuredAgentSessionBuiltinCatalog, + type StructuredAgentSessionOptionState +} from '../../../src/shared/structured-agent-session-options' +import type { RpcClient } from '../transport/rpc-client' +import { callAgentSession } from './mobile-structured-agent-session-rpc' + +// The host answers a waiting read when its first listing lands, within its own 30s picker wait. +const LISTING_WAIT_TIMEOUT_MS = 45_000 + +/** + * The desktop's host-catalog upgrade on the phone: the picker lists the account's models from the + * host store while the session's own options read may still wait on its attach. Until the host + * answers it shows the quiet placeholder; it never waits on a listing: a first listing still + * running lands in place. An older host refuses the method, and the built-in list stands, naming + * nothing, until the options read lands. + */ +export function useMobileHostModelCatalogUpgrade(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + /** This phone created the chat, so it launches the listed default; a reopened one may not. */ + newLaunch: boolean + /** Where a new chat runs: the host names no default its config could replace. */ + worktree?: string + fence: number | null + optionCatalog: AgentSessionOptionCatalog | null + activeOptionRecordRef: MutableRefObject + updateOptionState: ( + update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState + ) => void +}): void { + const { + activeOptionRecordRef, + agent, + client, + enabled, + fence, + newLaunch, + optionCatalog, + sessionId, + updateOptionState, + worktree + } = args + useEffect(() => { + if (!client || !sessionId || !enabled || !agent || !optionCatalog) { + return + } + let stale = false + const params = { agent, sessionId, ...(newLaunch && worktree ? { worktree } : {}) } + const read = (waitForListing: boolean): Promise => + waitForListing + ? callAgentSession( + client, + 'agentSession.modelCatalog', + { ...params, waitForListing }, + LISTING_WAIT_TIMEOUT_MS + ) + : callAgentSession(client, 'agentSession.modelCatalog', params) + // A pick made meanwhile stays on the record; a listing only replaces the list it is picked from. + const apply = (catalog: AgentSessionModelCatalogResult): void => { + if (stale) { + return + } + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { newLaunch }) + : current + ) + } + // A refused or failed read is no list: the built-in one becomes usable, naming nothing. + const settleBuiltin = (): void => { + if (!stale) { + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? settleStructuredAgentSessionBuiltinCatalog(current) + : current + ) + } + } + void read(false) + .then((catalog) => { + if (catalog.origin === 'unknown' && catalog.listingInProgress === true && !stale) { + // Usable on the built-in list while the listing runs; it lands in place. + apply(catalog) + return read(true).then(apply) + } + apply(catalog) + return undefined + }) + .catch(settleBuiltin) + return () => { + stale = true + } + }, [ + activeOptionRecordRef, + agent, + client, + enabled, + fence, + newLaunch, + optionCatalog, + sessionId, + updateOptionState, + worktree + ]) +} diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts new file mode 100644 index 00000000000..0779304fb02 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -0,0 +1,102 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' +import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' +import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' + +const HOST_LIST: AgentSessionModelCatalogResult = { + origin: 'probe', + models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], + fetchedAt: 1_000 +} + +/** Catalog reads take each scripted answer in turn; option reads never answer. */ +function client(catalog: () => Promise): RpcClient { + return { + sendRequest: async (method: string) => + method === 'agentSession.modelCatalog' + ? { id: 'rpc-1', ok: true as const, result: await catalog(), _meta: { runtimeId: 'r' } } + : new Promise(() => {}), + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } +} + +function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { + const model = snapshot.find((entry) => entry.id === 'model') + return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] +} + +type Props = { rpc: RpcClient; fence: number } + +async function mount(initial: Props) { + const frames: string[][] = [] + const mutate: StructuredAgentSessionMutate = vi.fn() + function Probe(props: Props): null { + const options = useMobileStructuredAgentOptions({ + agent: 'codex', + client: props.rpc, + sessionId: 'session-1', + enabled: true, + fence: props.fence, + mutate + }) + frames.push(modelChoices(options.optionSnapshot)) + return null + } + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(Probe, initial)) + }) + return { + frames, + update: (next: Props) => + act(async () => { + renderer?.update(createElement(Probe, next)) + }), + unmount: () => act(async () => renderer?.unmount()) + } +} + +describe('the phone picker’s first frame', () => { + it('shows the quiet placeholder, never the built-in list, until the host answers', async () => { + let answer!: (value: AgentSessionModelCatalogResult) => void + const rpc = client(() => new Promise((resolve) => (answer = resolve))) + const harness = await mount({ rpc, fence: 1 }) + expect(harness.frames.every((choices) => choices.length === 0)).toBe(true) + await act(async () => answer(HOST_LIST)) + expect(harness.frames.at(-1)).toEqual(['gpt-host']) + // Only the host's list was ever offered. + expect(harness.frames.filter((choices) => choices.length > 0)).toEqual( + harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host']) + ) + await harness.unmount() + }) + + it('keeps the host list through a new fence instead of falling back to the placeholder', async () => { + let reads = 0 + const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {}))) + const harness = await mount({ rpc, fence: 1 }) + expect(harness.frames.at(-1)).toEqual(['gpt-host']) + const before = harness.frames.length + await harness.update({ rpc, fence: 2 }) + expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true) + await harness.unmount() + }) + + it('makes the built-in list usable when the host refuses the read', async () => { + const rpc = client(() => Promise.reject(new Error('method_not_found'))) + const harness = await mount({ rpc, fence: 1 }) + expect(harness.frames.at(-1)?.length).toBeGreaterThan(0) + await harness.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 5658cc01839..15698925bba 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { describe, expect, it, vi } from 'vitest' import type { + AgentSessionModelCatalogResult, AgentSessionOptionResult, AgentSessionOptionsResult } from '../../../src/shared/agent-session-wire' @@ -12,6 +13,7 @@ import type { StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' +import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' const OPTIONS: AgentSessionOptionsResult = { models: [ @@ -69,11 +71,25 @@ type SentRequest = { method: string; params: unknown } /** `reads` yields what each successive `agentSession.options` call resolves to, so a test can * make the post-write refresh disagree with the first read. */ -function optionsClient(reads: () => Promise) { +function optionsClient( + reads: () => Promise, + catalog?: (params: unknown) => Promise +) { const sent: SentRequest[] = [] const client: RpcClient = { sendRequest: async (method: string, params?: unknown) => { sent.push({ method, params }) + if (method === 'agentSession.modelCatalog') { + // Without a scripted catalog, the host predates the method. + return catalog + ? rpcSuccess(await catalog(params)) + : { + id: 'rpc-1', + ok: false as const, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-1' } + } + } return rpcSuccess(method === 'agentSession.options' ? await reads() : {}) }, subscribe: () => () => {}, @@ -396,3 +412,211 @@ describe('useMobileStructuredAgentOptions pending guard', () => { await harness.unmount() }) }) + +describe('useMobileStructuredAgentOptions for every agent', () => { + it('reads and picks for an agent with no built-in list, as the desktop does', async () => { + const client = optionsClient(queuedReads(OPTIONS)) + const { calls, mutate } = recordingMutate(async () => + accepted({ key: 'model', value: 'gpt-fast', options: { model: 'gpt-fast' } }, true) + ) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + + expect(client.optionReads()).toBe(1) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') + await act(async () => { + await harness.current().setStructuredOption('model', 'gpt-fast') + }) + await settle() + expect(calls).toEqual([ + { method: 'agentSession.setOption', fields: { key: 'model', value: 'gpt-fast' } } + ]) + expect(client.methods('settings.mutateNativeChatSessionOptions')[0]?.params).toMatchObject({ + agent: 'grok' + }) + await harness.unmount() + }) +}) + +describe('useMobileStructuredAgentOptions host catalog', () => { + const HOST_CATALOG: AgentSessionModelCatalogResult = { + origin: 'probe', + models: [ + { + id: 'grok-4', + label: 'Grok 4', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + fetchedAt: 1, + listingNamesConfiguredModel: true + } + + it('lists the host’s models while the session’s own options read is still waiting', async () => { + const pending = deferred() + const client = optionsClient( + () => pending.promise, + async () => HOST_CATALOG + ) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + + expect(client.methods('agentSession.modelCatalog')[0]?.params).toEqual({ + agent: 'grok', + sessionId: 'session-1' + }) + const model = descriptorFor(harness.current().optionSnapshot, 'model') + expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ + 'grok-4' + ]) + // An existing chat may run a model picked in it, so the listing's default is not named. + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBeUndefined() + + pending.resolve({ ...OPTIONS, current: { model: 'gpt-live', confirmed: ['model'] } }) + await settle() + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') + await harness.unmount() + }) + + it.each(['grok', 'codex'])( + 'names the configured model and its effort for a %s chat this phone created', + async (agent) => { + const sessionId = `${agent}-created-here` + rememberMobileCreatedStructuredSession(sessionId, 'id:wt-1') + const client = optionsClient( + () => deferred().promise, + async () => HOST_CATALOG + ) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ + ...BASE, + agent, + sessionId, + client: client.client, + mutate + }) + + // As the desktop asks for a chat it launched: the host checks that workspace's config. + expect(client.methods('agentSession.modelCatalog')[0]?.params).toEqual({ + agent, + sessionId, + worktree: 'id:wt-1' + }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('grok-4') + expect(currentValueOf(harness.current().optionSnapshot, 'effort')).toBe('high') + await harness.unmount() + } + ) + + it('does not name the listed default when a chat this phone created is reopened', async () => { + const sessionId = 'grok-reopened' + rememberMobileCreatedStructuredSession(sessionId, 'id:wt-1') + const live = { ...OPTIONS, current: { model: 'gpt-fast', confirmed: ['model'] } } + const first = optionsClient( + () => Promise.resolve(live), + async () => HOST_CATALOG + ) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const opened = await mountOptions({ + ...BASE, + agent: 'grok', + sessionId, + client: first.client, + mutate + }) + await settle() + await opened.unmount() + + const pending = deferred() + const second = optionsClient( + () => pending.promise, + async () => HOST_CATALOG + ) + const reopened = await mountOptions({ + ...BASE, + agent: 'grok', + sessionId, + client: second.client, + mutate + }) + await settle() + + expect(second.methods('agentSession.modelCatalog')[0]?.params).toEqual({ + agent: 'grok', + sessionId + }) + expect(currentValueOf(reopened.current().optionSnapshot, 'model')).toBeUndefined() + pending.resolve(live) + await settle() + expect(currentValueOf(reopened.current().optionSnapshot, 'model')).toBe('gpt-fast') + await reopened.unmount() + }) + + it('waits once for the host’s first listing of the account', async () => { + const catalog = vi.fn(async (params: unknown): Promise => + typeof params === 'object' && params !== null && 'waitForListing' in params + ? HOST_CATALOG + : { origin: 'unknown', listingInProgress: true } + ) + const client = optionsClient(() => deferred().promise, catalog) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + await settle() + + expect(catalog).toHaveBeenCalledTimes(2) + const model = descriptorFor(harness.current().optionSnapshot, 'model') + expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ + 'grok-4' + ]) + await harness.unmount() + }) + + it('keeps a cold Codex picker usable on the built-in list and lands the listing in place', async () => { + const listed = deferred() + const catalog = vi.fn(async (params: unknown): Promise => + typeof params === 'object' && params !== null && 'waitForListing' in params + ? listed.promise + : { origin: 'unknown', listingInProgress: true } + ) + const client = optionsClient(() => deferred().promise, catalog) + const { calls, mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, client: client.client, mutate }) + await settle() + + const seeded = descriptorFor(harness.current().optionSnapshot, 'model') + expect(seeded?.settable).toBe(true) + const seedChoices = seeded?.kind.type === 'select' ? seeded.kind.choices : [] + expect(seedChoices.length).toBeGreaterThan(0) + await act(async () => { + await harness.current().setStructuredOption('model', seedChoices[0]!.value) + }) + expect(calls).toHaveLength(1) + + // A pick made meanwhile does not drop the listing that lands afterwards, and stays picked. + listed.resolve(HOST_CATALOG) + await settle() + const model = descriptorFor(harness.current().optionSnapshot, 'model') + expect(model?.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ + 'grok-4', + seedChoices[0]!.value + ]) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe(seedChoices[0]!.value) + await harness.unmount() + }) + + it('keeps the provider-default pill when an older host has no catalog method', async () => { + const client = optionsClient(() => deferred().promise) + const { mutate } = recordingMutate(async () => ({ status: 'unknown' })) + const harness = await mountOptions({ ...BASE, agent: 'grok', client: client.client, mutate }) + + expect(descriptorFor(harness.current().optionSnapshot, 'model')).toMatchObject({ + settable: false, + valueSource: 'unknown' + }) + await harness.unmount() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 54d14fd2a8a..c186ade0cdb 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' -import { getAgentSessionOptionCatalog } from '../../../src/shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from '../../../src/shared/structured-agent-session-seed-catalog' import type { AgentSessionOptionResult, AgentSessionOptionsResult @@ -26,6 +26,11 @@ import { type StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence' +import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade' +import { + forgetMobileCreatedStructuredSession, + mobileCreatedStructuredSession +} from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' type StructuredOptionsController = { @@ -47,8 +52,13 @@ export function useMobileStructuredAgentOptions(args: { mutate: StructuredAgentSessionMutate }): StructuredOptionsController { const { agent, client, enabled, fence, mutate, sessionId } = args + // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. + const optionCatalog = useMemo( + () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), + [agent] + ) const [optionState, setOptionState] = useState(() => - createStructuredAgentSessionOptionState(agent ?? 'codex') + createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) ) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) @@ -70,19 +80,43 @@ export function useMobileStructuredAgentOptions(args: { sessionId: string commands: readonly AgentSessionConversationCommand[] } | null>(null) - const optionCatalog = useMemo( - () => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null), - [agent] - ) + const optionIdentityRef = useRef(`${agent}:${sessionId}`) useEffect(() => { - const next = createStructuredAgentSessionOptionState(agent ?? 'codex') + const identity = `${agent}:${sessionId}` + const sameSession = optionIdentityRef.current === identity + optionIdentityRef.current = identity + const previous = optionStateRef.current + const seeded = createStructuredAgentSessionOptionState(agent ?? 'codex', optionCatalog) + // A host answer is the account's, not the fence's: keep it rather than fall back to the placeholder. + const next = + sameSession && (previous.catalogSource === 'host' || previous.catalogSource === 'builtin') + ? { ...seeded, catalog: previous.catalog, catalogSource: previous.catalogSource } + : seeded optionMutationGeneration.current += 1 pendingOptionRef.current = null optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) - }, [agent, enabled, fence, sessionId]) + }, [agent, enabled, fence, optionCatalog, sessionId]) + + // A chat this phone created runs the listed default, as a desktop chat its own view launched does. + const createdHere = useMemo( + () => (sessionId ? mobileCreatedStructuredSession(sessionId) : undefined), + [sessionId] + ) + useMobileHostModelCatalogUpgrade({ + agent, + client, + sessionId, + enabled, + fence, + newLaunch: createdHere !== undefined, + ...(createdHere ? { worktree: createdHere.worktree } : {}), + optionCatalog, + activeOptionRecordRef, + updateOptionState + }) useEffect(() => { if (!client || !sessionId || !enabled || !optionCatalog) { @@ -92,6 +126,8 @@ export function useMobileStructuredAgentOptions(args: { const readGeneration = optionMutationGeneration.current void callAgentSession(client, 'agentSession.options', { sessionId }) .then((result) => { + // This view keeps its latch; a later one may run a model picked here. + forgetMobileCreatedStructuredSession(sessionId) if (!stale && optionMutationGeneration.current === readGeneration) { setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) updateOptionState((current) => @@ -152,7 +188,7 @@ export function useMobileStructuredAgentOptions(args: { // Only an accepted pick: an `unknown` outcome commits optimistically to the // visible record, and remembering one the provider refused would seed a // launch the user never chose. - if (agent === 'claude' || agent === 'codex') { + if (agent) { void persistMobileStructuredOptionPicks({ client, agent, diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index db9657a4dd8..098c73e2c17 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -7,6 +7,7 @@ import type { AgentJournalResolution } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' @@ -22,6 +23,11 @@ const asyncStorage = vi.hoisted(() => ({ vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +function listedModelIds(snapshot: readonly SessionOptionDescriptor[]): string[] { + const model = snapshot.find((descriptor) => descriptor.id === 'model') + return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] +} + function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } } @@ -412,7 +418,8 @@ describe('useMobileStructuredAgentSession', () => { await vi.waitFor(() => expect(hook.permission).not.toBeNull()) await vi.waitFor(() => expect(hook.question).not.toBeNull()) - await vi.waitFor(() => expect(hook.optionSnapshot.length).toBeGreaterThan(0)) + // The seed paints first; the session's own list is what a pick is checked against. + await vi.waitFor(() => expect(listedModelIds(hook.optionSnapshot)).toContain('gpt-slow')) expect(hook.permission).toMatchObject({ title: 'Allow Bash?', @@ -706,7 +713,7 @@ describe('useMobileStructuredAgentSession', () => { }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent(3))) - await vi.waitFor(() => expect(hook!.optionSnapshot.length).toBeGreaterThan(0)) + await vi.waitFor(() => expect(listedModelIds(hook!.optionSnapshot)).toContain('gpt-slow')) sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.setOption') { throw markRpcDeliveryUnknown(new Error('Connection closed')) diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index 195a8beadf8..47fba1f0f86 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -7,7 +7,8 @@ import type { } from '../../../shared/native-chat-types' import type { ProviderTimelineRequestBody } from '../../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpAgentError } from '../acp-errors' -import type { ToolCallUpdate } from '../generated/acp-protocol.generated' +import type { AgentSessionOptionChoice } from '../../../shared/agent-session-wire' +import type { ModelInfo, ToolCallUpdate } from '../generated/acp-protocol.generated' export type AcpRequestPresentation = { body: ProviderTimelineRequestBody @@ -80,6 +81,8 @@ export type AcpDialect = { subagentSessionEnd?(method: string, params: unknown): 'completed' | 'stopped' | undefined notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined + /** A model's own effort menu and default, as the agent advertises them per model. */ + modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string } request?(method: string, params: unknown): AcpRequestPresentation | undefined /** Requests answered at once instead of shown to the person. */ settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index 150ff066266..569bca94ebf 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -3,6 +3,7 @@ import { acpNotificationEnvelopeSchema } from '../acp-context-usage' import type { AcpDialect, AcpDialectNotification } from './acp-dialect' import { grokRequest, grokSettleRequest } from './grok-requests' import { grokBackgroundTaskNotification, grokToolBackgroundTasks } from './grok-background-tasks' +import { grokModelEfforts } from './grok-model-catalog' import { grokSubagentNotification, grokToolSubagents } from './grok-subagents' import { grokSubagentStop } from './grok-subagent-stop' @@ -205,6 +206,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { subagentSessionEnd, notification, contextWindow, + modelEfforts: grokModelEfforts, promptErrorDetail: (error) => promptErrorDataSchema.safeParse(error.data).data?.message, failedTurnText: (stopReason) => stopReason === 'rate_limit' diff --git a/src/main/acp/acp-dialects/grok-model-catalog.ts b/src/main/acp/acp-dialects/grok-model-catalog.ts new file mode 100644 index 00000000000..7f1fbce3a14 --- /dev/null +++ b/src/main/acp/acp-dialects/grok-model-catalog.ts @@ -0,0 +1,125 @@ +// Grok's own model facts: the per-model effort menu it writes in each model's `_meta`, and the +// models it lists during `initialize` (or serves from `x.ai/models/list`) with no session. + +import { z } from 'zod' +import type { + AgentSessionModelOption, + AgentSessionOptionChoice +} from '../../../shared/agent-session-wire' +import { + SessionModelStateSchema, + type InitializeResponse, + type ModelInfo, + type SessionModelState +} from '../generated/acp-protocol.generated' + +const EFFORT_LABELS: Readonly> = { + none: 'None', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'X-High', + max: 'Max' +} +// The menu Grok offers a model that supports effort but names none of its own. +const BUILT_IN_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const + +const effortEntrySchema = z.union([ + z.string().min(1), + z.looseObject({ + value: z.string().min(1), + id: z.string().min(1).optional(), + label: z.string().min(1).optional(), + description: z.string().optional(), + default: z.boolean().optional() + }) +]) +const modelMetaSchema = z.looseObject({ + supportsReasoningEffort: z.boolean().optional(), + reasoningEfforts: z.array(z.unknown()).optional() +}) +const modelsListReplySchema = z.looseObject({ + result: SessionModelStateSchema.nullish(), + error: z.unknown().optional() +}) + +function capitalized(id: string): string { + return id.charAt(0).toUpperCase() + id.slice(1) +} + +/** One model's effort menu as Grok advertises it. Choice values are Grok's option ids — what its + * effort config option takes — not the canonical sampling value behind them. */ +export function grokModelEfforts(model: Pick): { + efforts: AgentSessionOptionChoice[] + defaultEffort?: string +} { + const meta = modelMetaSchema.safeParse(model._meta ?? {}) + if (!meta.success || meta.data.supportsReasoningEffort !== true) { + return { efforts: [] } + } + const efforts: AgentSessionOptionChoice[] = [] + let defaultEffort: string | undefined + for (const raw of meta.data.reasoningEfforts ?? []) { + const entry = effortEntrySchema.safeParse(raw) + if (!entry.success) { + continue + } + if (typeof entry.data === 'string') { + efforts.push({ value: entry.data, label: EFFORT_LABELS[entry.data] ?? entry.data }) + continue + } + const id = entry.data.id ?? entry.data.value + const label = + entry.data.label ?? + (entry.data.id ? capitalized(id) : (EFFORT_LABELS[entry.data.value] ?? entry.data.value)) + efforts.push({ + value: id, + label, + ...(entry.data.description ? { description: entry.data.description } : {}) + }) + if (entry.data.default === true) { + defaultEffort ??= id + } + } + if (efforts.length === 0) { + return { + efforts: BUILT_IN_EFFORTS.map((value) => ({ value, label: EFFORT_LABELS[value] ?? value })) + } + } + return { efforts, ...(defaultEffort ? { defaultEffort } : {}) } +} + +/** Names no default: a session's own model can differ from the `currentModelId` computed without + * one (Grok resolves its default from remote config too), so a no-pick chat reports it instead. */ +export function grokModelCatalogFromState(state: SessionModelState): AgentSessionModelOption[] { + return state.availableModels.map((model) => ({ + id: model.modelId, + label: model.name, + ...(model.description ? { description: model.description } : {}), + isDefault: false, + ...grokModelEfforts(model) + })) +} + +/** The models `initialize` computed, else `x.ai/models/list`; neither creates a session. */ +export async function readGrokModelCatalog( + initialized: InitializeResponse, + connection: { requestSessionFreeExtension(method: string, params: unknown): Promise } +): Promise { + const fromInitialize = SessionModelStateSchema.safeParse(initialized._meta?.modelState) + if (fromInitialize.success && fromInitialize.data.availableModels.length > 0) { + return grokModelCatalogFromState(fromInitialize.data) + } + const reply = modelsListReplySchema.safeParse( + await connection.requestSessionFreeExtension('x.ai/models/list', {}) + ) + if ( + !reply.success || + (reply.data.error !== undefined && reply.data.error !== null) || + !reply.data.result + ) { + throw new Error('grok did not list its models') + } + return grokModelCatalogFromState(reply.data.result) +} diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 31d03a5e48e..2cceeb216d6 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -18,6 +18,13 @@ import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-store import type { AcpStoredUserMessagesReader } from './acp-recovery-history' import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import type { InitializeResponse } from './generated/acp-protocol.generated' +import { readGrokModelCatalog } from './acp-dialects/grok-model-catalog' +import { + OPENCODE_MODEL_LISTING_ARGS, + parseOpenCodeModelListing +} from '../opencode/opencode-model-catalog-listing' import { agentSessionSignInFor } from '../../shared/agent-session-sign-in' import { loadGrokVisualsSkill, @@ -26,6 +33,28 @@ import { type AcpVisualsSkillLoader } from './acp-visuals-skill' +/** How an agent lists its models without a session: from its `initialize` answer (plus extension + * requests), from a listing command, or not at all. Never `authenticate` or `session/new`. */ +export type AcpModelDiscovery = + | { + kind: 'initialize' + read( + initialized: InitializeResponse, + connection: { + requestSessionFreeExtension(method: string, params: unknown): Promise + } + ): Promise + /** The listing marks the model the account is configured to run as its default. */ + listingNamesConfiguredModel: boolean + } + | { + kind: 'command' + args: readonly string[] + parse(stdout: string): AgentSessionModelOption[] + listingNamesConfiguredModel: boolean + } + | { kind: 'unavailable'; reason: string } + export type AcpLaunchSpec = { /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ agent: TuiAgent @@ -58,6 +87,7 @@ export type AcpLaunchSpec = { imagePrompts?: true /** The agent's own store of a session's user messages, read for restart recovery only. */ readStoredUserMessages?: AcpStoredUserMessagesReader + modelDiscovery: AcpModelDiscovery /** How a launch loads the inline-visuals skill; absent, the agent's chats have no visuals. */ visualsSkill?: AcpVisualsSkillLoader } @@ -84,6 +114,13 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { : undefined, account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), + // Grok lists its models in `initialize`, before and without any session. Its `currentModelId` + // there can differ from what a session runs, so a chat started with no pick names the default. + modelDiscovery: { + kind: 'initialize', + read: readGrokModelCatalog, + listingNamesConfiguredModel: false + }, visualsSkill: loadGrokVisualsSkill } @@ -113,6 +150,14 @@ const OPENCODE_LAUNCH_SPEC: AcpLaunchSpec = { OPENCODE_ACP_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)), imagePrompts: true, readStoredUserMessages: openCodeStoredUserMessagesReader(), + // Its `initialize` names no models and `session/new` stores a session; the listing does neither. + modelDiscovery: { + kind: 'command', + args: OPENCODE_MODEL_LISTING_ARGS, + parse: parseOpenCodeModelListing, + // The listing marks no default; a chat started with no pick names it. + listingNamesConfiguredModel: false + }, visualsSkill: loadOpenCodeVisualsSkill } @@ -133,6 +178,12 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { installDirectories: () => [], // Stable releases from 17.0.5, the release verified to serve `omp acp`. supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5'), + // `omp models --json` lists every model without the `enabledModels` filter a chat applies, so it + // could offer models a chat refuses; its chats' own listings fill the catalog instead. + modelDiscovery: { + kind: 'unavailable', + reason: 'omp has no session-free listing that matches what its chats offer' + }, visualsSkill: loadOmpVisualsSkill } diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts new file mode 100644 index 00000000000..a166a143a33 --- /dev/null +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' +import { + createAcpModelCatalogProbe, + type AcpModelCatalogProbeDeps +} from './acp-model-catalog-probe' +import { AcpSessionRuntime } from './acp-session-runtime' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' + +const GROK = acpLaunchSpecFor('grok')! +// The user's own OpenCode environment, read without the app's managed-account service. +const OPENCODE: AcpLaunchSpec = { + ...acpLaunchSpecFor('opencode')!, + account: openCodeAcpAccountBinding(() => ({ + list: () => ({ activeAccountId: null, accounts: [] }), + restoreOriginalEnvironment: () => {}, + environmentForAccount: () => ({}) + })) +} +const GROK_HOME = { variable: 'GROK_HOME', path: '/homes/grok-a' } + +const MODEL_STATE = { + currentModelId: 'grok-build', + availableModels: [ + { + modelId: 'grok-build', + name: 'Grok Build', + _meta: { + supportsReasoningEffort: true, + // The session's own pick, which must not become a catalog default. + reasoningEffort: 'low', + reasoningEfforts: [ + { value: 'low', id: 'fast', label: 'Fast' }, + { value: 'high', id: 'deep', default: true } + ] + } + }, + { modelId: 'grok-mini', name: 'Grok Mini', _meta: { supportsReasoningEffort: true } }, + { modelId: 'grok-chat', name: 'Grok Chat', _meta: {} } + ] +} + +const agents: AcpScriptedAgent[] = [] +afterEach(() => { + for (const agent of agents.splice(0)) { + agent.close() + } +}) + +function scriptedGrok(initializeMeta: Record): { + agent: AcpScriptedAgent + launches: ProviderProcessLaunch[] + closed: () => number + deps: AcpModelCatalogProbeDeps +} { + const agent = new AcpScriptedAgent() + agents.push(agent) + agent.on('initialize', (frame) => + agent.reply(frame, { protocolVersion: 1, agentCapabilities: {}, _meta: initializeMeta }) + ) + agent.on('x.ai/models/list', (frame) => agent.reply(frame, { result: MODEL_STATE })) + const launches: ProviderProcessLaunch[] = [] + let closes = 0 + return { + agent, + launches, + closed: () => closes, + deps: { + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveLaunchEnv: () => ({ XAI_API_KEY: 'from-settings' }), + resolveCommand: () => '/opt/grok/bin/grok', + homePath: '/home/user', + connect: (launch) => { + launches.push(launch) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin) + return { + initialize: () => runtime.initialize(), + requestSessionFreeExtension: (method, params) => + runtime.requestSessionFreeExtension(method, params), + close: async () => { + closes++ + runtime.close() + } + } + } + } + } +} + +describe('ACP model catalog probes', () => { + it('lists Grok from initialize alone, under its pinned account, and closes the child', async () => { + const { agent, deps, launches, closed } = scriptedGrok({ modelState: MODEL_STATE }) + const success = await createAcpModelCatalogProbe(GROK, deps)(GROK_HOME) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) + expect(agent.frames[0]!.params).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } + }) + expect(launches).toEqual([ + expect.objectContaining({ + command: '/opt/grok/bin/grok', + args: ['agent', 'stdio'], + env: expect.objectContaining({ GROK_HOME: '/homes/grok-a', XAI_API_KEY: 'from-settings' }) + }) + ]) + expect(closed()).toBe(1) + expect(success.models).toEqual([ + { + id: 'grok-build', + label: 'Grok Build', + // `initialize`'s currentModelId is not what a session runs, so it names no default. + isDefault: false, + // Grok's option ids, which its effort config option takes, and its own default only. + efforts: [ + { value: 'fast', label: 'Fast' }, + { value: 'deep', label: 'Deep' } + ], + defaultEffort: 'deep' + }, + { + id: 'grok-mini', + label: 'Grok Mini', + isDefault: false, + efforts: ['minimal', 'low', 'medium', 'high', 'xhigh'].map((value) => ({ + value, + label: expect.any(String) + })) + }, + { id: 'grok-chat', label: 'Grok Chat', isDefault: false, efforts: [] } + ]) + }) + + it('falls back to x.ai/models/list and never authenticates or opens a session', async () => { + const { agent, deps } = scriptedGrok({}) + const success = await createAcpModelCatalogProbe(GROK, deps)(GROK_HOME) + expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize', 'x.ai/models/list']) + expect(success.models.map((model) => model.id)).toEqual([ + 'grok-build', + 'grok-mini', + 'grok-chat' + ]) + }) + + it('fails, closing the child, when Grok lists nothing', async () => { + const { agent, deps, closed } = scriptedGrok({}) + agent.on('x.ai/models/list', (frame) => agent.reply(frame, { error: 'not signed in' })) + await expect(createAcpModelCatalogProbe(GROK, deps)(GROK_HOME)).rejects.toThrow(/did not list/) + expect(closed()).toBe(1) + expect(agent.frames.some((frame) => frame.method === 'authenticate')).toBe(false) + }) + + it('lists OpenCode with `models --verbose` under its scrubbed launch env, with no ACP call', async () => { + const runListing = vi.fn(async () => 'opencode/big-pickle\n{\n "name": "Big Pickle"\n}\n') + const connect = vi.fn() + const probe = createAcpModelCatalogProbe(OPENCODE, { + resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), + resolveCommand: () => '/opt/opencode/bin/opencode', + probeVersion: async () => true, + homePath: '/home/user', + runListing, + connect + }) + const success = await probe({ kind: 'opencode', locator: { kind: 'unmanaged' } }) + expect(connect).not.toHaveBeenCalled() + expect(runListing).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/opt/opencode/bin/opencode', + args: ['models', '--verbose'], + env: expect.objectContaining({ OPENCODE_CLIENT: 'acp' }) + }), + expect.objectContaining({ site: 'opencode-model-catalog-probe' }) + ) + expect(success.models).toEqual([ + { id: 'opencode/big-pickle', label: 'opencode/Big Pickle', isDefault: false, efforts: [] } + ]) + }) + + it('lists nothing for a release that runs no structured chat', async () => { + const runListing = vi.fn() + const probe = createAcpModelCatalogProbe(OPENCODE, { + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/opencode/bin/opencode', + probeVersion: async () => false, + runListing + }) + await expect(probe({ kind: 'opencode', locator: { kind: 'unmanaged' } })).rejects.toThrow( + /does not run structured chats/ + ) + expect(runListing).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts new file mode 100644 index 00000000000..24875317882 --- /dev/null +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -0,0 +1,88 @@ +import { homedir } from 'node:os' +import { probeAgentCliVersion } from '../agent-cli-version-probe' +import type { + AgentModelCatalogProbe, + AgentModelCatalogSuccess +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + runAgentModelCatalogListing, + runAgentModelCatalogSession +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { createAcpAgentConnection } from './acp-agent-connection' +import type { AcpLaunchSpec } from './acp-launch-specs' +import { + acpLaunchVersionSupported, + resolveAcpLaunchInvocation, + type AcpLaunchInvocationDeps +} from './acp-structured-launch-resolution' +import type { InitializeResponse } from './generated/acp-protocol.generated' + +/** The part of an ACP connection a session-free listing may use: no session method is reachable. */ +export type AcpModelCatalogConnection = { + initialize(): Promise + requestSessionFreeExtension(method: string, params: unknown): Promise + close(): Promise +} + +export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { + /** Test seams; production spawns through the shared supervised runner and ACP connection. */ + probeVersion?: typeof probeAgentCliVersion + connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection + runListing?: typeof runAgentModelCatalogListing +} + +/** + * Lists an ACP agent's models without a session, under the binary, account and scrubbed + * environment its chats launch with. A release that runs no structured chat lists nothing. + */ +export function createAcpModelCatalogProbe( + spec: AcpLaunchSpec, + deps: AcpModelCatalogProbeDeps +): AgentModelCatalogProbe { + return async (accountHome, options): Promise => { + const discovery = spec.modelDiscovery + // Registered as unavailable instead (`acpModelCatalogDiscovery`); never built for one. + if (discovery.kind === 'unavailable') { + throw new Error(discovery.reason) + } + const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, deps) + // A listing reads no workspace: the catalog is the account's, whichever chat asks. + const cwd = deps.homePath ?? homedir() + if ( + !(await acpLaunchVersionSupported( + spec, + { command, cwd, env }, + deps.probeVersion ?? probeAgentCliVersion + )) + ) { + throw new Error(`${spec.agent} at ${command} does not run structured chats`) + } + let models: AgentSessionModelOption[] + if (discovery.kind === 'command') { + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { command, args: [...discovery.args], cwd, env, envToDelete }, + { site: `${spec.agent}-model-catalog-probe`, signal: options?.signal } + ) + models = discovery.parse(stdout) + } else { + const launch = { + command, + args: spec.args({ fullAccess: false, pluginDir: null }), + cwd, + env, + envToDelete + } + models = await runAgentModelCatalogSession( + () => (deps.connect ?? createAcpAgentConnection)(launch), + async (connection) => discovery.read(await connection.initialize(), connection), + { label: spec.agent, signal: options?.signal } + ) + } + if (models.length === 0) { + throw new Error(`${spec.agent} listed no models`) + } + return { models, fastModeTierByModel: new Map(), origin: 'probe' } + } +} diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 5d7db97fd0b..5f362d0fe7d 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -132,6 +132,16 @@ export class AcpSessionRuntime { return this.call('authenticate', { methodId }, AuthenticateResponseSchema) } + /** A vendor extension method, answered as the agent sent it. Core methods have typed calls here, + * so a session-free caller can never create or touch a session through this. */ + async requestSessionFreeExtension(method: string, params: unknown): Promise { + if (!method.includes('/') || method.startsWith('session/')) { + throw new Error(`Not an ACP extension method: ${method}`) + } + await this.initialize() + return this.peer.request(method, params, { timeoutMs: null }) + } + start(options: AcpSessionStartOptions): Promise { if (this.started) { return Promise.resolve(this.started) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 2fef70bef87..3f8e9555e9f 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -94,7 +94,7 @@ export async function acquireAcpStructuredSession(input: { lane: null, reattaching: false } - const options = new AcpStructuredOptions() + const options = new AcpStructuredOptions(launch.spec.dialect) // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself // has nobody waiting on it. What a settled request carries still shows in that turn. const prompts = new AcpStructuredPrompts( @@ -262,7 +262,7 @@ export async function acquireAcpStructuredSession(input: { liveLane = makeLane(started.sessionId) } subagentStopSupported = await probeAcpChildStop(connection, spec.dialect) - options.adoptSession(started.response) + options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) const process = await identity.read(pid) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts new file mode 100644 index 00000000000..eeb5ccc4664 --- /dev/null +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -0,0 +1,153 @@ +// Grok's session-free listing can name a model (`initialize`'s currentModelId) other than the one a +// session runs. The user's case: the listing said Grok 4.7, every chat ran Grok 4.6. Through the +// real Grok probe, adapter and host, a new chat's first frame names only what a chat ran. + +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel +} from '../native-chat/agent-model-catalog/agent-project-model-override' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createAcpModelCatalogProbe } from './acp-model-catalog-probe' +import { AcpScriptedAgent } from './acp-scripted-agent.test-support' +import { AcpSessionRuntime } from './acp-session-runtime' +import { GROK, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' +import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { attachParams, launch, openHostRig } from './acp-structured-host.test-support' + +const GROK_HOME = { variable: 'GROK_HOME', path: '/grok' } +const EFFORT_META = { + supportsReasoningEffort: true, + reasoningEfforts: [ + { id: 'high', value: 'high', default: true }, + { id: 'low', value: 'low' } + ] +} + +const agents: AcpScriptedAgent[] = [] +afterEach(async () => { + for (const agent of agents.splice(0)) { + agent.close() + } + await closeProviderTimelineRigs() +}) + +/** Grok's `initialize` with no session: it computes 4.7 as its current model. */ +function grokProbe() { + return createAcpModelCatalogProbe(GROK, { + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/grok/bin/grok', + homePath: '/home/user', + connect: () => { + const agent = new AcpScriptedAgent() + agents.push(agent) + agent.on('initialize', (frame) => + agent.reply(frame, { + protocolVersion: 1, + agentCapabilities: {}, + _meta: { + modelState: { + currentModelId: 'grok-4.7', + availableModels: [ + { modelId: 'grok-4.7', name: 'Grok 4.7', _meta: EFFORT_META }, + { modelId: 'grok-4.6', name: 'Grok 4.6', _meta: EFFORT_META } + ] + } + } + }) + ) + const runtime = new AcpSessionRuntime(agent.stdout, agent.stdin) + return { + initialize: () => runtime.initialize(), + requestSessionFreeExtension: (method, params) => + runtime.requestSessionFreeExtension(method, params), + close: async () => runtime.close() + } + } + }) +} + +/** What a new Grok chat's composer paints first from a host answer. */ +function firstFrame(answer: AgentSessionModelCatalogResult) { + const seed = structuredAgentSessionSeedCatalog('grok') + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('grok', seed), + seed, + answer, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const select = (id: string) => { + const entry = snapshot.find((descriptor) => descriptor.id === id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null + } + return { model: select('model'), effort: select('effort') } +} + +/** A Grok chat opened with no model pick, whose own session runs `runs`. */ +async function openGrokHost(runs: string) { + const records: { store: AgentSessionRecordStore | null } = { store: null } + const discovery = GROK.modelDiscovery + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: (sessionId) => records.store?.getRecord(sessionId) ?? undefined, + drivesRecord: () => true, + resolveAccountHome: async () => GROK_HOME, + recordWorkspacePath: async () => '/workspace/project', + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel, + probes: { grok: grokProbe() }, + // As the launch spec says, not by hand. + listingNamesConfiguredModel: new Set( + discovery.kind !== 'unavailable' && discovery.listingNamesConfiguredModel ? ['grok'] : [] + ) + }) + const opened = GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: runs } : option + ) + const rig = await openHostRig({ + modelCatalog: catalog, + script: (agent) => + agent.on('session/new', (frame) => + agent.reply(frame, { sessionId: 'acp-session-1', configOptions: opened }) + ), + deps: { resolveLaunch: launch(() => false) } + }) + records.store = rig.store + expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + return { ...rig, catalog } +} + +describe('Grok’s default comes from what a chat with no pick runs', () => { + it('names no model before any chat ran, though the listing computed one', async () => { + const { catalog } = await openGrokHost('grok-4.6') + const answer = await catalog.read({ agent: 'grok', waitForListing: true }) + expect(answer).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) + expect(firstFrame(answer)).toEqual({ model: null, effort: null }) + }) + + it('names 4.6, the model a chat with no pick ran, never the listing’s 4.7', async () => { + const { catalog, host } = await openGrokHost('grok-4.6') + await catalog.read({ agent: 'grok', waitForListing: true }) + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + + // The next new chat, in any workspace: Grok reads no project config for its model. + const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) + expect(answer).toMatchObject({ + listingNamesConfiguredModel: true, + defaultHoldsInEveryWorkspace: true + }) + expect(firstFrame(answer)).toEqual({ model: 'grok-4.6', effort: 'high' }) + }) +}) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts new file mode 100644 index 00000000000..9e2512981d3 --- /dev/null +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -0,0 +1,34 @@ +// A running Grok chat's own model list, read through the real host, reaches the account's saved +// catalog: the next chat opens warm even when a session-free listing failed. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { openAttachedHostRig } from './acp-structured-host.test-support' + +afterEach(async () => { + await closeProviderTimelineRigs() +}) + +describe('a live Grok listing', () => { + it('is saved through the host catalog when the chat reads its options', async () => { + const modelCatalog: AgentModelCatalogService = { + read: vi.fn(async () => ({ origin: 'unknown' as const })), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted: vi.fn() + } + const { host } = await openAttachedHostRig({}, undefined, modelCatalog) + + await host.readOptions(SESSION) + + expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) + const [sessionId, listing] = vi.mocked(modelCatalog.recordLiveListing).mock.calls[0]! + expect(sessionId).toBe(SESSION) + expect(listing.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) + // The session's picks are no one's default. + expect(listing.models.every((model) => !model.isDefault)).toBe(true) + }) +}) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 6dbbb75c171..e9ebec7d885 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -38,6 +38,7 @@ import { } from './acp-structured-agent-definitions' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' +import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-ownership' export const hello: AgentJournalMessageItem = { @@ -84,6 +85,7 @@ export async function openHostRig( script?: (agent: AcpScriptedAgent) => void initialize?: Record deps?: Partial + modelCatalog?: AgentModelCatalogService statusSink?: StructuredAgentSessionStatusSink } = {} ) { @@ -118,7 +120,8 @@ export async function openHostRig( claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => HOST_TEST_NOW, - statusSink: options.statusSink + statusSink: options.statusSink, + ...(options.modelCatalog ? { modelCatalog: options.modelCatalog } : {}) }) hosted.host = host replaceHostTestState({ store, host }) @@ -185,12 +188,14 @@ export const framesOf = (child: FakeAcpChild, method: string) => /** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig( deps: Partial = {}, - statusSink?: StructuredAgentSessionStatusSink + statusSink?: StructuredAgentSessionStatusSink, + modelCatalog?: AgentModelCatalogService ) { const count = { loads: 0 } let resumed = false const rig = await openHostRig({ statusSink, + ...(modelCatalog ? { modelCatalog } : {}), initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => { diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index fb0211c624e..240dddfff3c 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -14,6 +14,7 @@ import { type AgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { @@ -140,6 +141,37 @@ export async function acpLaunchVersionSupported( return (await probeAcpLaunchVersion(spec, launch, false, probe)).supported } +export type AcpLaunchInvocationDeps = Pick< + AcpStructuredLaunchResolverDeps, + | 'resolveEnvironment' + | 'resolveLaunchEnv' + | 'resolveCommandSettings' + | 'resolveCommand' + | 'homePath' + | 'inheritedEnv' +> + +/** The binary and environment any child of this agent runs under for `accountHome`: a session's + * launch and a catalog probe both resolve here, so neither can reach another account or install. */ +export async function resolveAcpLaunchInvocation( + spec: AcpLaunchSpec, + accountHome: AgentSessionAccountHome, + deps: AcpLaunchInvocationDeps +): Promise<{ command: string; env: Record; envToDelete: string[] }> { + const base = await deps.resolveEnvironment() + const env: Record = spec.account.environment(accountHome, { + ...base, + ...deps.resolveLaunchEnv?.(spec.agent) + }) + const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] + Object.assign(env, spec.env) + const command = resolveAcpLaunchCommand(spec, env, { + ...deps, + ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) + }) + return { command, env, envToDelete } +} + export function createAcpStructuredLaunchResolver( spec: AcpLaunchSpec, deps: AcpStructuredLaunchResolverDeps @@ -160,17 +192,7 @@ export function createAcpStructuredLaunchResolver( `${spec.agent} structured sessions run on this runtime's host, not ${location.executionHostId}` ) } - const base = await deps.resolveEnvironment() - const env: Record = spec.account.environment(accountHome, { - ...base, - ...deps.resolveLaunchEnv?.(spec.agent) - }) - const envToDelete = spec.scrubEnvironment?.(env, deps.inheritedEnv ?? process.env) ?? [] - Object.assign(env, spec.env) - const command = resolveAcpLaunchCommand(spec, env, { - ...deps, - ...(deps.resolveCommandSettings ? { commandSettings: deps.resolveCommandSettings() } : {}) - }) + const { env, envToDelete, command } = await resolveAcpLaunchInvocation(spec, accountHome, deps) const cwd = await deps.resolveWorkspacePath(location.workspaceId) const visuals = spec.visualsSkill ? ((await deps.prepareVisuals?.(identity.sessionId)) ?? null) diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts new file mode 100644 index 00000000000..b348d0fdba9 --- /dev/null +++ b/src/main/acp/acp-structured-options.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' + +const configOptions = [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select' as const, + currentValue: 'model-b', + options: [ + { value: 'model-a', name: 'Model A' }, + { value: 'model-b', name: 'Model B' } + ] + }, + { + id: 'effort', + name: 'Effort', + category: 'thought_level', + type: 'select' as const, + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' } + ] + } +] + +describe('ACP session options', () => { + it('keeps the session’s picks out of the catalog facts', () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }) + const { models, current } = options.read() + // The effort menu belongs to the model running now; nothing is anyone's default. + expect(models).toEqual([ + { id: 'model-a', label: 'Model A', isDefault: false, efforts: [] }, + { + id: 'model-b', + label: 'Model B', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ]) + expect(current).toEqual({ model: 'model-b', effort: 'high', confirmed: ['model', 'effort'] }) + }) + + it('reads each model’s own menu where the agent advertises one', () => { + const options = new AcpStructuredOptions(GROK_ACP_DIALECT) + options.adoptSession({ + configOptions, + models: { + currentModelId: 'model-b', + availableModels: [ + { + modelId: 'model-a', + name: 'Model A', + _meta: { + supportsReasoningEffort: true, + reasoningEfforts: [{ value: 'medium', default: true }] + } + }, + // The session's own effort written into the running model's meta is not its default. + { + modelId: 'model-b', + name: 'Model B', + _meta: { + supportsReasoningEffort: true, + reasoningEffort: 'high', + reasoningEfforts: ['low', 'high'] + } + } + ] + } + }) + expect(options.read().models).toEqual([ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + efforts: [{ value: 'medium', label: 'Medium' }], + defaultEffort: 'medium' + }, + { + id: 'model-b', + label: 'Model B', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ]) + expect(options.reported()).toEqual({ model: 'model-b', effort: 'high' }) + }) + + it('keeps the session’s effort menu for the running model when its advertised one is empty', () => { + const options = new AcpStructuredOptions(GROK_ACP_DIALECT) + options.adoptSession({ + configOptions, + models: { + currentModelId: 'model-b', + availableModels: [ + { modelId: 'model-a', name: 'Model A' }, + { modelId: 'model-b', name: 'Model B', _meta: { supportsReasoningEffort: false } } + ] + } + }) + const [modelA, modelB] = options.read().models + expect(modelA?.efforts).toEqual([]) + expect(modelB?.efforts).toEqual([ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ]) + expect(modelB?.defaultEffort).toBeUndefined() + }) +}) + +describe('what a new session with no pick says about the configured default', () => { + const connection = { + setConfigOption: async () => ({ configOptions }), + setModel: async () => ({}) + } + + it('is the model and effort the agent chose for itself', () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }, 'new') + expect(options.configuredDefault()).toEqual({ modelId: 'model-b', effort: 'high' }) + }) + + it('says nothing of a loaded session, which keeps the model it ran', () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }) + expect(options.configuredDefault()).toBeUndefined() + }) + + it('says nothing once the chat holds a model pick, even one the session already runs', async () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }, 'new') + await restoreAcpSessionOptions(connection, options, { model: 'model-b' }) + expect(options.configuredDefault()).toBeUndefined() + }) + + it('keeps the model but not an effort the chat picked', async () => { + const options = new AcpStructuredOptions() + options.adoptSession({ configOptions }, 'new') + await restoreAcpSessionOptions(connection, options, { effort: 'low' }) + expect(options.configuredDefault()).toEqual({ modelId: 'model-b' }) + }) + + it('says nothing when the agent reports no model, so no empty default is saved', () => { + const noModel = JSON.parse( + readFileSync(new URL('./fixtures/omp-v17-windows-new-no-model.json', import.meta.url), 'utf8') + ) + const options = new AcpStructuredOptions() + options.adoptSession(noModel, 'new') + expect(options.configuredDefault()).toBeUndefined() + options.adoptSession({ models: { currentModelId: '', availableModels: [] } }, 'new') + expect(options.configuredDefault()).toBeUndefined() + }) + + it('retires a saved default when the session runs a model it does not list', () => { + const options = new AcpStructuredOptions() + options.adoptSession( + { configOptions: [{ ...configOptions[0]!, currentValue: 'model-z' }] }, + 'new' + ) + expect(options.configuredDefault()).toBeNull() + }) +}) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 250541a7e21..0ee9d5d8b5e 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -11,6 +11,8 @@ import type { import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' import { AcpRpcError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpDialect } from './acp-dialects/acp-dialect' +import type { AgentModelCatalogConfiguredChoice } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { SessionConfigSelectGroupSchema, SessionConfigSelectOptionSchema, @@ -48,17 +50,31 @@ function isSelect(option: SessionConfigOption): option is SelectOption { } export class AcpStructuredOptions { + constructor(private readonly dialect: Pick = {}) {} + private configOptions: SessionConfigOption[] = [] private models: SessionModelState | null = null private commands: AgentSessionSlashCommand[] | undefined + // A loaded session keeps the model it ran; only a new one resolves the agent's own config. + private resolvesConfig = false + private readonly picked = new Set() /** The session state a new, load or resume response reported. */ - adoptSession(response: { - configOptions?: SessionConfigOption[] | null - models?: SessionModelState | null - }): void { + adoptSession( + response: { + configOptions?: SessionConfigOption[] | null + models?: SessionModelState | null + }, + origin: 'new' | 'loaded' = 'loaded' + ): void { this.configOptions = response.configOptions ?? [] this.models = response.models ?? null + this.resolvesConfig = origin === 'new' + } + + /** Orca sent `key` to the session, or the chat's saved options hold a value for it. */ + notePick(key: string): void { + this.picked.add(key) } adoptConfigOptions(configOptions: SessionConfigOption[] | null | undefined): void { @@ -102,10 +118,15 @@ export class AcpStructuredOptions { return null } + /** + * The agent's models and what this session runs. A model's effort menu and default are catalog + * facts: the dialect's per-model menu when the agent advertises one, else the session's effort + * option for the model it runs now only. The session's current values are never a default. + */ read(): Pick { const modelOption = this.select('model') const effortOption = this.select('thought_level') - const efforts: AgentSessionOptionChoice[] = effortOption + const sessionEfforts: AgentSessionOptionChoice[] = effortOption ? selectChoices(effortOption).map((choice) => ({ value: choice.value, label: choice.name, @@ -124,12 +145,17 @@ export class AcpStructuredOptions { label: model.name, ...(model.description ? { description: model.description } : {}) })) ?? []) - const models: AgentSessionModelOption[] = listed.map((model) => ({ - ...model, - isDefault: model.id === currentModel, - efforts, - ...(effortOption ? { defaultEffort: effortOption.currentValue } : {}) - })) + const models: AgentSessionModelOption[] = listed.map((model) => { + const info = this.models?.availableModels.find((entry) => entry.modelId === model.id) + const advertised = info ? this.dialect.modelEfforts?.(info) : undefined + const sessionMenu = { efforts: model.id === currentModel ? sessionEfforts : [] } + return { + ...model, + isDefault: false, + // An empty advertised menu is no menu: the running model keeps the one its session offers. + ...(advertised && advertised.efforts.length > 0 ? advertised : sessionMenu) + } + }) const confirmed = [...(currentModel ? ['model'] : []), ...(effortOption ? ['effort'] : [])] return { models, @@ -141,6 +167,27 @@ export class AcpStructuredOptions { } } + /** With no model sent since a new session began, what it runs is the agent's own config + * resolution: that scope's configured default. Null when that names no listed model; undefined + * when this session can't say. */ + configuredDefault(): AgentModelCatalogConfiguredChoice | null | undefined { + if (!this.resolvesConfig || this.picked.has('model')) { + return undefined + } + const { models, current } = this.read() + if (!current.model) { + return undefined + } + if (!models.some((model) => model.id === current.model)) { + return null + } + return { + modelId: current.model, + // An effort this session picked is its own and says nothing of the config's. + ...(this.picked.has('effort') ? {} : { effort: current.effort ?? null }) + } + } + /** The values the agent reports now, as the record's options would name them. */ reported(): Readonly> { const { current } = this.read() @@ -207,8 +254,11 @@ export async function restoreAcpSessionOptions( ): Promise { const skipped: string[] = [] for (const [key, value] of Object.entries(saved ?? {})) { - const reported = options.reported() - if (!isAcpStructuredOptionKey(key) || reported[key] === value) { + if (!isAcpStructuredOptionKey(key)) { + continue + } + options.notePick(key) + if (options.reported()[key] === value) { continue } const write = options.write(key, value) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index b69b24cc4b2..a352c924e2c 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -43,6 +43,7 @@ import { } from './acp-structured-session-adapter-deps' import { writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' +import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { @@ -212,6 +213,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!write) { throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) } + session.options.notePick(input.key) // Bounded, and abandoned by a close or Stop: the session's queue waits on it. await writeAcpSessionOption(session.connection, session.options, write, { agent: session.spec.agent, @@ -221,8 +223,10 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return session.options.reported() } - readOptions = async (input: { sessionId: string; fence: number }) => - this.live(input.sessionId).options.read() + readOptions = async (input: { sessionId: string; fence: number }) => { + const { options } = this.live(input.sessionId) + return withLiveCatalogListing(options.read(), options.configuredDefault()) + } readOptionRestoreFailures = (sessionId: string): readonly string[] => this.sessions.get(sessionId)?.restoreSkipped ?? [] diff --git a/src/main/claude/claude-model-catalog-probe.test.ts b/src/main/claude/claude-model-catalog-probe.test.ts index b03ce52f3b8..e6a337a70a6 100644 --- a/src/main/claude/claude-model-catalog-probe.test.ts +++ b/src/main/claude/claude-model-catalog-probe.test.ts @@ -1,5 +1,3 @@ -import { createMockDiscoveryChild } from '../text-generation/commit-message-text-generation-test-harness' -import { CLAUDE_MODEL_LIST_STDIN } from '../../shared/claude-model-list-probe' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' @@ -8,18 +6,25 @@ import { describe, expect, it, vi } from 'vitest' import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-process-supervisor' import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe' import { resolveClaudeStructuredInvocation } from './claude-structured-launch-resolution' -import type { discoverModelsLocal } from '../text-generation/commit-message-model-discovery' import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' -import type { - SpawnedSourceControlAgentProcess, - SpawnSourceControlAgent -} from '../text-generation/source-control-text-generation-types' +import { + AgentModelCatalogProbeError, + type runAgentModelCatalogListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import { + CLAUDE_MODEL_LIST_ARGS, + CLAUDE_MODEL_LIST_STDIN +} from '../../shared/claude-model-list-probe' -type DiscoverInput = Parameters[0] -type DiscoverResult = Awaited> +type ListingCall = Parameters const AUTH_POLICY = { stripAuthEnv: false } as const +const HOME = (path: string): { variable: string; path: string } => ({ + variable: 'CLAUDE_CONFIG_DIR', + path +}) + function probeDeps(command = `"${process.execPath}"`): { resolveCommand: () => string resolveEnv: () => Record @@ -37,113 +42,99 @@ function probeDeps(command = `"${process.execPath}"`): { } } -function listedResult(): DiscoverResult { - return { - success: true, - capability: { - id: 'claude', - label: 'Claude', - modelSource: 'dynamic', - defaultModelId: 'sonnet', - models: [] - }, - models: [ - { - id: 'sonnet', - label: 'Sonnet', - isDefault: true, - thinkingLevels: [{ id: 'low', label: 'Low' }], - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'sonnet', - catalogOrigin: 'probe' +const LISTED = `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { + models: [ + { + value: 'sonnet', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low'] + } + ] + } } -} +})}\n` describe('claude model catalog probe', () => { it.each([`"${process.execPath}"`, ''])( - 'lists with the session executable for Command %j', + 'lists with the session executable and env for Command %j', async (command) => { const deps = probeDeps(command) - const captured: DiscoverInput[] = [] - const spawnAgent = vi.fn( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake discover below never reads the returned child. - () => ({}) as SpawnedSourceControlAgentProcess - ) + const calls: ListingCall[] = [] const probe = createClaudeModelCatalogProbe({ ...deps, - spawnAgent, - discover: async (input) => { - captured.push(input) - // Drive the probe's spawn wrapper once, the way the real listing would. - input.spawnAgent({ - binary: 'claude', - args: ['--list'], - env: input.env, - stdinMode: 'ignore', - useCwdForNative: false - }) - return listedResult() + runListing: async (...call) => { + calls.push(call) + return LISTED } }) - const success = await probe('/homes/account-a') + const success = await probe(HOME('/homes/account-a')) expect(success.origin).toBe('probe') expect(success.models).toEqual([ { id: 'sonnet', label: 'Sonnet', - isDefault: true, - // The spec's `defaultThinkingLevel` is not what Claude runs; naming it would label the effort. + isDefault: false, + // The parser's thinking default is not what Claude runs; naming it would label the effort. efforts: [{ value: 'low', label: 'Low' }] } ]) - // The probe's env is exactly the session launch's resolved env for the - // same deps, plus the account pin — and the spawn runs the resolved - // binary, never the bare spec name. + // The probe's env is exactly the session launch's resolved env for the same deps, plus the + // account pin, and the listing runs the resolved binary, never the bare spec name. const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({ ...env, CLAUDE_CONFIG_DIR: '/homes/account-a' })) - expect(captured).toHaveLength(1) - expect(captured[0]!.env).toEqual(invocation.env) - expect(captured[0]!.agentCommandOverride).toBeUndefined() - expect(spawnAgent).toHaveBeenCalledTimes(1) - expect(spawnAgent.mock.calls[0]![0].binary).toBe(invocation.command) + expect(calls).toHaveLength(1) + const [launch, options] = calls[0]! + expect(launch).toEqual({ + command: invocation.command, + args: [...CLAUDE_MODEL_LIST_ARGS], + stdin: CLAUDE_MODEL_LIST_STDIN + }) + expect(options.inheritedEnv).toEqual(invocation.env) } ) it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => { - const envs: DiscoverInput['env'][] = [] + const envs: (NodeJS.ProcessEnv | undefined)[] = [] const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - discover: async (input) => { - envs.push(input.env) - return listedResult() + runListing: async (_launch, options) => { + envs.push(options.inheritedEnv) + return LISTED } }) - await probe(join(homedir(), '.claude')) - await probe('/homes/account-a') + await probe(HOME(join(homedir(), '.claude'))) + await probe(HOME('/homes/account-a')) // An explicit default would move the CLI off its default Keychain item (claude.ai OAuth). expect(envs[0]).not.toHaveProperty('CLAUDE_CONFIG_DIR') expect(envs[1]).toMatchObject({ CLAUDE_CONFIG_DIR: '/homes/account-a' }) }) - it('refuses a static-fallback answer rather than reporting it as a catalog', async () => { + it('refuses an answer that names no model, as an older CLI gives, rather than store it', async () => { const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - discover: async () => ({ ...listedResult(), catalogOrigin: 'spec' }) + runListing: async () => + `${JSON.stringify({ type: 'control_response', response: { subtype: 'error' } })}\n` }) - await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) + await expect(probe(HOME('/homes/a'))).rejects.toThrow(/listed no models/) }) it.each(['npx claude', 'claude --verbose', '/missing/claude', './claude'])( 'lists nothing, and never probes the stock CLI, for Command %s', async (command) => { - const discover = vi.fn() - const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), discover }) - await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) - expect(discover).not.toHaveBeenCalled() + const runListing = vi.fn() + const probe = createClaudeModelCatalogProbe({ ...probeDeps(command), runListing }) + await expect(probe(HOME('/homes/a'))).rejects.toMatchObject({ + reason: 'agentCommandNotRunnable' + }) + expect(runListing).not.toHaveBeenCalled() } ) @@ -176,7 +167,7 @@ process.stdin.on('end', () => setTimeout(() => { resolveInheritedEnv: async () => ({ PATH: process.env.PATH ?? '', HOME: folder }) }) - await expect(probe(join(folder, 'account'))).resolves.toMatchObject({ + await expect(probe(HOME(join(folder, 'account')))).resolves.toMatchObject({ origin: 'probe', models: [{ id: 'sonnet', label: 'Sonnet' }] }) @@ -190,73 +181,46 @@ process.stdin.on('end', () => setTimeout(() => { }) describe('Claude catalog availability', () => { - it('does not turn a generic discovery failure into unavailable', async () => { + it('does not turn a generic listing failure into unavailable', async () => { const probe = createClaudeModelCatalogProbe({ ...probeDeps(), - discover: async () => ({ success: false, error: 'timeout' }) - }) - await expect(probe('/homes/a')).rejects.not.toBeInstanceOf(AgentModelCatalogUnavailableError) - }) -}) - -describe('Claude catalog fake-child contract', () => { - // Claude has no pre-send sign-in verdict: its real start refusal is the only one. - it.each([false, true])( - 'lists in one process with no sign-in verdict, managed=%s', - async (managed) => { - const child = createMockDiscoveryChild() - const spawnAgent = vi.fn(() => { - queueMicrotask(() => { - child.stdout.emit( - 'data', - Buffer.from( - `${JSON.stringify({ - type: 'control_response', - response: { - request_id: 'orca-model-discovery', - subtype: 'success', - response: { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } - } - })}\n` - ) - ) - child.emit('close', 0) - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: discovery reads only the fake child's EventEmitter streams, pid, kill and stdin.end. - return child as unknown as SpawnedSourceControlAgentProcess - }) - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveEnv: () => ({}), - resolveAuthPolicy: () => ({ stripAuthEnv: managed }), - spawnAgent - }) - const result = await probe('/homes/a') - expect(result).toMatchObject({ models: [{ id: 'sonnet' }] }) - expect(result).not.toHaveProperty('unavailable') - expect(spawnAgent).toHaveBeenCalledTimes(1) - expect(child.stdin.end).toHaveBeenCalledWith(CLAUDE_MODEL_LIST_STDIN) - } - ) - it('preserves the resolved executable missing error from a child', async () => { - const child = createMockDiscoveryChild() - const probe = createClaudeModelCatalogProbe({ - ...probeDeps(), - resolveCommand: () => '/resolved/claude with spaces/claude', - spawnAgent: () => { - queueMicrotask(() => - child.emit( - 'error', - Object.assign(new Error('missing'), { - code: 'ENOENT', - path: '/resolved/claude with spaces/claude' - }) - ) - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: discovery reads only the fake child's EventEmitter streams, pid, kill and stdin.end. - return child as unknown as SpawnedSourceControlAgentProcess + runListing: async () => { + throw new AgentModelCatalogProbeError('claude did not list models', 'timeout') } }) - await expect(probe('/homes/a')).rejects.toMatchObject({ unavailable: { reason: 'cliMissing' } }) + await expect(probe(HOME('/homes/a'))).rejects.not.toBeInstanceOf( + AgentModelCatalogUnavailableError + ) + }) + + // Claude has no pre-send sign-in verdict: its real start refusal is the only one. + it.each([false, true])('lists with no sign-in verdict, managed=%s', async (managed) => { + const runListing = vi.fn(async () => LISTED) + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + resolveAuthPolicy: () => ({ stripAuthEnv: managed }), + runListing + }) + const result = await probe(HOME('/homes/a')) + expect(result).toMatchObject({ models: [{ id: 'sonnet' }] }) + expect(result).not.toHaveProperty('unavailable') + expect(runListing).toHaveBeenCalledTimes(1) + }) + + it('says the CLI is missing when the resolved executable is not there', async () => { + const folder = mkdtempSync(join(tmpdir(), 'orca-claude-missing-')) + try { + // Never created: the real runner's spawn reports ENOENT for this exact path. + const missing = join(folder, 'claude with spaces', 'claude') + const probe = createClaudeModelCatalogProbe({ + ...probeDeps(), + resolveCommand: () => missing + }) + await expect(probe(HOME('/homes/a'))).rejects.toMatchObject({ + unavailable: { reason: 'cliMissing' } + }) + } finally { + rmSync(folder, { recursive: true, force: true }) + } }) }) diff --git a/src/main/claude/claude-model-catalog-probe.ts b/src/main/claude/claude-model-catalog-probe.ts index 7a316bd0f40..58ef1050a93 100644 --- a/src/main/claude/claude-model-catalog-probe.ts +++ b/src/main/claude/claude-model-catalog-probe.ts @@ -1,7 +1,10 @@ +import { + CLAUDE_MODEL_LIST_ARGS, + CLAUDE_MODEL_LIST_STDIN +} from '../../shared/claude-model-list-probe' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { parseClaudeModels } from '../../shared/commit-message-model-parsers' import { AgentModelCatalogUnavailableError } from '../native-chat/agent-model-catalog/agent-model-catalog-unavailable' -import { discoverModelsLocal } from '../text-generation/commit-message-model-discovery' -import { commandBackslashMode } from '../text-generation/commit-message-text-generation' -import { spawnSourceControlAgent } from '../text-generation/source-control-agent-launch' import { claudeConfigDirEnvPatch } from './claude-config-dir-pin' import { resolveClaudeStructuredInvocation, @@ -11,60 +14,58 @@ import type { AgentModelCatalogProbe, AgentModelCatalogSuccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + AgentModelCatalogProbeError, + runAgentModelCatalogListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' export type ClaudeModelCatalogProbeDeps = Pick< ClaudeStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' | 'resolveAuthPolicy' > & { authSwitchSettleTimeoutMs?: number - /** Test seams; production runs the one-shot listing child. */ - discover?: typeof discoverModelsLocal - spawnAgent?: typeof spawnSourceControlAgent + /** Test seam; production runs the shared supervised listing. */ + runListing?: typeof runAgentModelCatalogListing } /** - * Lists models without a live session, through the existing one-shot CLI - * listing — but under the SAME binary and environment the structured session - * launch resolves, so a probe can never list under a different install or - * shell env than the sessions it stands in for. + * Lists models without a live session: one `list_models` control request, under the SAME binary + * and environment the structured session launch resolves, so a probe can never list under a + * different install or shell env than the sessions it stands in for. */ export function createClaudeModelCatalogProbe( deps: ClaudeModelCatalogProbeDeps ): AgentModelCatalogProbe { - return async (accountHomePath: string): Promise => { + return async (accountHome, options): Promise => { + const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path // Same pin rule as the session spawn: naming the CLI's default dir would move // it off the default Keychain item and list under another identity. const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => ({ ...base, ...claudeConfigDirEnvPatch(accountHomePath, { env: base }) })) - const result = await (deps.discover ?? discoverModelsLocal)({ - agentId: 'claude', - binary: command, - env, - options: {}, - backslash: commandBackslashMode({ kind: 'local', cwd: '' }), - // The resolved absolute command replaces the plan's bare binary directly: - // routing it through the command-override template would re-tokenize a - // path that may contain spaces. - spawnAgent: (input) => - (deps.spawnAgent ?? spawnSourceControlAgent)({ ...input, binary: command }) + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { command, args: [...CLAUDE_MODEL_LIST_ARGS], stdin: CLAUDE_MODEL_LIST_STDIN }, + { site: 'claude-model-catalog-probe', inheritedEnv: env, signal: options?.signal } + ).catch((error: unknown) => { + // Claude has no pre-send sign-in verdict; a missing CLI is the one this probe can find. + if (error instanceof AgentModelCatalogProbeError && error.executableMissing) { + throw new AgentModelCatalogUnavailableError({ reason: 'cliMissing' }) + } + throw error }) - if (!result.success && result.unavailable) { - throw new AgentModelCatalogUnavailableError(result.unavailable) - } - // The spec's static fallback must never pass as a listing: Claude's real - // list replaces the seed, so only a probe-origin answer is a catalog. - if (!result.success || result.catalogOrigin !== 'probe' || result.models.length === 0) { - throw new Error(result.success ? 'claude listed no models' : result.error) + // A CLI that predates the request answers a control error: no models, so no catalog. + const listed = parseClaudeModels(stdout) + if (listed.length === 0) { + throw new Error('claude listed no models') } return { - models: result.models.map((model) => ({ + models: listed.map((model) => ({ id: model.id, label: model.label, ...(model.description ? { description: model.description } : {}), isDefault: model.isDefault === true, - // No defaultEffort: the listing's thinking default is the commit-message generator's + // No defaultEffort: the parser's thinking default is the commit-message generator's // choice, not the effort Claude runs; the store keeps the one a live child reported. efforts: (model.thinkingLevels ?? []).map((level) => ({ value: level.id, diff --git a/src/main/claude/claude-structured-acquire-catalog.ts b/src/main/claude/claude-structured-acquire-catalog.ts deleted file mode 100644 index e4144a22c01..00000000000 --- a/src/main/claude/claude-structured-acquire-catalog.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import type { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' - -/** A Claude session's catalog, keyed by the config directory it launched under. */ -export function claudeAcquireCatalogAccess( - store: AgentModelCatalogStore | undefined, - claudeConfigDir: string | null -) { - return agentModelCatalogSessionAccess(store, CLAUDE_STRUCTURED_AGENT, claudeConfigDir) -} diff --git a/src/main/claude/claude-structured-catalog-listing.ts b/src/main/claude/claude-structured-catalog-listing.ts new file mode 100644 index 00000000000..1b26cb4d999 --- /dev/null +++ b/src/main/claude/claude-structured-catalog-listing.ts @@ -0,0 +1,108 @@ +// What a Claude child's listing says about its account, apart from this session's own state. + +import type { + AgentSessionFastModeSupport, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import type { + AgentModelCatalogConfiguredChoice, + AgentModelCatalogLiveListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import type { ListedModel } from './claude-structured-model-catalog' +import type { ClaudeSession } from './claude-structured-session-state' + +const TRANSIENT_FAST_MODE_REASONS = new Set(['network_error', 'unknown', 'pending']) +const NON_BLOCKING_FAST_MODE_REASONS = new Set(['preference', 'sdk_opt_in_required']) + +export function claudeFastModeSupport( + models: readonly ListedModel[], + disabledReason: string | undefined +): AgentSessionFastModeSupport | undefined { + if (disabledReason && TRANSIENT_FAST_MODE_REASONS.has(disabledReason)) { + return undefined + } + if (disabledReason && !NON_BLOCKING_FAST_MODE_REASONS.has(disabledReason)) { + return { supported: false, reason: disabledReason } + } + if (!models.some((model) => model.supportsFastMode === true)) { + return models.length > 0 && models.every((model) => model.supportsFastMode === false) + ? { supported: false, reason: 'model-not-supported' } + : undefined + } + return { supported: true } +} + +export type WireClaudeModel = AgentSessionOptionsResult['models'][number] + +function wireClaudeModel(entry: ListedModel): WireClaudeModel { + return { + id: entry.id, + label: entry.label, + ...(entry.description ? { description: entry.description } : {}), + isDefault: entry.isDefault, + efforts: entry.efforts, + ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) + } +} + +export function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { + return models.map(wireClaudeModel) +} + +/** The account-level facts of a provider listing, for the host to save: this session's disabled + * reason and its unlisted current model stay out, so another surface never inherits session state + * as a catalog. A child launched with `--model X` lists X itself (Claude Code 2.1.280 adds a row + * named by the raw id, "Custom model"), whether or not X exists; native rows carry a display name + * of their own, so a row named by its id is the launch talking, not the account. */ +export function claudeCatalogListing( + session: ClaudeSession, + discovered: ListedModel[] +): AgentModelCatalogLiveListing | undefined { + if (discovered.length === 0) { + return undefined + } + const launched = session.launchedModel + const launchOnly = + launched !== null && discovered.some((row) => row.id === launched && row.label === row.id) + const support = claudeFastModeSupport(discovered, undefined) + const configured = configuredClaudeDefault(session, discovered) + return { + // No default effort here: the CLI's effort is a config fact, saved with the configured model. + models: wireClaudeModels(discovered), + ...(support ? { fastModeSupport: support } : {}), + ...(launchOnly ? { launchOnlyModelId: launched } : {}), + ...(configured !== undefined ? { configuredDefault: configured } : {}) + } +} + +/** With no model sent at launch or since, the model and effort the CLI says it applies are its own + * resolution of env over settings over its default: the configured default for this session's + * config scope. Null when that resolution names no listed model; undefined when the session + * picked its own model or never read its settings back. */ +function configuredClaudeDefault( + session: ClaudeSession, + discovered: ListedModel[] +): AgentModelCatalogConfiguredChoice | null | undefined { + const applied = session.appliedOptions + if (session.launchedModel !== null || session.options.has('model') || !applied) { + return undefined + } + const row = applied.model + ? discovered.find( + (entry) => entry.id === applied.model || entry.resolvedModel === applied.model + ) + : undefined + if (!row) { + return null + } + const runs = row.resolvedModel ?? row.id + const sameModelIds = discovered + .filter((entry) => entry !== row && (entry.resolvedModel ?? entry.id) === runs) + .map((entry) => entry.id) + return { + modelId: row.id, + ...(sameModelIds.length > 0 ? { sameModelIds } : {}), + // An effort this session picked is its own and says nothing of the config's. + ...(session.options.has('effort') ? {} : { effort: applied.effort ?? null }) + } +} diff --git a/src/main/claude/claude-structured-configured-default.test.ts b/src/main/claude/claude-structured-configured-default.test.ts new file mode 100644 index 00000000000..bf57896023d --- /dev/null +++ b/src/main/claude/claude-structured-configured-default.test.ts @@ -0,0 +1,233 @@ +// A new Claude chat names its model and effort on the first frame: a chat launched with nothing +// picked reports the model the CLI's own config resolution applies, and the host keeps that as the +// account's configured default when the chat's workspace has no Claude settings of its own. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { getAgentSessionOptionCatalog } from '../../shared/agent-session-option-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import { + PROVIDER_SESSION_ID, + fakeClaude, + identityFor, + recordingJournalSink, + claudeStartupSettled +} from './claude-structured-session-test-support' + +const SESSION = 'session-1' +const ACCOUNT_HOME = '/accounts/claude' +const EFFORTS = ['low', 'medium', 'high'] + +const CATALOG = [ + { + value: 'default', + resolvedModel: 'claude-opus-5-5', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'opus', + resolvedModel: 'claude-opus-5-5', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: EFFORTS + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: EFFORTS + } +] + +/** The user's settings pick Sonnet over the listing's recommended Opus; the CLI applies Sonnet. */ +async function liveListing( + options?: Record, + applied: string | null = 'claude-sonnet-5' +) { + const claude = fakeClaude({ + initProof: 'session-start', + initModel: applied ?? 'claude-opus-5-5', + initModels: CATALOG, + settings: { + effective: {}, + sources: [], + applied: { model: applied, effort: applied ? 'high' : null, advisor: null, ultracode: false } + }, + routes: { list_models: () => CATALOG } + }) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: options?.model ? { model: options.model } : {}, + cwd: '/work/repo', + claudeConfigDir: ACCOUNT_HOME, + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => 1_700_000_000_500, + persistHandle: async () => {} + }) + await adapter.acquire({ + identity: identityFor(SESSION), + fence: 7, + spawnToken: 'spawn-9', + events: recordingJournalSink(), + ...(options ? { options } : {}) + }) + await claudeStartupSettled(adapter, SESSION) + return (await adapter.readOptions({ sessionId: SESSION, fence: 7 })).catalogListing +} + +function record(): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog reads only these fields. + return { + sessionId: SESSION, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, + location: { executionHostId: 'local', wslDistro: null, workspaceId: 'ws-1' }, + launchDirectory: '/work/repo' + } as unknown as AgentSessionRecord +} + +function service(store: AgentModelCatalogStore, projectSettings: boolean) { + const launched = record() + return createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === SESSION ? launched : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + workspaceMayOverrideDefaultModel: async ({ workspacePath }) => + projectSettings && workspacePath === '/work/repo' + }) +} + +/** What a new chat's first frame shows from the host's answer alone. */ +async function firstFrame(store: AgentModelCatalogStore, workspacePath: string) { + const answer = await service(store, false).read({ agent: 'claude', workspacePath }) + const seed = getAgentSessionOptionCatalog('claude')! + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', seed), + seed, + answer, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const current = (id: string) => { + const kind = snapshot.find((row) => row.id === id)?.kind + return kind && 'currentValue' in kind ? kind.currentValue : undefined + } + return { answer, model: current('model'), effort: current('effort') } +} + +describe('Claude configured default', () => { + it('names the model and effort the CLI resolves for a chat with nothing picked', async () => { + const store = new AgentModelCatalogStore() + const listing = await liveListing() + // Sonnet, as the user's settings pick it — not the listing's recommended Opus. + expect(listing?.configuredDefault).toEqual({ modelId: 'sonnet', effort: 'high' }) + // The effort travels with the configured model, never as a live row's own default. + expect(listing?.models.some((model) => model.defaultEffort !== undefined)).toBe(false) + service(store, false).recordLiveListing(SESSION, listing!) + + await vi.waitFor(async () => + expect((await firstFrame(store, '/work/other')).answer).toMatchObject({ + listingNamesConfiguredModel: true + }) + ) + const frame = await firstFrame(store, '/work/other') + expect(frame.model).toBe('sonnet') + expect(frame.effort).toBe('high') + }) + + it('keeps nothing from a chat whose workspace has Claude settings of its own', async () => { + const store = new AgentModelCatalogStore() + service(store, true).recordLiveListing(SESSION, (await liveListing())!) + await new Promise((resolve) => setTimeout(resolve, 0)) + + const frame = await firstFrame(store, '/work/other') + expect(frame.answer).toMatchObject({ listingNamesConfiguredModel: false }) + expect(frame.model).toBeUndefined() + // The project's effort stays out of the account's catalog too. + const entry = store.get(agentModelCatalogFingerprintForRecord(record())) + expect(entry?.configured).toBeNull() + expect(entry?.models.some((model) => model.defaultEffort !== undefined)).toBe(false) + }) + + it('forgets a saved default once the config resolves to no listed model, for good', async () => { + const store = new AgentModelCatalogStore() + const fingerprint = agentModelCatalogFingerprintForRecord(record()) + service(store, false).recordLiveListing(SESSION, (await liveListing())!) + await vi.waitFor(() => expect(store.get(fingerprint)?.configured?.modelId).toBe('sonnet')) + + // The user's settings now pick a model the account no longer lists. + const unlisted = await liveListing(undefined, 'claude-retired-1') + expect(unlisted?.configuredDefault).toBeNull() + service(store, false).recordLiveListing(SESSION, unlisted!) + await vi.waitFor(() => expect(store.get(fingerprint)?.configured).toBeNull()) + + // A later background listing keeps it forgotten. + store.recordSuccess( + fingerprint, + 'claude', + { + models: CATALOG.map((row) => ({ + id: row.value, + label: row.displayName, + isDefault: row.value === 'default', + efforts: EFFORTS.map((value) => ({ value, label: value })) + })), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + const frame = await firstFrame(store, '/work/other') + expect(store.get(fingerprint)?.configured).toBeNull() + expect(frame.answer).toMatchObject({ listingNamesConfiguredModel: false }) + expect(frame.model).toBeUndefined() + }) + + it('forgets a saved default when the config resolves to no model at all', async () => { + const listing = await liveListing(undefined, null) + expect(listing?.configuredDefault).toBeNull() + }) + + it('learns nothing from a chat launched with a model pick', async () => { + const listing = await liveListing({ model: 'sonnet' }) + expect(listing).toBeDefined() + expect(listing?.configuredDefault).toBeUndefined() + }) + + it('names nothing in a workspace whose own Claude settings could pick another model', async () => { + const store = new AgentModelCatalogStore() + service(store, false).recordLiveListing(SESSION, (await liveListing())!) + await vi.waitFor(() => + expect(store.get(agentModelCatalogFingerprintForRecord(record()))?.configured).not.toBeNull() + ) + + const answer = await createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), + workspaceMayOverrideDefaultModel: async () => true + }).read({ agent: 'claude', workspacePath: '/work/repo' }) + expect(answer).toMatchObject({ listingNamesConfiguredModel: false }) + }) +}) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index e0342094db6..73b871ae2c9 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -1,5 +1,5 @@ -// A Claude chat at rest shows the effort its next start will run: a live child teaches the host -// catalog what the CLI runs for each model when no effort is sent, and the resting read answers it. +// A Claude chat at rest shows the effort its next start will run: a live child with nothing picked +// teaches the host catalog the configured model and effort, and the resting read answers it. import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -111,8 +111,7 @@ async function startChild( openConnection: settingsWithNoOverride(cli.runs, cli.lists).openConnection, readProcessStartTime: async () => 1_700_000_000_000, now: () => 1_700_000_000_500, - persistHandle: async () => {}, - modelCatalog: store + persistHandle: async () => {} }) await adapter.acquire({ identity: identityFor(SESSION), @@ -122,6 +121,13 @@ async function startChild( ...(options ? { options } : {}) }) await claudeStartupSettled(adapter, SESSION) + // As the host does with every live options read: what the child listed becomes the account's. + const { catalogListing } = await adapter.readOptions({ sessionId: SESSION, fence: 7 }) + if (catalogListing) { + catalogService(store, restingRecord({})).recordLiveListing(SESSION, catalogListing) + // The configured default lands after the workspace check. + await new Promise((resolve) => setTimeout(resolve, 0)) + } return adapter } @@ -140,14 +146,21 @@ function catalogDefault(store: AgentModelCatalogStore, model: string): string | return entry?.models.find((row) => row.id === model)?.defaultEffort } -/** The options a client reads for the chat once its child is gone. */ -function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { - const modelCatalog = createAgentModelCatalogService({ +function catalogService(store: AgentModelCatalogStore, record: AgentSessionRecord) { + return createAgentModelCatalogService({ store, getRecord: () => record, drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }) + resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }), + // The chat ran in a workspace with no Claude settings of its own: its config is the account's. + recordWorkspacePath: async () => '/work/repo', + workspaceMayOverrideDefaultModel: async () => false }) +} + +/** The options a client reads for the chat once its child is gone. */ +function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { + const modelCatalog = catalogService(store, record) const resting = { child: null, params: { provider: 'claude' }, @@ -262,10 +275,15 @@ describe('Claude effort default at rest', () => { ], fastModeTierByModel: new Map() } - store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { - ...listing, - origin: 'live-session' - }) + store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + 'codex', + { + ...listing, + origin: 'live-session' + }, + 'discovery' + ) const resting = await readAtRest(store, record) const live = composeCodexSessionOptionCatalog(listing, { @@ -340,22 +358,27 @@ describe('Claude effort default at rest', () => { location: { wslDistro: null }, options: { model: 'gpt-5.5' } } as unknown as AgentSessionRecord - store.recordSuccess(agentModelCatalogFingerprintForRecord(record), 'codex', { - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - isDefault: true, - defaultEffort: 'medium', - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - } - ], - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + 'codex', + { + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + fastModeTierByModel: new Map(), + origin: 'live-session' + }, + 'discovery' + ) const result = await readAtRest(store, record) diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index 86397cc59f6..a2ede54c961 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -1,12 +1,9 @@ import { describe, expect, it } from 'vitest' -import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import { - claudeCatalogRowsOfAccount, - retireClaudeLaunchedModel -} from './claude-structured-retired-model' + AgentModelCatalogStore, + type AgentModelCatalogLiveListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { retireClaudeLaunchedModel } from './claude-structured-retired-model' import { adapterFor, claudeFrame, @@ -18,15 +15,6 @@ import { recordingJournalSink } from './claude-structured-session-test-support' -const ACCOUNT_HOME = '/accounts/claude' - -function catalogAccess(store: AgentModelCatalogStore) { - const access = claudeAcquireCatalogAccess(store, ACCOUNT_HOME) - if (!access) { - throw new Error('a store and an account home always give access') - } - return access -} const RETIRED = 'claude-retired-1' /** The rows Claude Code 2.1.280 lists for a child launched with `--model claude-bogus-9`: its own * native rows, then one for the launched id, named by that id. */ @@ -79,76 +67,64 @@ describe("the CLI's word that a launched model does not exist", () => { }) }) -describe("the account catalog a child's listing writes through", () => { - const rows = [ - { id: 'opus', label: 'Opus' }, - { id: RETIRED, label: RETIRED } - ] - - function access(cached: string[] | null) { - const store = new AgentModelCatalogStore() - const handle = catalogAccess(store) - if (cached) { - store.recordSuccess(handle.fingerprint, 'claude', { - models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), - fastModeTierByModel: new Map(), - origin: 'probe' - }) - } - return handle - } - - it('leaves out the row the launch added for its own model', () => { - expect(claudeCatalogRowsOfAccount(access(null), rows, RETIRED)).toEqual([rows[0]]) - }) - - it('keeps that row when the account already listed the model', () => { - expect(claudeCatalogRowsOfAccount(access([RETIRED]), rows, RETIRED)).toEqual(rows) - }) - - it('keeps a launched native model, which carries its own display name', () => { - expect(claudeCatalogRowsOfAccount(access(null), rows, 'opus')).toEqual(rows) - }) - - it('keeps every row for a launch that named no model', () => { - expect(claudeCatalogRowsOfAccount(access(null), rows, null)).toEqual(rows) - }) - - it('never writes a launched-only model into the account catalog at start', async () => { - const store = new AgentModelCatalogStore() - const handle = catalogAccess(store) - const claude = fakeClaude({ initModels: [NATIVE_OPUS, LAUNCHED_ROW] }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: '/work/repo', - claudeConfigDir: ACCOUNT_HOME, - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - resumesTranscript: true, - continuesChain: true - }), - onEvent: (event) => events.push(event), - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => 1_700_000_000_500, - persistHandle: async () => {}, - modelCatalog: store +describe("the account catalog facts a child's listing hands the host", () => { + async function listingOfChildLaunchedWith(model: string) { + const claude = fakeClaude({ + initModels: [NATIVE_OPUS, LAUNCHED_ROW], + routes: { list_models: () => [NATIVE_OPUS, LAUNCHED_ROW] } }) + const adapter = adapterFor(claude) await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: recordingJournalSink(), - options: { model: RETIRED } + options: { model } }) await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(store.get(handle.fingerprint)?.models.map((model) => model.id)).toEqual(['opus']) + const { catalogListing } = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) await adapter.closeAll() + return catalogListing + } + + function savedIds(cached: string[] | null, listing: AgentModelCatalogLiveListing) { + const store = new AgentModelCatalogStore() + if (cached) { + store.recordSuccess( + 'fp', + 'claude', + { + models: cached.map((id) => ({ id, label: id, isDefault: false, efforts: [] })), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + } + store.recordSuccess( + 'fp', + 'claude', + { ...listing, fastModeTierByModel: new Map(), origin: 'live-session' }, + 'live' + ) + return store.get('fp')?.models.map((model) => model.id) + } + + it('marks the row the launch added for its own model, which the account catalog leaves out', async () => { + const listing = await listingOfChildLaunchedWith(RETIRED) + expect(listing?.launchOnlyModelId).toBe(RETIRED) + expect(savedIds(null, listing!)).toEqual(['opus']) + }) + + it('keeps that row when the account already listed the model', async () => { + const listing = await listingOfChildLaunchedWith(RETIRED) + expect(savedIds([RETIRED], listing!)).toEqual(['opus', RETIRED]) + }) + + it('marks nothing for a launched native model, which carries its own display name', async () => { + const listing = await listingOfChildLaunchedWith('opus') + expect(listing?.launchOnlyModelId).toBeUndefined() + expect(savedIds(null, listing!)).toEqual(['opus', RETIRED]) }) }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 5c25ad46181..b6433d7a060 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -2,7 +2,6 @@ // retired fails the turn. The CLI says so itself: a synthetic reply whose `error` is // `model_not_found` (Claude Code 2.1.280). That reply is the evidence the saved model cannot run. -import type { AgentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { ClaudeSession, ClaudeStructuredSessionAdapterDeps @@ -71,20 +70,3 @@ export function claudeRetiredOptions( ? { retiredOptions: { model: session.launchedModel } } : {} } - -/** The rows of a listing the account's catalog keeps. A child launched with `--model X` lists X - * itself (Claude Code 2.1.280 adds a row named by the raw id, "Custom model"), whether or not X - * exists: that row is the launch talking, not the account, unless the account already listed X. - * Native rows carry a display name of their own, so a row named by its id is the tell. */ -export function claudeCatalogRowsOfAccount( - access: AgentModelCatalogSessionAccess, - rows: readonly T[], - launchedModel: string | null -): T[] { - const listedBefore = access.store - .get(access.fingerprint) - ?.models.some((model) => model.id === launchedModel) - return rows.filter( - (row) => listedBefore === true || row.id !== launchedModel || row.label !== row.id - ) -} diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 2290ba53e96..83fc04ba2fb 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -41,7 +41,6 @@ import { readClaudeTranscriptEntryUuid } from './claude-transcript-entry-uuid' import { persistClaudeTurnResumePoint } from './claude-structured-resume-point' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' import { resolveClaudeAcquisitionLaunch } from './claude-structured-acquisition-launch' -import { claudeAcquireCatalogAccess } from './claude-structured-acquire-catalog' import { bindClaudeConnectionJournalControls, createClaudeSessionJournalTranslator @@ -260,10 +259,6 @@ export async function acquireClaudeSession({ const session = publication.session liveSession = session adoptClaudeStructuredSpawnOptions(session, launch.savedOptions) - const catalogAccess = claudeAcquireCatalogAccess(deps.modelCatalog, launch.claudeConfigDir) - if (catalogAccess) { - session.catalogAccess = catalogAccess - } acquisitions.deleteIfCurrent(sessionId, attempt) await withAgentSessionCreatePhase('publish', input.recordPhase, async () => { sessions.set(sessionId, session) diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 3d0512d8b7c..413d2db4fb6 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -239,7 +239,13 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { fence: 7 }) - await expect(adapter.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ + const { catalogListing, ...options } = await adapter.readOptions({ + sessionId: 'session-1', + fence: 7 + }) + // The listing the host saves for the account: the CLI's rows, none of this session's state. + expect(catalogListing?.models.map((model) => model.id)).toEqual(['opus', 'sonnet']) + expect(options).toEqual({ models: [ { id: 'opus', diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 1718a10e447..37a98944d3c 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -1,8 +1,4 @@ -import type { - AgentSessionFastModeState, - AgentSessionFastModeSupport, - AgentSessionOptionsResult -} from '../../shared/agent-session-wire' +import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import { currentModelId, listedModels, @@ -14,7 +10,13 @@ import { } from './claude-structured-model-catalog' import type { ClaudeSession } from './claude-structured-session-state' import { structuredAgentSessionOptionModels } from '../native-chat/agent-session-wire/structured-agent-session-option-models' -import { claudeCatalogRowsOfAccount } from './claude-structured-retired-model' +import { + claudeCatalogListing, + claudeFastModeSupport, + wireClaudeModels, + type WireClaudeModel +} from './claude-structured-catalog-listing' +import type { StructuredAgentSessionLiveOptions } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' /** @@ -172,27 +174,6 @@ export function claudeModelFastModeSupport( } } -const TRANSIENT_FAST_MODE_REASONS = new Set(['network_error', 'unknown', 'pending']) -const NON_BLOCKING_FAST_MODE_REASONS = new Set(['preference', 'sdk_opt_in_required']) - -function claudeFastModeSupport( - models: readonly ListedModel[], - disabledReason: string | undefined -): AgentSessionFastModeSupport | undefined { - if (disabledReason && TRANSIENT_FAST_MODE_REASONS.has(disabledReason)) { - return undefined - } - if (disabledReason && !NON_BLOCKING_FAST_MODE_REASONS.has(disabledReason)) { - return { supported: false, reason: disabledReason } - } - if (!models.some((model) => model.supportsFastMode === true)) { - return models.length > 0 && models.every((model) => model.supportsFastMode === false) - ? { supported: false, reason: 'model-not-supported' } - : undefined - } - return { supported: true } -} - function listedModelFastModeSupport( models: readonly ListedModel[], modelId: string @@ -225,66 +206,16 @@ export function claudeCatalogAdmitsModel(models: readonly ListedModel[], modelId ) } -type WireClaudeModel = AgentSessionOptionsResult['models'][number] - -function wireClaudeModel(entry: ListedModel): WireClaudeModel { - return { - id: entry.id, - label: entry.label, - ...(entry.description ? { description: entry.description } : {}), - isDefault: entry.isDefault, - efforts: entry.efforts, - ...(entry.supportsFastMode !== undefined ? { supportsFastMode: entry.supportsFastMode } : {}) - } -} - -function wireClaudeModels(models: readonly ListedModel[]): WireClaudeModel[] { - return models.map(wireClaudeModel) -} - /** The built-in models a running child lists when the CLI gives it none; a chat at rest with no * catalog lists the same. */ export function claudeFallbackModelOptions(): WireClaudeModel[] { return wireClaudeModels(seedModels()) } -/** The listing, with what the CLI runs when no effort is sent on each model the child applies — - * a default only a running child knows, and only while this session has no effort pick. */ -function catalogClaudeModels(session: ClaudeSession, discovered: ListedModel[]): WireClaudeModel[] { - const applied = session.options.has('effort') ? undefined : session.appliedOptions - return discovered.map((listed) => { - const model = wireClaudeModel(listed) - const effort = applied?.effort - const runsApplied = - applied?.model !== undefined && - (listed.id === applied.model || listed.resolvedModel === applied.model) - return effort && runsApplied && model.efforts.some((choice) => choice.value === effort) - ? { ...model, defaultEffort: effort } - : model - }) -} - -/** Write a provider-listed catalog through to the host store. Account-level - * facts only: this session's disabled reason and its unlisted current model - * stay out, so another surface never inherits session state as a catalog. */ -function writeClaudeCatalogThrough(session: ClaudeSession, discovered: ListedModel[]): void { - if (discovered.length === 0 || !session.catalogAccess) { - return - } - const rows = claudeCatalogRowsOfAccount(session.catalogAccess, discovered, session.launchedModel) - const support = claudeFastModeSupport(rows, undefined) - session.catalogAccess.store.recordSuccess(session.catalogAccess.fingerprint, 'claude', { - models: catalogClaudeModels(session, rows), - ...(support ? { fastModeSupport: support } : {}), - fastModeTierByModel: new Map(), - origin: 'live-session' - }) -} - export async function readClaudeStructuredSessionOptions( session: ClaudeSession, timeoutMs: number | undefined -): Promise { +): Promise { const readMutationSequence = session.optionMutationSequence // Before startup both requests would wait on initialize; answer from the saved options. const [catalog, settings] = @@ -332,9 +263,9 @@ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, readMutationSequence = session.optionMutationSequence -): AgentSessionOptionsResult { +): StructuredAgentSessionLiveOptions { const discovered = listedModels(catalog ? { models: catalog } : null) - writeClaudeCatalogThrough(session, discovered) + const catalogListing = claudeCatalogListing(session, discovered) const listed = discovered.length > 0 ? discovered : seedModels() const current = readClaudeCurrentModel(session) const model = currentModelId(listed, current.id) @@ -384,6 +315,7 @@ export function claudeStructuredSessionOptionsFrom( ...(fastMode !== undefined ? { fastMode } : {}), ...(session.fastModeState ? { fastModeState: session.fastModeState } : {}), ...(confirmed.length > 0 ? { confirmed } : {}) - } + }, + ...(catalogListing ? { catalogListing } : {}) } } diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 0c01bcd51f0..7eece9a4ed6 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -120,6 +120,8 @@ describe('Claude structured session publishes before the CLI answers initialize' // What the child was launched with, carried so the host never asks the CLI again. reportedOptions: expect.objectContaining({ model: 'opus' }), restoreSkippedOptions: [], + // The account listing initialize answered, for the host's catalog. + catalogListing: { models: [expect.objectContaining({ id: 'claude-sonnet' })] }, // No host revision handed in: nothing has moved it. optionRevision: 0 }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index a40d22c25c0..024746715d7 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -46,7 +46,11 @@ import { export type StructuredAgentSessionStartedOptions = Pick< StructuredAgentSessionStartedEvent, - 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' | 'optionRevision' + | 'reportedOptions' + | 'restoreSkippedOptions' + | 'retiredOptions' + | 'optionRevision' + | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ @@ -170,19 +174,25 @@ function applyClaudeSettingsFacts( session.events?.publish() } -/** What the start persists as the session's options. The applied effort is display-only: saved, - * it would pin an effort nobody chose on every reopen, past a later settings change. */ +/** What the start persists as the session's options, and the account listing it read for the + * host's catalog. The applied effort is display-only: saved, it would pin an effort nobody chose + * on every reopen, past a later settings change. */ function claudeStartedReportedOptions( session: ClaudeSession, catalog: unknown[], readMutationSequence = session.optionMutationSequence -): StructuredAgentSessionStartedOptions['reportedOptions'] { - const { current } = claudeStructuredSessionOptionsFrom(session, catalog, readMutationSequence) +): Pick { + const { current, catalogListing } = claudeStructuredSessionOptionsFrom( + session, + catalog, + readMutationSequence + ) + const listing = catalogListing ? { catalogListing } : {} if (session.options.has('effort') || session.reportedOptions.effort !== undefined) { - return current + return { reportedOptions: current, ...listing } } const { effort: _displayOnly, ...persisted } = current - return persisted + return { reportedOptions: persisted, ...listing } } /** Applies the initialize answer and reports `started`, so the host hands the child what it holds; @@ -229,7 +239,7 @@ export async function settleClaudeSessionStartup(input: { // `list_models` is answered from this same initialize result, so nothing is re-read. A // saved Fast the launch left out is decided once settings are read, so this read settles // none of it (an older sequence never rewrites an option). - reportedOptions: claudeStartedReportedOptions( + ...claudeStartedReportedOptions( session, readClaudeModels(initialized.initialization), session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined @@ -288,7 +298,9 @@ async function settleClaudeStartupSettings( const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', - reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)), + // `started` already carried this listing to the host. + reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) + .reportedOptions, restoreSkippedOptions: [...session.restoreSkippedOptions], ...claudeRetiredOptions(session), optionRevision diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 61ad3a64551..b1ccc81b9d5 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -20,10 +20,6 @@ import type { ClaudeJournalTranslator } from './claude-journal-translator-contra import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import { randomUUID } from 'node:crypto' -import type { - AgentModelCatalogSessionAccess, - AgentModelCatalogStore -} from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { AgentSessionFastModeState } from '../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' @@ -133,8 +129,6 @@ export type ClaudeStructuredSessionAdapterDeps = { leafUuid: string fence: number }) => Promise - /** Host model catalog; sessions write their listings through. */ - modelCatalog?: AgentModelCatalogStore } export type ClaudeDispatchWaiter = { @@ -191,8 +185,6 @@ export type ClaudeSession = { launchedModel: string | null /** The launch left the saved Fast out, so the start applies it once the settings are read. */ fastModeAtStart: boolean - /** Absent when the adapter runs without a host catalog store (tests). */ - catalogAccess?: AgentModelCatalogSessionAccess /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] backgroundTasks: ClaudeBackgroundTaskTracker diff --git a/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts b/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts new file mode 100644 index 00000000000..ac6998ad71d --- /dev/null +++ b/src/main/codex-accounts/runtime-home-catalog-account-key.test.ts @@ -0,0 +1,129 @@ +// A new Codex chat's picker reads the catalog under the account key the record-less resolver +// answers; the chat it launches saves under the key launch preparation pins. Both run for real +// here, wired as the runtime service wires them, so a drift between the two fails this test. + +import type * as NodeOs from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { createSettings } from './runtime-home-settings-test-fixtures' +import { + createCodexAccountRecord, + createCodexAuthJson, + createManagedAuth, + createStore, + getRuntimeCodexHomePath, + getSystemCodexHomePath, + setupRuntimeHomeTest, + teardownRuntimeHomeTest, + testState +} from './runtime-home-service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +// Hook install and session bridges are launch chores outside the key; keep them off disk. +vi.mock('../codex/hook-service', () => ({ + codexHookService: { prepareRuntimeHomeForLaunch: async () => ({ state: 'installed' }) } +})) +vi.mock('../codex/codex-hook-reconcile', () => ({ reconcileCodexHooksForLaunch: async () => {} })) +vi.mock('../codex/codex-session-bridge', () => ({ + startSystemCodexSessionBridgeInBackground: async () => {} +})) +vi.mock('../codex/codex-account-session-bridge', () => ({ + startCodexAccountSessionBridgeInBackground: async () => {} +})) + +/** The production wiring: the host's one runtime home service behind both resolvers. */ +async function wireCodexAccountKey(settings: ReturnType) { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { mainProcessState } = await import('../startup/main-process-state') + const { codexStructuredLaunchHomeResolvers } = await import('../startup/codex-launch-preparation') + const { structuredAgentRuntimeRegistration } = + await import('../runtime/structured-agent-runtime-registrations') + const { agentModelCatalogFingerprint } = + await import('../native-chat/agent-model-catalog/agent-model-catalog-fingerprint') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the settings read/update surface the service uses. + const runtimeHome = new CodexRuntimeHomeService(createStore(settings) as never) + mainProcessState.codexRuntimeHome = runtimeHome + const codex = structuredAgentRuntimeRegistration('codex')! + const services = { + getClaudeConfigDirectory: () => null, + // The resolvers main-process-runtime-service hands the runtime. + prepareCodexLaunchHome: codexStructuredLaunchHomeResolvers.prepareCodexStructuredLaunch, + readCodexLaunchHome: codexStructuredLaunchHomeResolvers.resolveCodexStructuredLaunchHome, + workspaceTrustSettings: () => settings + } + const resolve = (purpose: 'launch' | 'read') => + codex.resolveAccountHome( + { launchEnv: {}, location: null, purpose, workspacePath: null }, + services + ) + const key = (accountHome: AgentSessionAccountHome) => + agentModelCatalogFingerprint({ agent: 'codex', accountHome, wslDistro: null }) + return { resolve, key, release: () => (mainProcessState.codexRuntimeHome = null) } +} + +describe('Codex catalog account key: picker read vs launch', () => { + let release: (() => void) | null = null + + beforeEach(() => { + setupRuntimeHomeTest() + }) + + afterEach(() => { + release?.() + release = null + teardownRuntimeHomeTest() + }) + + async function expectOneKey(settings: ReturnType, expectedPath: string) { + const wired = await wireCodexAccountKey(settings) + release = wired.release + const before = await wired.resolve('read') + const launched = await wired.resolve('launch') + // Launch preparation syncs homes; the next picker read must still land on the same key. + const after = await wired.resolve('read') + + expect(launched).toMatchObject({ variable: 'CODEX_HOME', path: expectedPath }) + expect(wired.key(before)).toBe(wired.key(launched)) + expect(wired.key(after)).toBe(wired.key(launched)) + } + + it('system account on the real ~/.codex', async () => { + await expectOneKey(createSettings({ realHomeRoutable: true }), getSystemCodexHomePath()) + }) + + it("system account on Orca's shared runtime home", async () => { + await expectOneKey(createSettings({ realHomeRoutable: false }), getRuntimeCodexHomePath()) + }) + + it('a managed account in its own home', async () => { + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('user@example.com', 'acct-1', 'refresh-1') + ) + await expectOneKey( + createSettings({ + realHomeRoutable: true, + codexManagedAccounts: [ + createCodexAccountRecord('account-1', 'user@example.com', 'acct-1', managedHomePath) + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }), + managedHomePath + ) + }) +}) diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 122d95c16f5..c63873c197f 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -40,6 +40,8 @@ export type CodexAppServerInvocation = { envToDelete?: readonly string[] /** Whole-session deadline. The codex child is stopped when it lapses. */ timeoutMs: number + /** Stops the session and its child early, as the deadline would. */ + signal?: AbortSignal } /** Codex-side absence of the requested app-server RPC surface (old CLI without @@ -194,18 +196,30 @@ export async function runCodexAppServerSession( const deadlinePromise = new Promise((_resolve, reject) => { rejectDeadline = reject }) - const deadline = setTimeout(() => { + function endSession(error: Error): void { timedOut = true - const error = new CodexAppServerTimeoutError( - `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` - ) // A supervised child is stopped by the finally below, which this rejection runs at once. if (!managed.supervised) { killCodexAppServerProcessTree(child) } failPending(error) rejectDeadline(error) - }, invocation.timeoutMs) + } + const deadline = setTimeout( + () => + endSession( + new CodexAppServerTimeoutError( + `codex app-server session exceeded ${invocation.timeoutMs}ms (${invocation.command})` + ) + ), + invocation.timeoutMs + ) + const onAbort = (): void => endSession(new Error('codex app-server session stopped')) + if (invocation.signal?.aborted) { + onAbort() + } else { + invocation.signal?.addEventListener('abort', onAbort, { once: true }) + } function sendLine(payload: Record): void { child.stdin.write(`${JSON.stringify(payload)}\n`) @@ -333,5 +347,6 @@ export async function runCodexAppServerSession( // Ends stdin, gives a supervisor its full stop time (a direct child 1.5 s), then the tree. await managed.close() clearTimeout(deadline) + invocation.signal?.removeEventListener('abort', onAbort) } } diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index 58bb8fd27d2..0e69991f75b 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -47,7 +47,7 @@ describe('codex model catalog probe', () => { }) } }) - const success = await probe('/homes/account-a') + const success = await probe({ variable: 'CODEX_HOME', path: '/homes/account-a' }) expect(success.origin).toBe('probe') expect(success.models.map((model) => model.id)).toEqual(['gpt-live']) // The session launch resolves the exact same invocation for the same deps. @@ -97,7 +97,7 @@ describe('codex model catalog probe', () => { body ) }) - const success = await probe('/homes/a') + const success = await probe({ variable: 'CODEX_HOME', path: '/homes/a' }) expect(success.models.map((model) => ({ id: model.id, isDefault: model.isDefault }))).toEqual([ { id: 'gpt-live', isDefault: true } ]) @@ -110,7 +110,9 @@ describe('codex model catalog probe', () => { runSession: async (_invocation, body) => body({ request: async () => ({ data: [], nextCursor: null }), notify: () => {} }) }) - await expect(probe('/homes/a')).rejects.toThrow(/listed no models/) + await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toThrow( + /listed no models/ + ) }) it.each(['npx codex', 'codex --profile work', '/missing/codex', './codex'])( @@ -127,7 +129,9 @@ describe('codex model catalog probe', () => { ), runSession }) - await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) + await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toMatchObject({ + reason: 'agentCommandNotRunnable' + }) expect(runSession).not.toHaveBeenCalled() } ) @@ -168,7 +172,9 @@ describe('Codex catalog availability', () => { new Error('unsupported method'), new Error('timeout') ])('unknown or authenticated account does not block: %j', async (account) => { - expect((await withAccount(account)('/homes/a')).models).toHaveLength(1) + expect( + (await withAccount(account)({ variable: 'CODEX_HOME', path: '/homes/a' })).models + ).toHaveLength(1) }) it.each([ ['/homes/a', 'managed'], @@ -177,7 +183,12 @@ describe('Codex catalog availability', () => { 'reports explicit signed-out account for %s beside its list', async (home, account) => { // The verdict can be wrong while a chat works, so the picker keeps the list it got. - expect(await withAccount({ account: null, requiresOpenaiAuth: true })(home)).toMatchObject({ + expect( + await withAccount({ account: null, requiresOpenaiAuth: true })({ + variable: 'CODEX_HOME', + path: home + }) + ).toMatchObject({ models: [{ id: 'gpt-live' }], unavailable: { reason: 'notSignedIn', account } }) @@ -196,7 +207,9 @@ describe('Codex catalog availability', () => { notify: () => {} }) }) - expect((await probe('/custom/home')).unavailable).toEqual({ reason: 'notSignedIn' }) + expect((await probe({ variable: 'CODEX_HOME', path: '/custom/home' })).unavailable).toEqual({ + reason: 'notSignedIn' + }) }) it('lists models while the account check is still answering', async () => { let answerAccount!: (value: unknown) => void @@ -216,7 +229,7 @@ describe('Codex catalog availability', () => { notify: () => {} }) }) - const pending = probe('/homes/a') + const pending = probe({ variable: 'CODEX_HOME', path: '/homes/a' }) await vi.waitFor(() => expect(methods).toContain('model/list')) expect(methods).toContain('account/read') answerAccount({ account: { type: 'chatgpt' }, requiresOpenaiAuth: true }) @@ -237,7 +250,7 @@ describe('Codex catalog availability', () => { notify: () => {} }) }) - await expect(probe('/homes/a')).rejects.toMatchObject({ + await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toMatchObject({ unavailable: { reason: 'notSignedIn' } }) }) @@ -249,7 +262,9 @@ describe('Codex catalog availability', () => { throw Object.assign(new Error('spawn failed'), { code, path: 'codex' }) } }) - const failure = await probe('/homes/a').catch((error: unknown) => error) + const failure = await probe({ variable: 'CODEX_HOME', path: '/homes/a' }).catch( + (error: unknown) => error + ) expect(failure instanceof AgentModelCatalogUnavailableError).toBe(code === 'ENOENT') }) }) @@ -272,7 +287,7 @@ describe('Codex catalog probe reads the home a launch would', () => { }) } }) - expect((await probe('/homes/mirror')).models).toHaveLength(1) + expect((await probe({ variable: 'CODEX_HOME', path: '/homes/mirror' })).models).toHaveLength(1) expect(order).toEqual(['prepare /homes/mirror', 'spawn']) }) }) diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 6f065d184f9..5dc42007a93 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -8,15 +8,13 @@ import { resolveCodexStructuredInvocation, type CodexStructuredLaunchResolverDeps } from './codex-structured-launch-resolution' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' import type { AgentModelCatalogProbe, AgentModelCatalogSuccess } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -// Why 15s: the whole probe session is stopped at the deadline (on POSIX its supervisor then gets -// up to PROVIDER_SUPERVISOR_MAX_STOP_MS), and a cold `model/list` may pay one /models fetch. -const CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000 - export type CodexModelCatalogProbeDeps = Pick< CodexStructuredLaunchResolverDeps, 'resolveCommand' | 'resolveEnvironment' @@ -39,15 +37,17 @@ function definedEnv(env: NodeJS.ProcessEnv | undefined): Record } /** - * Lists models without a live session: one short-lived read-only app-server - * under the given account home, spawned through the SAME invocation resolver - * a structured session launch uses — a probe that resolved a different binary - * or env could list models the user's sessions cannot see, under their key. + * Lists models without a live session: one short-lived read-only app-server under the given + * account home and the shared probe budget, spawned through the SAME invocation resolver a + * structured session launch uses — a probe that resolved a different binary or env could list + * models the user's sessions cannot see, under their key. Its supervised session runner is the + * one every Codex app-server RPC consumer shares. */ export function createCodexModelCatalogProbe( deps: CodexModelCatalogProbeDeps ): AgentModelCatalogProbe { - return async (accountHomePath: string): Promise => { + return async (accountHome, options): Promise => { + const accountHomePath = requireLegacyAgentSessionAccountHome(accountHome).path const { command, environment } = await resolveCodexStructuredInvocation(deps) deps.prepareHome?.(accountHomePath) const run = deps.runSession ?? runCodexAppServerSession @@ -57,7 +57,8 @@ export function createCodexModelCatalogProbe( args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], cliPath: command, env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, - timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS + timeoutMs: AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS, + ...(options?.signal ? { signal: options.signal } : {}) }, async (rpc) => { // Both at once, so the account check adds no latency. diff --git a/src/main/codex/codex-short-lived-app-server-spawn.test.ts b/src/main/codex/codex-short-lived-app-server-spawn.test.ts index fdd3733225d..c69f8c6f5ab 100644 --- a/src/main/codex/codex-short-lived-app-server-spawn.test.ts +++ b/src/main/codex/codex-short-lived-app-server-spawn.test.ts @@ -56,7 +56,7 @@ describe('short-lived Codex app-server invocations on Windows', () => { }) } }) - await probe('C:\\homes\\a') + await probe({ variable: 'CODEX_HOME', path: 'C:\\homes\\a' }) expect(invocations[0]?.command).toBe(NPM_CODEX_SHIM) expect(invocations[0]?.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) }) diff --git a/src/main/codex/codex-structured-background-catalog.ts b/src/main/codex/codex-structured-background-catalog.ts index ed4eda7aa11..5e6dc2f62c3 100644 --- a/src/main/codex/codex-structured-background-catalog.ts +++ b/src/main/codex/codex-structured-background-catalog.ts @@ -72,11 +72,17 @@ async function refresh(input: BackgroundCatalogInput): Promise { return } if (fingerprint && store) { - store.recordSuccess(fingerprint, 'codex', { - models: listing.models, - fastModeTierByModel: listing.fastModeTierByModel, - origin: 'live-session' - }) + // `model/list` is Codex's account-level listing, the one its probe also runs. + store.recordSuccess( + fingerprint, + 'codex', + { + models: listing.models, + fastModeTierByModel: listing.fastModeTierByModel, + origin: 'live-session' + }, + 'discovery' + ) } applyListing(session, listing) } catch (error) { diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 4182014d14a..4480df25050 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -78,11 +78,16 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { CODEX_STRUCTURED_AGENT, '/codex/home' )! - modelCatalog.recordSuccess(access.fingerprint, 'codex', { - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - fastModeTierByModel: new Map([['gpt-live', 'priority-live-v2']]), - origin: 'live-session' - }) + modelCatalog.recordSuccess( + access.fingerprint, + 'codex', + { + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + fastModeTierByModel: new Map([['gpt-live', 'priority-live-v2']]), + origin: 'live-session' + }, + 'discovery' + ) const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) await acquire(adapter) await send(adapter, 'first') @@ -343,11 +348,16 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { await vi.waitFor(() => expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) ) - modelCatalog.recordSuccess(access.fingerprint, 'codex', { - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - fastModeTierByModel: new Map([['gpt-live', 'priority-probe']]), - origin: 'probe' - }) + modelCatalog.recordSuccess( + access.fingerprint, + 'codex', + { + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + fastModeTierByModel: new Map([['gpt-live', 'priority-probe']]), + origin: 'probe' + }, + 'discovery' + ) pending.reject(new Error('own listing failed')) await vi.waitFor(() => expect( @@ -370,20 +380,31 @@ describe('Codex structured Fast mode without send-path catalog waits', () => { '/codex/home' )! const medium = [{ value: 'medium', label: 'Medium' }] - modelCatalog.recordSuccess(access.fingerprint, 'codex', { - models: [ - { - id: 'gpt-live', - label: 'Live', - isDefault: true, - efforts: medium, - supportsFastMode: false - }, - { id: 'gpt-next', label: 'Next', isDefault: false, efforts: medium, supportsFastMode: true } - ], - fastModeTierByModel: new Map([['gpt-next', 'priority-next']]), - origin: 'live-session' - }) + modelCatalog.recordSuccess( + access.fingerprint, + 'codex', + { + models: [ + { + id: 'gpt-live', + label: 'Live', + isDefault: true, + efforts: medium, + supportsFastMode: false + }, + { + id: 'gpt-next', + label: 'Next', + isDefault: false, + efforts: medium, + supportsFastMode: true + } + ], + fastModeTierByModel: new Map([['gpt-next', 'priority-next']]), + origin: 'live-session' + }, + 'discovery' + ) const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID }, [], { modelCatalog }) diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index f5b29ac51ba..b9ee653b18c 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -11,6 +11,7 @@ import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { + AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AGENT_MODEL_CATALOG_FRESH_MS, AgentModelCatalogStore, type AgentModelCatalogProbe @@ -71,17 +72,22 @@ function storeSession( } function seedEntry(store: AgentModelCatalogStore, ...ids: string[]): void { - store.recordSuccess(FINGERPRINT, 'codex', { - models: ids.map((id, index) => ({ - id, - label: id.toUpperCase(), - isDefault: index === 0, - efforts: [{ value: 'high', label: 'High' }], - defaultEffort: 'high' - })), - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + store.recordSuccess( + FINGERPRINT, + 'codex', + { + models: ids.map((id, index) => ({ + id, + label: id.toUpperCase(), + isDefault: index === 0, + efforts: [{ value: 'high', label: 'High' }], + defaultEffort: 'high' + })), + fastModeTierByModel: new Map(), + origin: 'live-session' + }, + 'discovery' + ) } describe('Codex session options through the host catalog store', () => { @@ -90,7 +96,9 @@ describe('Codex session options through the host catalog store', () => { const signedOut: AgentModelCatalogProbe = async () => { throw new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' }) } - await store.refresh(FINGERPRINT, 'codex', signedOut, () => signedOut('/homes/a')) + await store.refresh(FINGERPRINT, 'codex', signedOut, () => + signedOut({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) const request = vi.fn(async () => listAnswer('gpt-live')) const result = await readLiveCodexSessionOptions(storeSession(request, store), undefined) expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) @@ -100,6 +108,27 @@ describe('Codex session options through the host catalog store', () => { }) }) + it('never re-lists for an aged signed-out verdict beside a fresh entry', async () => { + let at = 1_000 + const store = new AgentModelCatalogStore({ now: () => at }) + seedEntry(store, 'gpt-live') + const signedOut: AgentModelCatalogProbe = async () => { + throw new AgentModelCatalogUnavailableError({ reason: 'notSignedIn', account: 'system' }) + } + await store.refresh(FINGERPRINT, 'codex', signedOut, () => + signedOut({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) + at += AGENT_MODEL_CATALOG_FAILURE_TTL_MS + const request = vi.fn(async () => listAnswer('gpt-live')) + const session = storeSession(request, store) + // Only a probe re-derives the verdict, so the chat's own listing would repeat on every read. + await readLiveCodexSessionOptions(session, undefined) + await readLiveCodexSessionOptions(session, undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(modelListCalls(request)).toBe(0) + expect(store.probeDue(FINGERPRINT)).toBe(true) + }) + it('lists once at the first read and serves every later read from the store', async () => { const store = new AgentModelCatalogStore() const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-next')) @@ -124,7 +153,9 @@ describe('Codex session options through the host catalog store', () => { const store = new AgentModelCatalogStore() // Opening the chat's picker kicked the host probe for this account; its Codex never answers. const hungProbe: AgentModelCatalogProbe = () => new Promise(() => {}) - void store.refresh(FINGERPRINT, 'codex', hungProbe, () => hungProbe('/homes/a')) + void store.refresh(FINGERPRINT, 'codex', hungProbe, () => + hungProbe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) const request = vi.fn(async () => listAnswer('gpt-live')) const session = storeSession(request, store) // The acquire-time restore read: joining the probe would fail the chat at the probe's deadline. diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts index a80d509d0f7..487d8cb854c 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-account-expiry.ts @@ -26,3 +26,59 @@ export function expireAgentModelCatalogFailuresForSettings( store.expireFailures(agent) } } + +// The settings that pick the binary and environment an agent's probe and chats launch with. +type AgentLaunchSettings = Partial> + +function launchKeys(settings: AgentLaunchSettings): Map { + const commands = new Map( + Object.entries(settings.agentCmdOverrides ?? {}) + ) + const envs = new Map | undefined>( + Object.entries(settings.agentDefaultEnv ?? {}) + ) + const keys = new Map() + for (const agent of new Set([...commands.keys(), ...envs.keys()])) { + const env = Object.entries(envs.get(agent) ?? {}).sort(([a], [b]) => a.localeCompare(b)) + keys.set(agent, JSON.stringify([commands.get(agent) ?? null, env])) + } + return keys +} + +/** Agents whose command or launch env differs between the two settings. */ +export function agentsWithChangedLaunchSettings( + previous: AgentLaunchSettings, + next: AgentLaunchSettings +): string[] { + const before = launchKeys(previous) + const after = launchKeys(next) + return [...new Set([...before.keys(), ...after.keys()])].filter( + (agent) => before.get(agent) !== after.get(agent) + ) +} + +/** A settings listener that expires the catalogs a change made stale: an account change expires + * the agent's held reasons; a command or env change also marks its catalogs due for a listing. */ +export function createAgentModelCatalogSettingsExpiry( + store: Pick, + initial: AgentLaunchSettings +): (updates: Partial, settings: AgentLaunchSettings) => void { + let launch: AgentLaunchSettings = { + agentCmdOverrides: initial.agentCmdOverrides, + agentDefaultEnv: initial.agentDefaultEnv + } + return (updates, settings) => { + expireAgentModelCatalogFailuresForSettings(store, updates) + if (!('agentCmdOverrides' in updates) && !('agentDefaultEnv' in updates)) { + return + } + const next = { + agentCmdOverrides: settings.agentCmdOverrides, + agentDefaultEnv: settings.agentDefaultEnv + } + for (const agent of agentsWithChangedLaunchSettings(launch, next)) { + store.expireAgent(agent) + } + launch = next + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts new file mode 100644 index 00000000000..e47c2210048 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery-freshness.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../../shared/agent-session-wire' +import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' + +// A live session lists every turn; the account-level discovery is the only source of the configured +// default. These pin that the first can never starve, outrun or erase the second. + +const HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/homes/grok' } + +const RECORD = { + sessionId: 'grok-session', + provider: 'grok', + accountHome: HOME, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } +} as const satisfies Pick + +const EFFORTS = [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } +] + +function discovery(defaultModel: string): AgentModelCatalogSuccess { + return { + models: ['grok-4', 'grok-3-mini'].map((id) => ({ + id, + label: id, + isDefault: id === defaultModel, + efforts: EFFORTS, + ...(id === defaultModel ? { defaultEffort: 'high' } : {}) + })), + fastModeTierByModel: new Map(), + origin: 'probe' + } +} + +/** What a running Grok chat reports: no default, and the model it switched to. */ +const LIVE: AgentSessionModelOption[] = [ + { id: 'grok-4', label: 'grok-4', isDefault: false, efforts: EFFORTS }, + { id: 'grok-3-mini', label: 'grok-3-mini', isDefault: false, efforts: EFFORTS }, + { id: 'grok-code', label: 'grok-code', isDefault: false, efforts: [] } +] + +function harness(probe: () => Promise) { + let now = 1_000 + const store = new AgentModelCatalogStore({ now: () => now }) + const probes = vi.fn(probe) + const catalog = createAgentModelCatalogService({ + store, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location. + getRecord: () => RECORD as unknown as AgentSessionRecord, + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { grok: probes }, + listingNamesConfiguredModel: new Set(['grok']) + }) + const read = async () => { + const result = await catalog.read({ agent: 'grok', waitForListing: true }) + return result.origin === 'unknown' ? null : result + } + const defaultOf = async () => (await read())?.models.find((model) => model.isDefault)?.id + return { + store, + catalog, + probes, + read, + defaultOf, + advance: (ms: number) => (now += ms), + saveLive: () => catalog.recordLiveListing(RECORD.sessionId, { models: LIVE }) + } +} + +describe('a live listing beside the account-level discovery', () => { + it('merges a discovery that resolves after a live save instead of dropping it', async () => { + let settle!: (success: AgentModelCatalogSuccess) => void + const run = harness(() => new Promise((resolve) => (settle = resolve))) + // The first read starts the account's discovery; a chat lists while it is still running. + expect(await run.catalog.read({ agent: 'grok' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + run.saveLive() + settle(discovery('grok-4')) + await vi.waitFor(() => + expect(run.store.get(agentModelCatalogFingerprintForRecord(RECORD))?.discovered).not.toBe( + null + ) + ) + + const read = await run.read() + expect(read?.listingNamesConfiguredModel).toBe(true) + expect(await run.defaultOf()).toBe('grok-4') + // The session's newer membership stays, with the default effort discovery named. + expect(read?.models.map((model) => model.id)).toEqual(['grok-4', 'grok-3-mini', 'grok-code']) + expect(read?.models[0]?.defaultEffort).toBe('high') + }) + + it('names the default on a cold account whose first listing came from a live chat', async () => { + const run = harness(async () => discovery('grok-4')) + run.saveLive() + // A live listing alone names no default and does not count as fresh, so a read lists the account. + await run.catalog.read({ agent: 'grok' }) + expect(run.probes).toHaveBeenCalledTimes(1) + await vi.waitFor(async () => expect(await run.defaultOf()).toBe('grok-4')) + }) + + it('still refreshes the account listing after ten minutes of live saves', async () => { + let configured = 'grok-4' + const run = harness(async () => discovery(configured)) + expect(await run.defaultOf()).toBe('grok-4') + expect(run.probes).toHaveBeenCalledTimes(1) + + // The user changes the account's default while chats keep listing every minute. + configured = 'grok-3-mini' + for (let minute = 0; minute < 11; minute++) { + run.advance(60_000) + run.saveLive() + } + expect(run.store.shouldRefresh(agentModelCatalogFingerprintForRecord(RECORD))).toBe(true) + await run.catalog.read({ agent: 'grok' }) + expect(run.probes).toHaveBeenCalledTimes(2) + await vi.waitFor(async () => expect(await run.defaultOf()).toBe('grok-3-mini')) + }) + + it('does not refresh while the discovery is younger than its freshness window', async () => { + const run = harness(async () => discovery('grok-4')) + await run.read() + run.advance(AGENT_MODEL_CATALOG_FRESH_MS - 1) + run.saveLive() + await run.read() + expect(run.probes).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts new file mode 100644 index 00000000000..a2c2ff6b8e4 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-discovery.ts @@ -0,0 +1,17 @@ +import type { AgentModelCatalogProbe } from './agent-model-catalog-store' + +/** + * What every agent registration must say about its models: a probe that lists them without + * starting a session, or that it has none. Refresh, keying, persistence and publication are the + * catalog service's, never the agent's. An agent with no probe shows the provider-default + * placeholder until a live session reports. + */ +export type AgentModelCatalogDiscovery = + | { + kind: 'probe' + probe: AgentModelCatalogProbe + /** The listing marks the model the account is configured to run as its default, so a new + * chat launches the listed default. False where the agent's own settings may pick another. */ + listingNamesConfiguredModel: boolean + } + | { kind: 'unavailable'; reason: string } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts new file mode 100644 index 00000000000..3e31cc41400 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -0,0 +1,253 @@ +import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' +import type { + AgentSessionFastModeSupport, + AgentSessionModelOption +} from '../../../shared/agent-session-wire' + +/** `discovery`: an account-level listing that names the configured default and owns freshness. + * `live`: what a running session listed — which models exist and their efforts, nothing more. */ +export type AgentModelCatalogSource = 'discovery' | 'live' + +/** One successful listing, as any lister hands it to the store. */ +export type AgentModelCatalogSuccess = { + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + fastModeTierByModel: ReadonlyMap + origin: 'live-session' | 'probe' + /** A row only this session's launch added (its own `--model`): kept only once the account's + * catalog already lists that model. */ + launchOnlyModelId?: string + /** A probe that listed models but also found no chat can start (a signed-out Codex): both kept. */ + unavailable?: AgentSessionUnavailable +} + +/** One listing as the store keeps it. */ +export type AgentModelCatalogListing = { + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + /** Provider-advertised Fast tier per model id. */ + fastModeTierByModel: Record + origin: 'live-session' | 'probe' + at: number +} + +/** What a running session listed, as its adapter hands it to the host. Kept here, apart from the + * store, so the session wire types don't pull the store's Node-only persistence into clients. */ +export type AgentModelCatalogLiveListing = { + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + /** A row only this session's launch added (its own `--model`): kept only once the account's + * catalog already lists that model. */ + launchOnlyModelId?: string + /** What the agent's own config resolution picked for a session launched with no model pick: + * the configured default for that session's config scope. Null when that resolution names no + * listed model, so a saved default is stale; absent when this session can't say. */ + configuredDefault?: AgentModelCatalogConfiguredChoice | null +} + +/** A configured model as one session resolved it. `effort` is null when the config sends none + * and absent when the session picked its own, which says nothing about the config. */ +export type AgentModelCatalogConfiguredChoice = { + modelId: string + /** Other listed rows that run the same model, and so the same effort. */ + sameModelIds?: string[] + effort?: string | null +} + +/** The account's configured default as an agent's CLI resolved it for a chat with no model pick, + * in a workspace with no config of its own; for agents whose listing names none. */ +export type AgentModelCatalogConfiguredDefault = { + modelId: string + sameModelIds?: string[] + effort?: string + at: number +} + +/** A live options answer whose model rows are the account's listing as the child reported it, + * with what its no-pick launch resolved when the session can say. */ +export function withLiveCatalogListing< + T extends Pick +>( + options: T, + configuredDefault?: AgentModelCatalogConfiguredChoice | null +): T & { catalogListing: AgentModelCatalogLiveListing } { + return { + ...options, + catalogListing: { + models: options.models, + ...(options.fastModeSupport ? { fastModeSupport: options.fastModeSupport } : {}), + ...(configuredDefault !== undefined ? { configuredDefault } : {}) + } + } +} + +export type AgentModelCatalogEntry = { + agent: string + fingerprint: string + discovered: AgentModelCatalogListing | null + live: AgentModelCatalogListing | null + configured: AgentModelCatalogConfiguredDefault | null + // The merged view every reader uses, derived from the two listings above. + models: AgentSessionModelOption[] + fastModeSupport?: AgentSessionFastModeSupport + fastModeTierByModel: Record + origin: 'live-session' | 'probe' + fetchedAt: number +} + +/** Which models exist and their menus follow the newer listing; the default model and effort are + * the CLI-resolved configured default's, else discovery's, else what a live child reported while + * its model offers it. */ +function mergedModels( + discovered: AgentModelCatalogListing | null, + live: AgentModelCatalogListing | null, + configured: AgentModelCatalogConfiguredDefault | null +): AgentSessionModelOption[] { + const liveIsNewer = live !== null && (discovered === null || live.at >= discovered.at) + const newer = liveIsNewer ? live : discovered + const older = liveIsNewer ? discovered : live + return (newer?.models ?? []).map((model) => { + const listed = discovered?.models.find((entry) => entry.id === model.id) + const reported = live?.models.find((entry) => entry.id === model.id) + const efforts = + model.efforts.length > 0 + ? model.efforts + : (older?.models.find((entry) => entry.id === model.id)?.efforts ?? []) + const runsConfigured = + configured?.modelId === model.id || configured?.sameModelIds?.includes(model.id) === true + const configuredEffort = runsConfigured ? configured?.effort : undefined + const defaultEffort = [configuredEffort, listed?.defaultEffort, reported?.defaultEffort].find( + (effort) => effort !== undefined && efforts.some((choice) => choice.value === effort) + ) + const { defaultEffort: _own, ...rest } = model + return { + ...rest, + // A session names no default; without any discovery its own flags are all there is. + isDefault: configured + ? model.id === configured.modelId + : discovered + ? listed?.isDefault === true + : model.isDefault, + efforts, + ...(defaultEffort ? { defaultEffort } : {}) + } + }) +} + +export function agentModelCatalogEntry( + agent: string, + fingerprint: string, + discovered: AgentModelCatalogListing | null, + live: AgentModelCatalogListing | null, + configured: AgentModelCatalogConfiguredDefault | null +): AgentModelCatalogEntry | null { + const newer = live && (!discovered || live.at >= discovered.at) ? live : discovered + if (!newer) { + return null + } + const older = newer === live ? discovered : live + const fastModeSupport = newer.fastModeSupport ?? older?.fastModeSupport + return { + agent, + fingerprint, + discovered, + live, + configured, + models: mergedModels(discovered, live, configured), + ...(fastModeSupport ? { fastModeSupport } : {}), + fastModeTierByModel: { + ...live?.fastModeTierByModel, + ...discovered?.fastModeTierByModel + }, + origin: newer.origin, + fetchedAt: newer.at + } +} + +/** The entry `success` makes of `previous`, replacing only the listing of its source. */ +export function agentModelCatalogEntryWithSuccess( + previous: AgentModelCatalogEntry | undefined, + identity: { agent: string; fingerprint: string }, + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource, + at: number +): AgentModelCatalogEntry | null { + const launchOnly = success.launchOnlyModelId + const models = + launchOnly !== undefined && !previous?.models.some((model) => model.id === launchOnly) + ? success.models.filter((model) => model.id !== launchOnly) + : success.models + if (models.length === 0) { + // An empty list identifies no model; it is doubt, not a catalog. + return null + } + const listing: AgentModelCatalogListing = { + models: models.map((model) => ({ ...model })), + ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), + fastModeTierByModel: Object.fromEntries(success.fastModeTierByModel.entries()), + origin: success.origin, + at + } + const { agent, fingerprint } = identity + const configured = previous?.configured ?? null + return source === 'discovery' + ? agentModelCatalogEntry(agent, fingerprint, listing, previous?.live ?? null, configured) + : agentModelCatalogEntry(agent, fingerprint, previous?.discovered ?? null, listing, configured) +} + +/** A choice as the store keeps it: one that says nothing of effort keeps the saved effort of the + * same model, since only the session's own pick hid it. */ +function configuredDefaultFacts( + choice: AgentModelCatalogConfiguredChoice | null, + previous: AgentModelCatalogConfiguredDefault | null = null +): Omit | null { + if (!choice) { + return null + } + const effort = + choice.effort === undefined + ? previous?.modelId === choice.modelId + ? previous.effort + : undefined + : (choice.effort ?? undefined) + return { + modelId: choice.modelId, + ...(choice.sameModelIds?.length ? { sameModelIds: [...choice.sameModelIds] } : {}), + ...(effort ? { effort } : {}) + } +} + +/** `entry` with `choice` as its configured default (null forgets it); null when nothing changes. */ +export function entryWithConfiguredDefault( + entry: AgentModelCatalogEntry, + choice: AgentModelCatalogConfiguredChoice | null, + at: number +): AgentModelCatalogEntry | null { + const next = configuredDefaultFacts(choice, entry.configured) + if (JSON.stringify(next) === JSON.stringify(configuredDefaultFacts(entry.configured))) { + return null + } + return agentModelCatalogEntry( + entry.agent, + entry.fingerprint, + entry.discovered, + entry.live, + next && { ...next, at } + ) +} + +/** What a saved entry says, without its clocks: an unchanged key needs no write to disk. */ +export function agentModelCatalogListingKey(entry: AgentModelCatalogEntry): string { + const facts = (listing: AgentModelCatalogListing | null): unknown => + listing && [ + listing.origin, + listing.models, + listing.fastModeSupport ?? null, + listing.fastModeTierByModel + ] + return JSON.stringify([ + facts(entry.discovered), + facts(entry.live), + configuredDefaultFacts(entry.configured) + ]) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts index 7af34934ca8..7da3c68d680 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-failures.ts @@ -7,6 +7,9 @@ import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavail export const AGENT_MODEL_CATALOG_FAILURE_TTL_MS = 30_000 +/** A probe the host stopped (it is going away): says nothing about the account, so it is not held. */ +export class AgentModelCatalogListingStoppedError extends Error {} + export type AgentModelCatalogFailure = { /** Set by the store's own refreshes; the fingerprint is a hash, so expiry by agent reads it. */ agent?: string diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts new file mode 100644 index 00000000000..f957737983e --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-learned-default.test.ts @@ -0,0 +1,168 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentSessionAccountHome, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { AcpStructuredOptions } from '../../acp/acp-structured-options' +import { agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { AgentModelCatalogStore, withLiveCatalogListing } from './agent-model-catalog-store' +import { + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel +} from './agent-project-model-override' + +// OpenCode and OMP list no default. A chat started with no pick in a workspace whose own config +// picks nothing runs the account's default, so the host names it for the next new chat there. + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-learned-default-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +const HOMES: Record = { + opencode: { kind: 'opencode', locator: { kind: 'unmanaged' } }, + omp: { variable: 'PI_CODING_AGENT_DIR', path: '/homes/omp' } +} + +const MODELS = [ + { value: 'github-copilot/gpt-6', name: 'GPT-6' }, + { value: 'github-copilot/claude-fable-5', name: 'Claude Fable 5' } +] + +function workspace(name: string, files: Record = {}): string { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') + for (const [file, text] of Object.entries(files)) { + mkdirSync(join(dir, file, '..'), { recursive: true }) + writeFileSync(join(dir, file), text) + } + return dir +} + +function record(agent: string, workspacePath: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. + return { + sessionId: `${agent}-1`, + provider: agent, + accountHome: HOMES[agent]!, + launchDirectory: workspacePath, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord +} + +/** A host whose saved list names no default, and one chat started with no pick in `workspacePath` + * that reports what it runs. */ +async function afterNoPickChat(agent: string, workspacePath: string, picked = false) { + const chat = record(agent, workspacePath) + const store = new AgentModelCatalogStore() + store.recordSuccess( + agentModelCatalogFingerprintForRecord(chat), + agent, + { + models: MODELS.map((model) => ({ + id: model.value, + label: model.name, + isDefault: false, + efforts: [] + })), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + const catalog = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === chat.sessionId ? chat : undefined), + drivesRecord: () => true, + resolveAccountHome: async () => HOMES[agent]!, + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel + }) + const options = new AcpStructuredOptions() + options.adoptSession( + { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'github-copilot/claude-fable-5', + options: MODELS + } + ] + }, + 'new' + ) + if (picked) { + options.notePick('model') + } + catalog.recordLiveListing( + chat.sessionId, + withLiveCatalogListing(options.read(), options.configuredDefault()).catalogListing + ) + return catalog +} + +function named( + answer: Awaited['read']>> +) { + return answer.origin === 'unknown' || answer.listingNamesConfiguredModel !== true + ? null + : (answer.models.find((model) => model.isDefault)?.id ?? null) +} + +describe('a default learned from a chat with no pick', () => { + it.each(['opencode', 'omp'])( + '%s: the next new chat in that workspace names the model the first one ran', + async (agent) => { + const clean = workspace('clean') + const catalog = await afterNoPickChat(agent, clean) + const answer = await catalog.read({ agent, workspacePath: clean }) + expect(named(answer)).toBe('github-copilot/claude-fable-5') + // Its own project config may still pick the model, so it is not every workspace's. + expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') + expect(named(await catalog.read({ agent, workspacePath: workspace('other') }))).toBe( + 'github-copilot/claude-fable-5' + ) + } + ) + + it.each([ + ['opencode', 'opencode.json', '{"model":"github-copilot/claude-fable-5"}'], + ['omp', '.omp/config.yml', 'modelRoles:\n default: github-copilot/claude-fable-5\n'] + ])( + '%s: a chat whose workspace config picks the model teaches nothing', + async (agent, file, text) => { + const configured = workspace('configured', { [file]: text }) + const catalog = await afterNoPickChat(agent, configured) + expect(named(await catalog.read({ agent, workspacePath: workspace('clean') }))).toBeNull() + } + ) + + it('a new chat in a workspace whose config picks the model names none', async () => { + const catalog = await afterNoPickChat('opencode', workspace('clean')) + const configured = workspace('configured', { 'opencode.json': '{"model":"x/y"}' }) + expect(named(await catalog.read({ agent: 'opencode', workspacePath: configured }))).toBeNull() + }) + + it('a chat that picked its model teaches nothing', async () => { + const clean = workspace('clean') + const catalog = await afterNoPickChat('opencode', clean, true) + expect(named(await catalog.read({ agent: 'opencode', workspacePath: clean }))).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts new file mode 100644 index 00000000000..cbbca251a5f --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-listing-waiters.ts @@ -0,0 +1,55 @@ +import type { AgentModelCatalogEntry } from './agent-model-catalog-entry' + +export const AGENT_MODEL_CATALOG_PICKER_WAIT_MS = 30_000 + +/** Pickers following a fingerprint's listing work: each settles once a catalog lands, all work + * ends, or its fixed deadline expires. */ +export class AgentModelCatalogListingWaiters { + private readonly waiters = new Map void>>() + + constructor( + private readonly read: (fingerprint: string) => AgentModelCatalogEntry | null, + private readonly listing: (fingerprint: string) => boolean + ) {} + + wait(fingerprint: string): Promise | null { + if (!this.listing(fingerprint)) { + return null + } + return new Promise((resolve) => { + const waiters = this.waiters.get(fingerprint) ?? new Set<() => void>() + let settled = false + const finish = (entry: AgentModelCatalogEntry | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(deadline) + waiters.delete(check) + if (waiters.size === 0) { + this.waiters.delete(fingerprint) + } + resolve(entry) + } + const check = (): void => { + const entry = this.read(fingerprint) + if (entry || !this.listing(fingerprint)) { + finish(entry) + } + } + const deadline = setTimeout( + () => finish(this.read(fingerprint)), + AGENT_MODEL_CATALOG_PICKER_WAIT_MS + ) + waiters.add(check) + this.waiters.set(fingerprint, waiters) + check() + }) + } + + notify(fingerprint: string): void { + for (const check of this.waiters.get(fingerprint) ?? []) { + check() + } + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts index d47e9efbec5..ca8a90a0f04 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-persistence.ts @@ -4,10 +4,14 @@ import type { AgentSessionModelOption, AgentSessionOptionChoice } from '../../../shared/agent-session-wire' -import type { AgentModelCatalogEntry } from './agent-model-catalog-store' +import { + agentModelCatalogEntry, + type AgentModelCatalogEntry, + type AgentModelCatalogListing +} from './agent-model-catalog-entry' import { isStructuredAgentId } from '../../../shared/agent-session-provider-handle-encoding' -const SCHEMA_VERSION = 1 +const SCHEMA_VERSION = 2 const SAVE_COALESCE_MS = 500 export type AgentModelCatalogPersistence = { @@ -62,16 +66,12 @@ function parseModel(value: unknown): AgentSessionModelOption | null { } } -/** Checked reconstruction rather than trust: a field a future schema drops or - * reshapes loads as "no entry", never as a corrupt catalog. */ -function parseEntry(value: unknown): AgentModelCatalogEntry | null { +function parseListing(value: unknown): AgentModelCatalogListing | null { const row = asRecord(value) if ( !row || - !isStructuredAgentId(row.agent) || - typeof row.fingerprint !== 'string' || (row.origin !== 'live-session' && row.origin !== 'probe') || - typeof row.fetchedAt !== 'number' || + typeof row.at !== 'number' || !Array.isArray(row.models) || row.models.length === 0 ) { @@ -86,11 +86,14 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { const supported = support?.supported const supportReason = text(support?.reason) return { - agent: row.agent, - fingerprint: row.fingerprint, models: models.filter((model): model is AgentSessionModelOption => model !== null), ...(typeof supported === 'boolean' - ? { fastModeSupport: { supported, ...(supportReason ? { reason: supportReason } : {}) } } + ? { + fastModeSupport: { + supported, + ...(supportReason ? { reason: supportReason } : {}) + } + } : {}), fastModeTierByModel: Object.fromEntries( Object.entries(tiers ?? {}).filter( @@ -98,7 +101,47 @@ function parseEntry(value: unknown): AgentModelCatalogEntry | null { ) ), origin: row.origin, - fetchedAt: row.fetchedAt + at: row.at + } +} + +/** Checked reconstruction rather than trust: a field a future schema drops or + * reshapes loads as "no entry", never as a corrupt catalog. */ +function parseEntry(value: unknown): AgentModelCatalogEntry | null { + const row = asRecord(value) + if (!row || !isStructuredAgentId(row.agent) || typeof row.fingerprint !== 'string') { + return null + } + const configured = asRecord(row.configured) + const configuredModelId = text(configured?.modelId) + const configuredEffort = text(configured?.effort) + const sameModelIds = Array.isArray(configured?.sameModelIds) + ? configured.sameModelIds.filter((id): id is string => typeof id === 'string') + : [] + return agentModelCatalogEntry( + row.agent, + row.fingerprint, + parseListing(row.discovered), + parseListing(row.live), + configuredModelId && typeof configured?.at === 'number' + ? { + modelId: configuredModelId, + ...(sameModelIds.length > 0 ? { sameModelIds } : {}), + ...(configuredEffort ? { effort: configuredEffort } : {}), + at: configured.at + } + : null + ) +} + +/** Only the two listings and the configured default are written; the merged view is derived again on load. */ +function persistedEntry(entry: AgentModelCatalogEntry): unknown { + return { + agent: entry.agent, + fingerprint: entry.fingerprint, + discovered: entry.discovered, + live: entry.live, + ...(entry.configured ? { configured: entry.configured } : {}) } } @@ -126,7 +169,14 @@ export function createAgentModelCatalogFilePersistence( try { await mkdir(dirname(filePath), { recursive: true }) const tmpPath = `${filePath}.tmp` - await writeFile(tmpPath, JSON.stringify({ version: SCHEMA_VERSION, entries }), 'utf8') + await writeFile( + tmpPath, + JSON.stringify({ + version: SCHEMA_VERSION, + entries: entries.map(persistedEntry) + }), + 'utf8' + ) await rename(tmpPath, filePath) } catch { // Bookkeeping only; the in-memory store stays authoritative this run. diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts new file mode 100644 index 00000000000..5c5c9bf1a05 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-prewarm.test.ts @@ -0,0 +1,303 @@ +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { describe, expect, it, vi } from 'vitest' + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentModelCatalogFingerprintForRecord, + agentModelCatalogSessionAccess +} from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' + +const spans = vi.hoisted(() => { + const started: { name: string; attributes: Record; outcome: string }[] = [] + return { started } +}) + +vi.mock('../../observability/tracer', () => ({ + startSpan: (name: string, options?: { attributes?: Record }) => { + const span = { name, attributes: { ...options?.attributes }, outcome: 'open' } + spans.started.push(span) + return { + setAttribute: (key: string, value: unknown) => { + span.attributes[key] = value + }, + end: () => { + span.outcome = 'ok' + }, + fail: () => { + span.outcome = 'failed' + } + } + } +})) +function listing(id: string): AgentModelCatalogSuccess { + return { + models: [{ id, label: id, isDefault: true, efforts: [] }], + fastModeTierByModel: new Map(), + origin: 'probe' + } +} + +const HOME = (variable: string, path: string): AgentSessionAccountHome => ({ variable, path }) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} + +describe('model catalog prewarm', () => { + it('lists every agent once for the account a new chat would pin, and joins a read meanwhile', async () => { + let now = 0 + const store = new AgentModelCatalogStore({ now: () => now }) + const codexListed = deferred() + const codex = vi.fn(async (_home: AgentSessionAccountHome) => codexListed.promise) + const grok = vi.fn(async (_home: AgentSessionAccountHome) => listing('grok-4')) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async (agent) => + agent === 'codex' ? HOME('CODEX_HOME', '/homes/a') : HOME('GROK_HOME', '/grok'), + probes: { codex, grok } + }) + + const prewarmed = service.prewarm() + await vi.waitFor(() => expect(codex).toHaveBeenCalledTimes(1)) + // A picker opening while the prewarm lists joins it instead of spawning a second listing. + expect(await service.read({ agent: 'codex' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + codexListed.resolve(listing('gpt-a')) + await prewarmed + + expect(codex).toHaveBeenCalledTimes(1) + expect(codex).toHaveBeenCalledWith(HOME('CODEX_HOME', '/homes/a'), { + signal: expect.any(AbortSignal) + }) + expect(grok).toHaveBeenCalledTimes(1) + expect((await service.read({ agent: 'codex' })).origin).toBe('probe') + expect((await service.read({ agent: 'grok' })).origin).toBe('probe') + + // Fresh: a second prewarm lists nothing; once old, it lists again. + await service.prewarm() + expect(codex).toHaveBeenCalledTimes(1) + now += AGENT_MODEL_CATALOG_FRESH_MS + await service.prewarm() + expect(codex).toHaveBeenCalledTimes(2) + expect(grok).toHaveBeenCalledTimes(2) + }) + + it('lists the new account after a switch and leaves the old account’s catalog alone', async () => { + const store = new AgentModelCatalogStore() + let selected = '/homes/a' + const probe = vi.fn(async (home: AgentSessionAccountHome) => + listing('variable' in home && home.path === '/homes/a' ? 'gpt-a' : 'gpt-b') + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async () => HOME('CODEX_HOME', selected), + probes: { codex: probe } + }) + await service.prewarm() + selected = '/homes/b' + await service.prewarm() + + expect(probe.mock.calls.map(([home]) => home)).toEqual([ + HOME('CODEX_HOME', '/homes/a'), + HOME('CODEX_HOME', '/homes/b') + ]) + const read = await service.read({ agent: 'codex' }) + expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-b']) + }) + + it('respects the failure back-off and skips an agent whose account cannot be resolved', async () => { + const store = new AgentModelCatalogStore() + const codex = vi.fn( + async (_home: AgentSessionAccountHome): Promise => { + throw new Error('spawn codex ENOENT') + } + ) + const claude = vi.fn(async (_home: AgentSessionAccountHome) => listing('opus')) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async (agent) => { + if (agent === 'claude') { + throw new Error('account home unreadable') + } + return HOME('CODEX_HOME', '/homes/a') + }, + probes: { codex, claude } + }) + spans.started.length = 0 + await service.prewarm() + await service.prewarm() + expect(codex).toHaveBeenCalledTimes(1) + expect(claude).not.toHaveBeenCalled() + // Each listing leaves its agent and outcome in the trace log; the span carries its duration. + expect(spans.started).toEqual([ + { + name: 'agentModelCatalog.discovery', + attributes: { agent: 'codex', lister: 'probe' }, + outcome: 'failed' + } + ]) + }) + + it('lists only agents used here: a saved catalog or a chat of it', async () => { + const store = new AgentModelCatalogStore() + const probeFor = (agent: string) => + vi.fn(async (_home: AgentSessionAccountHome) => listing(`${agent}-model`)) + const probes = { codex: probeFor('codex'), claude: probeFor('claude'), pi: probeFor('pi') } + // Claude has a catalog saved under another account; Codex has a chat; Pi was never used. + store.recordSuccess('claude-other-account', 'claude', listing('opus'), 'discovery') + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), + hasChatRecords: (agent) => agent === 'codex', + probes + }) + await service.prewarm() + + expect(probes.codex).toHaveBeenCalledTimes(1) + expect(probes.claude).toHaveBeenCalledTimes(1) + expect(probes.pi).not.toHaveBeenCalled() + // Its first chat still lists it on demand. + expect(await service.read({ agent: 'pi' })).toEqual({ + origin: 'unknown', + listingInProgress: true + }) + expect(probes.pi).toHaveBeenCalledTimes(1) + }) + + it('on stop, starts no queued agent, stops the running listings and lists nothing more', async () => { + const store = new AgentModelCatalogStore() + const started: string[] = [] + const signals: AbortSignal[] = [] + const probeFor = + (agent: string) => (_home: AgentSessionAccountHome, options?: { signal?: AbortSignal }) => { + started.push(agent) + const signal = options?.signal + if (signal) { + signals.push(signal) + } + return new Promise((_resolve, reject) => + signal?.addEventListener('abort', () => reject(new Error('stopped'))) + ) + } + const agents = ['codex', 'claude', 'grok', 'opencode'] + const hasChatRecords = vi.fn(() => true) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), + hasChatRecords, + probes: Object.fromEntries(agents.map((agent) => [agent, probeFor(agent)])) + }) + const prewarmed = service.prewarm() + await vi.waitFor(() => expect(started).toHaveLength(2)) + + service.stop() + await prewarmed + expect(started).toEqual(['codex', 'claude']) + expect(signals.every((signal) => signal.aborted)).toBe(true) + + // A read or another prewarm after stop spawns nothing. + expect(await service.read({ agent: 'grok' })).toEqual({ origin: 'unknown' }) + hasChatRecords.mockClear() + await service.prewarm() + expect(started).toEqual(['codex', 'claude']) + // Not even the chat records are read. + expect(hasChatRecords).not.toHaveBeenCalled() + }) + + it('runs at most two listings at once', async () => { + const store = new AgentModelCatalogStore() + let running = 0 + let peak = 0 + const gates = new Map void>() + const probeFor = (agent: string) => async (): Promise => { + running += 1 + peak = Math.max(peak, running) + await new Promise((resolve) => gates.set(agent, resolve)) + running -= 1 + return listing(`${agent}-model`) + } + const agents = ['codex', 'claude', 'grok', 'opencode', 'pi'] + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + hasChatRecords: () => true, + resolveAccountHome: async (agent) => HOME('HOME', `/homes/${agent}`), + probes: Object.fromEntries(agents.map((agent) => [agent, probeFor(agent)])) + }) + const prewarmed = service.prewarm() + for (const agent of agents) { + await vi.waitFor(() => expect(gates.has(agent)).toBe(true)) + gates.get(agent)?.() + } + await prewarmed + expect(peak).toBe(2) + }) +}) + +describe('one account, one catalog key', () => { + function record(path: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the key reads only provider, accountHome and location. + return { + sessionId: 'session-1', + provider: 'codex', + accountHome: HOME('CODEX_HOME', path), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord + } + + // The read and launch resolvers agreeing on the home is runtime-home-catalog-account-key.test.ts. + it('a session’s saves through the host and through its adapter’s handle land under its record’s key', async () => { + const store = new AgentModelCatalogStore() + const managedHome = '/orca/codex-accounts/acct-1/home' + const launched = record(managedHome) + const service = createAgentModelCatalogService({ + store, + getRecord: () => launched, + drivesRecord: () => true, + resolveAccountHome: async () => HOME('CODEX_HOME', managedHome) + }) + // What the chat saved, through the host and through the Codex adapter's own handle… + service.recordLiveListing('session-1', { models: listing('gpt-live').models }) + const adapterAccess = agentModelCatalogSessionAccess( + store, + { agent: 'codex', accountHomeVariable: 'CODEX_HOME' }, + managedHome + ) + expect(adapterAccess?.fingerprint).toBe(agentModelCatalogFingerprintForRecord(launched)) + + // …is what the next new chat reads for the same home: no cold start for this account. + const read = await service.read({ agent: 'codex' }) + expect(read.origin !== 'unknown' && read.models.map((model) => model.id)).toEqual(['gpt-live']) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts new file mode 100644 index 00000000000..744c13dafa6 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AgentModelCatalogProbeError, + runAgentModelCatalogListing, + runAgentModelCatalogSession +} from './agent-model-catalog-probe-runner' + +// Every child here is this test's own Node, never an agent CLI. +const folders: string[] = [] +afterEach(() => { + for (const folder of folders.splice(0)) { + rmSync(folder, { recursive: true, force: true }) + } +}) + +function node(script: string): { command: string; args: string[] } { + return { command: process.execPath, args: ['-e', script] } +} + +function pidFile(): string { + const folder = mkdtempSync(join(tmpdir(), 'orca-catalog-probe-')) + folders.push(folder) + return join(folder, 'pid') +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const NODE_ENV = { ELECTRON_RUN_AS_NODE: '1' } + +describe('the shared catalog probe runner', () => { + it('answers a listing’s stdout once it exits 0, with its request on stdin', async () => { + const stdout = await runAgentModelCatalogListing( + { + ...node( + "let i='';process.stdin.on('data',c=>i+=c);process.stdin.on('end',()=>process.stdout.write('got:'+i))" + ), + env: NODE_ENV, + stdin: 'list' + }, + { site: 'test-listing' } + ) + expect(stdout).toBe('got:list') + }) + + it('rejects a listing that exits non-zero, with its stderr', async () => { + await expect( + runAgentModelCatalogListing( + { ...node("process.stderr.write('not signed in');process.exit(3)"), env: NODE_ENV }, + { site: 'test-listing' } + ) + ).rejects.toMatchObject({ reason: 'exit', message: expect.stringContaining('not signed in') }) + }) + + it('stops a listing that outlives its budget before rejecting', async () => { + const file = pidFile() + const run = runAgentModelCatalogListing( + { + ...node( + `require('fs').writeFileSync(${JSON.stringify(file)}, String(process.pid));setInterval(()=>{},1000)` + ), + env: NODE_ENV + }, + { site: 'test-listing', timeoutMs: 1_000 } + ) + await expect(run).rejects.toMatchObject({ reason: 'timeout' }) + const pid = Number(readFileSync(file, 'utf8')) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 5_000 }) + }, 20_000) + + it('stops a running listing, and starts none, once its host stops it', async () => { + const file = pidFile() + const stop = new AbortController() + const run = runAgentModelCatalogListing( + { + ...node( + `require('fs').writeFileSync(${JSON.stringify(file)}, String(process.pid));setInterval(()=>{},1000)` + ), + env: NODE_ENV + }, + { site: 'test-listing', signal: stop.signal } + ) + await vi.waitFor(() => expect(readFileSync(file, 'utf8')).not.toBe(''), { timeout: 5_000 }) + stop.abort() + await expect(run).rejects.toMatchObject({ reason: 'stopped' }) + const pid = Number(readFileSync(file, 'utf8')) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 5_000 }) + + const never = pidFile() + await expect( + runAgentModelCatalogListing( + { ...node(`require('fs').writeFileSync(${JSON.stringify(never)}, 'ran')`), env: NODE_ENV }, + { site: 'test-listing', signal: stop.signal } + ) + ).rejects.toMatchObject({ reason: 'stopped' }) + expect(() => readFileSync(never, 'utf8')).toThrow() + }, 20_000) + + it('stops a listing whose output overflows the cap', async () => { + const file = pidFile() + const run = runAgentModelCatalogListing( + { + ...node( + `require('fs').writeFileSync(${JSON.stringify(file)}, String(process.pid));setInterval(()=>process.stdout.write('x'.repeat(4096)),5)` + ), + env: NODE_ENV + }, + { site: 'test-listing', maxOutputBytes: 64 * 1024 } + ) + await expect(run).rejects.toMatchObject({ reason: 'output-overflow' }) + const pid = Number(readFileSync(file, 'utf8')) + await vi.waitFor(() => expect(isAlive(pid)).toBe(false), { timeout: 5_000 }) + }, 20_000) + + it('rejects a command that cannot start, saying its executable is missing', async () => { + const failure = await runAgentModelCatalogListing( + { command: join(tmpdir(), 'orca-no-such-agent-binary'), args: [] }, + { site: 'test-listing' } + ).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(AgentModelCatalogProbeError) + expect(failure).toMatchObject({ reason: 'spawn', executableMissing: true }) + }) + + it('closes a probe connection on success, failure and timeout alike', async () => { + const closes: string[] = [] + const open = (name: string) => () => ({ + close: async () => { + closes.push(name) + } + }) + await expect( + runAgentModelCatalogSession(open('ok'), async () => 1, { label: 'a' }) + ).resolves.toBe(1) + await expect( + runAgentModelCatalogSession( + open('crash'), + async () => { + throw new Error('agent exited') + }, + { label: 'a' } + ) + ).rejects.toThrow('agent exited') + await expect( + runAgentModelCatalogSession(open('hang'), () => new Promise(() => {}), { + label: 'a', + timeoutMs: 20 + }) + ).rejects.toMatchObject({ reason: 'timeout' }) + const stop = new AbortController() + const stopped = runAgentModelCatalogSession(open('stopped'), () => new Promise(() => {}), { + label: 'a', + signal: stop.signal + }) + stop.abort() + await expect(stopped).rejects.toMatchObject({ reason: 'stopped' }) + expect(closes).toEqual(['ok', 'crash', 'hang', 'stopped']) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts new file mode 100644 index 00000000000..8adf15a1365 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-probe-runner.ts @@ -0,0 +1,194 @@ +// The one way a catalog probe runs an agent CLI: a disposable child under the provider supervisor, +// one budget for the whole listing, bounded output, and a teardown awaited on every path. A probe +// only names what to run and how to read its answer. + +import { createOutputSink } from '../../../shared/child-process/bounded-output-sink' +import type { spawnProcess } from '../../../shared/child-process/run-process' +import { spawnManagedProviderProcess } from '../../provider-process/managed-provider-process' +import type { ProviderProcessClosePolicy } from '../../provider-process/provider-process-close' +import type { ProviderProcessLaunch } from '../../provider-process/provider-process-launch' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' +import { + providerStderrForDisplay, + supervisedProviderSpawnFailure +} from '../../provider-process/provider-spawn-failure-report' + +// Why 15s: a cold listing may pay one network fetch; past that, the picker keeps what it has. +export const AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000 +export const AGENT_MODEL_CATALOG_PROBE_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +const STDERR_DETAIL_MAX_CHARS = 400 +const UNSUPERVISED_GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 + +export type AgentModelCatalogProbeFailure = + | 'timeout' + | 'output-overflow' + | 'exit' + | 'spawn' + | 'stopped' + +export class AgentModelCatalogProbeError extends Error { + constructor( + message: string, + readonly reason: AgentModelCatalogProbeFailure, + /** The listing's own executable was not found, as the spawn or its supervisor reported. */ + readonly executableMissing = false + ) { + super(message) + this.name = 'AgentModelCatalogProbeError' + } +} + +/** Nothing waits on a probe past its answer, so a close stops it at once rather than draining. */ +function probeClosePolicy(supervised: boolean): ProviderProcessClosePolicy { + return { + gracefulExitMs: supervised ? PROVIDER_SUPERVISOR_MAX_STOP_MS : UNSUPERVISED_GRACEFUL_EXIT_MS, + forcedExitMs: FORCED_EXIT_MS, + signalSupervisorOnClose: true + } +} + +export type AgentModelCatalogListingLaunch = ProviderProcessLaunch & { + /** Written then closed; a listing that reads no request gets an immediately closed stdin. */ + stdin?: string +} + +export type AgentModelCatalogRunnerOptions = { + site: string + timeoutMs?: number + maxOutputBytes?: number + /** Stops the listing and its child, as the deadline would. */ + signal?: AbortSignal + /** Test seams. */ + spawnImpl?: typeof spawnProcess + platform?: NodeJS.Platform + inheritedEnv?: NodeJS.ProcessEnv +} + +/** Runs a listing command to its exit and answers its stdout; any exit but 0, an overflow or the + * deadline rejects. The child is stopped and awaited before this settles either way. */ +export async function runAgentModelCatalogListing( + launch: AgentModelCatalogListingLaunch, + options: AgentModelCatalogRunnerOptions +): Promise { + const timeoutMs = options.timeoutMs ?? AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS + throwIfStopped(options.signal, launch.command) + const managed = spawnManagedProviderProcess(launch, { + site: options.site, + // A listing's stdin end is the end of its request, not a stop. + lifetime: 'one-shot', + policy: probeClosePolicy, + ...(options.spawnImpl ? { spawnImpl: options.spawnImpl } : {}), + ...(options.platform ? { platform: options.platform } : {}), + ...(options.inheritedEnv ? { inheritedEnv: options.inheritedEnv } : {}) + }) + const { child } = managed + const output = createOutputSink( + options.maxOutputBytes ?? AGENT_MODEL_CATALOG_PROBE_MAX_OUTPUT_BYTES + ) + const stderrDetail = (): string => + providerStderrForDisplay(managed.stderrTail()).trim().slice(0, STDERR_DETAIL_MAX_CHARS) + let timer: ReturnType | undefined + let onAbort = (): void => {} + try { + return await new Promise((resolve, reject) => { + const fail = (message: string, reason: AgentModelCatalogProbeFailure): void => + reject( + new AgentModelCatalogProbeError( + message, + reason, + reason === 'spawn' && managed.executableMissing + ) + ) + onAbort = () => fail(`${launch.command} listing stopped`, 'stopped') + options.signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout( + () => fail(`${launch.command} did not list models within ${timeoutMs}ms`, 'timeout'), + timeoutMs + ) + child.stdout.on('data', (chunk: Buffer | string) => { + output.write(chunk) + if (output.truncated()) { + fail(`${launch.command} listed more than the model catalog reads`, 'output-overflow') + } + }) + child.stdout.on('error', () => {}) + child.stdin.on('error', () => {}) + child.on('error', (error) => { + if (child.pid === undefined) { + fail(`${launch.command} could not start: ${error.message}`, 'spawn') + } + }) + // `close`, not `exit`: only then has every byte of stdout arrived. + child.on('close', (code: number | null) => { + const spawnFailure = managed.supervised + ? supervisedProviderSpawnFailure(code, managed.stderrTail()) + : null + if (spawnFailure) { + fail(`${launch.command} could not start: ${spawnFailure.error.message}`, 'spawn') + } else if (code === 0) { + resolve(output.text()) + } else { + const detail = stderrDetail() + fail( + `${launch.command} exited ${code ?? 'by signal'}${detail ? `: ${detail}` : ''}`, + 'exit' + ) + } + }) + try { + child.stdin.end(launch.stdin ?? '') + } catch { + // A child that is already gone reports through `close`. + } + }) + } finally { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + await managed.close() + } +} + +function throwIfStopped(signal: AbortSignal | undefined, label: string): void { + if (signal?.aborted) { + throw new AgentModelCatalogProbeError(`${label} listing stopped`, 'stopped') + } +} + +/** A probe that holds a protocol connection rather than reading one command's output. */ +export type AgentModelCatalogProbeConnection = { close(): Promise } + +/** Opens a disposable connection, runs `body` under the one probe budget, and always closes it. */ +export async function runAgentModelCatalogSession( + open: () => C, + body: (connection: C) => Promise, + options: { label: string; timeoutMs?: number; signal?: AbortSignal } +): Promise { + const timeoutMs = options.timeoutMs ?? AGENT_MODEL_CATALOG_PROBE_TIMEOUT_MS + throwIfStopped(options.signal, options.label) + const connection = open() + let timer: ReturnType | undefined + let onAbort = (): void => {} + const deadline = new Promise((_resolve, reject) => { + onAbort = () => + reject(new AgentModelCatalogProbeError(`${options.label} listing stopped`, 'stopped')) + options.signal?.addEventListener('abort', onAbort, { once: true }) + timer = setTimeout( + () => + reject( + new AgentModelCatalogProbeError( + `${options.label} did not list models within ${timeoutMs}ms`, + 'timeout' + ) + ), + timeoutMs + ) + }) + try { + return await Promise.race([body(connection), deadline]) + } finally { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + await connection.close().catch(() => undefined) + } +} diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts new file mode 100644 index 00000000000..2262edfaf85 --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-saved-only-read.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from './agent-model-catalog-service' +import { + AGENT_MODEL_CATALOG_FAILURE_TTL_MS, + AGENT_MODEL_CATALOG_FRESH_MS, + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from './agent-model-catalog-store' +import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable' +import { agentReadsProjectModelConfig } from './agent-project-model-override' + +// A client preloads every agent's saved list through `savedOnly`: the answer comes from what the +// host saved, and no listing, probe or re-check of a held reason starts. + +const HOME = { variable: 'GROK_HOME', path: '/homes/grok' } as const +const FINGERPRINT = agentModelCatalogFingerprint({ + agent: 'grok', + accountHome: HOME, + wslDistro: null +}) +const SIGNED_OUT = { reason: 'notSignedIn', account: 'system' } as const + +function listing(id: string): AgentModelCatalogSuccess { + return { + models: [{ id, label: id, isDefault: true, efforts: [] }], + fastModeTierByModel: new Map(), + origin: 'probe' + } +} + +function rig(agent = 'grok') { + let now = 1_000 + const store = new AgentModelCatalogStore({ now: () => now }) + const probe = vi.fn( + (_home: AgentSessionAccountHome) => new Promise(() => {}) + ) + const service = createAgentModelCatalogService({ + store, + getRecord: () => undefined, + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { [agent]: probe }, + listingNamesConfiguredModel: new Set(['grok']), + agentReadsProjectModelConfig + }) + return { store, probe, service, advance: (ms: number) => (now += ms) } +} + +describe('a saved-only catalog read', () => { + it('answers from the saved entry and starts nothing, however old the entry is', async () => { + const { store, probe, service, advance } = rig() + store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') + advance(AGENT_MODEL_CATALOG_FRESH_MS) + const result = await service.read({ agent: 'grok', savedOnly: true }) + expect(result).toMatchObject({ origin: 'probe', models: [{ id: 'grok-4.7' }] }) + expect(result).not.toHaveProperty('listingInProgress') + expect(store.pendingListing(FINGERPRINT)).toBeNull() + expect(probe).not.toHaveBeenCalled() + // The same read without `savedOnly` refreshes the aged entry: the probe is what the flag skips. + await service.read({ agent: 'grok' }) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('answers unknown with nothing saved, and starts no listing for it', async () => { + const { store, probe, service } = rig() + expect(await service.read({ agent: 'grok', savedOnly: true })).toEqual({ origin: 'unknown' }) + expect(store.pendingListing(FINGERPRINT)).toBeNull() + expect(probe).not.toHaveBeenCalled() + }) + + it('carries a held reason without re-checking it past its TTL', async () => { + const { store, probe, service, advance } = rig() + await store + .refresh(FINGERPRINT, 'grok', probe, () => + Promise.reject(new AgentModelCatalogUnavailableError(SIGNED_OUT)) + ) + .catch(() => undefined) + advance(AGENT_MODEL_CATALOG_FAILURE_TTL_MS) + expect(await service.read({ agent: 'grok', savedOnly: true })).toEqual({ + origin: 'unknown', + unavailable: SIGNED_OUT + }) + expect(probe).not.toHaveBeenCalled() + }) + + it('says the named default holds in every workspace only for an agent no project config moves', async () => { + const grok = rig('grok') + grok.store.recordSuccess(FINGERPRINT, 'grok', listing('grok-4.7'), 'discovery') + expect(await grok.service.read({ agent: 'grok', savedOnly: true })).toMatchObject({ + listingNamesConfiguredModel: true, + defaultHoldsInEveryWorkspace: true + }) + // OpenCode's listing names no default, and a project's own config may pick its model. + const opencode = rig('opencode') + const opencodeKey = agentModelCatalogFingerprint({ + agent: 'opencode', + accountHome: HOME, + wslDistro: null + }) + opencode.store.recordSuccess(opencodeKey, 'opencode', listing('a/b'), 'discovery') + expect(await opencode.service.read({ agent: 'opencode', savedOnly: true })).not.toHaveProperty( + 'defaultHoldsInEveryWorkspace' + ) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index 14b3ce613eb..16092382a6f 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -51,7 +52,7 @@ const CODEX_HOME = (path: string): { variable: 'CODEX_HOME'; path: string } => ( describe('agent model catalog service', () => { it('answers unknown and kicks one probe for a session whose key has never listed', async () => { const store = new AgentModelCatalogStore() - const probe = vi.fn(async (_home: string) => listing('gpt-a')) + const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a')) const service = createAgentModelCatalogService({ store, getRecord: () => record('/homes/a'), @@ -67,7 +68,7 @@ describe('agent model catalog service', () => { // record-scoped read probes the RECORD's pinned home, not the selection. await service.read({ agent: 'codex', sessionId: 'session-1' }) expect(probe).toHaveBeenCalledTimes(1) - expect(probe).toHaveBeenCalledWith('/homes/a') + expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/a'), { signal: expect.any(AbortSignal) }) await vi.waitFor(async () => { const result = await service.read({ agent: 'codex', sessionId: 'session-1' }) expect(result.origin).toBe('probe') @@ -76,7 +77,7 @@ describe('agent model catalog service', () => { it('probes as for no record when this build cannot start the record as it is pinned', async () => { const store = new AgentModelCatalogStore() - const probe = vi.fn(async (_home: string) => listing('gpt-a')) + const probe = vi.fn(async (_home: AgentSessionAccountHome) => listing('gpt-a')) // A Codex record pinning Claude's variable: its path is not a Codex home to probe under. const pinned = { ...record('/x'), accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/x' } } const drivesRecord = vi.fn(() => false) @@ -91,14 +92,16 @@ describe('agent model catalog service', () => { await service.read({ agent: 'codex', sessionId: 'session-1' }) expect(drivesRecord).toHaveBeenCalledWith(pinned) - expect(probe).toHaveBeenCalledExactlyOnceWith('/homes/selected') + expect(probe).toHaveBeenCalledExactlyOnceWith(CODEX_HOME('/homes/selected'), { + signal: expect.any(AbortSignal) + }) }) it('an account switch with no record reads and prewarms the NEW account, never the old entry', async () => { const store = new AgentModelCatalogStore() // The old account listed under its own fingerprint before the switch. const oldFingerprint = selectedHomeFingerprint('/homes/old') - store.recordSuccess(oldFingerprint, 'codex', listing('gpt-old')) + store.recordSuccess(oldFingerprint, 'codex', listing('gpt-old'), 'discovery') const probe = vi.fn(async () => listing('gpt-new')) const service = createAgentModelCatalogService({ store, @@ -112,7 +115,9 @@ describe('agent model catalog service', () => { origin: 'unknown', listingInProgress: true }) - expect(probe).toHaveBeenCalledWith('/homes/new') + expect(probe).toHaveBeenCalledWith(CODEX_HOME('/homes/new'), { + signal: expect.any(AbortSignal) + }) await vi.waitFor(async () => { const result = await service.read({ agent: 'codex' }) expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-new') @@ -124,8 +129,18 @@ describe('agent model catalog service', () => { it('a record-less read serves the selected account entry when it exists', async () => { const store = new AgentModelCatalogStore() - store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-mine')) - store.recordSuccess(selectedHomeFingerprint('/homes/other'), 'codex', listing('gpt-other')) + store.recordSuccess( + selectedHomeFingerprint('/homes/selected'), + 'codex', + listing('gpt-mine'), + 'discovery' + ) + store.recordSuccess( + selectedHomeFingerprint('/homes/other'), + 'codex', + listing('gpt-other'), + 'discovery' + ) const service = createAgentModelCatalogService({ store, getRecord: () => undefined, @@ -142,12 +157,14 @@ describe('agent model catalog service', () => { store.recordSuccess( agentModelCatalogFingerprintForRecord(sessionRecord), 'codex', - listing('gpt-session') + listing('gpt-session'), + 'discovery' ) store.recordSuccess( selectedHomeFingerprint('/homes/selected'), 'codex', - listing('gpt-selected') + listing('gpt-selected'), + 'discovery' ) const service = createAgentModelCatalogService({ store, @@ -210,7 +227,9 @@ describe('agent model catalog service', () => { return { promise, resolve, reject } } - function coldService(probe: (home: string) => Promise) { + function coldService( + probe: (home: AgentSessionAccountHome) => Promise + ) { const store = new AgentModelCatalogStore() const service = createAgentModelCatalogService({ store, @@ -423,7 +442,12 @@ describe('agent model catalog service', () => { it('serves an aged entry at once and refreshes it behind the answer', async () => { let now = 0 const store = new AgentModelCatalogStore({ now: () => now }) - store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old')) + store.recordSuccess( + selectedHomeFingerprint('/homes/selected'), + 'codex', + listing('gpt-old'), + 'discovery' + ) now = AGENT_MODEL_CATALOG_FRESH_MS const probe = vi.fn(() => new Promise(() => {})) const service = createAgentModelCatalogService({ @@ -442,7 +466,12 @@ describe('agent model catalog service', () => { describe('a read for the workspace a new chat runs in', () => { function serviceWith(mayOverride: boolean) { const store = new AgentModelCatalogStore() - store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-user')) + store.recordSuccess( + selectedHomeFingerprint('/homes/selected'), + 'codex', + listing('gpt-user'), + 'discovery' + ) const workspaceMayOverrideDefaultModel = vi.fn(async () => mayOverride) const service = createAgentModelCatalogService({ store, @@ -490,4 +519,59 @@ describe('agent model catalog service', () => { expect(workspaceMayOverrideDefaultModel).not.toHaveBeenCalled() }) }) + + describe('an agent whose account is not one directory', () => { + const managed = (id: string): AgentSessionAccountHome => ({ + kind: 'opencode', + locator: { kind: 'managed', managedProfileId: id } + }) + const PROFILE_A = '0b0c6f5e-4f51-4c4a-9d3e-1a2b3c4d5e6f' + const PROFILE_B = '9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f' + const openCodeRecord = ( + home: AgentSessionAccountHome, + wslDistro: string | null = null + ): AgentSessionRecord => ({ + ...record('/unused'), + provider: 'opencode', + accountHome: home, + location: { ...record('/unused').location, wslDistro } + }) + + it('probes under the account the chat pinned, and keeps each account’s listing apart', async () => { + const store = new AgentModelCatalogStore() + const probe = vi.fn(async (home: AgentSessionAccountHome) => listing(JSON.stringify(home))) + const service = createAgentModelCatalogService({ + store, + getRecord: () => openCodeRecord(managed(PROFILE_A)), + drivesRecord: () => true, + resolveAccountHome: async () => managed(PROFILE_B), + probes: { opencode: probe } + }) + await service.read({ agent: 'opencode', sessionId: 'session-1', waitForListing: true }) + await service.read({ agent: 'opencode', waitForListing: true }) + expect(probe.mock.calls.map(([home]) => home)).toEqual([ + managed(PROFILE_A), + managed(PROFILE_B) + ]) + const pinned = await service.read({ agent: 'opencode', sessionId: 'session-1' }) + const selected = await service.read({ agent: 'opencode' }) + expect(pinned.origin !== 'unknown' && pinned.models[0]!.id).toContain(PROFILE_A) + expect(selected.origin !== 'unknown' && selected.models[0]!.id).toContain(PROFILE_B) + }) + + it('starts no host-side probe for a chat pinned inside WSL', async () => { + const probe = vi.fn(async () => listing('never')) + const service = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => openCodeRecord(managed(PROFILE_A), 'Ubuntu'), + drivesRecord: () => true, + resolveAccountHome: async () => managed(PROFILE_B), + probes: { opencode: probe } + }) + expect(await service.read({ agent: 'opencode', sessionId: 'session-1' })).toEqual({ + origin: 'unknown' + }) + expect(probe).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index ca2dd6c77a2..92dff5766e7 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -8,11 +8,15 @@ import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord } from './agent-model-catalog-fingerprint' +import type { AgentModelCatalogConfiguredChoice } from './agent-model-catalog-entry' import type { AgentModelCatalogEntry, + AgentModelCatalogLiveListing, AgentModelCatalogProbe, - AgentModelCatalogStore + AgentModelCatalogStore, + AgentModelCatalogSuccess } from './agent-model-catalog-store' +import { AgentModelCatalogListingStoppedError } from './agent-model-catalog-failures' export type AgentModelCatalogServiceDeps = { store: AgentModelCatalogStore @@ -26,12 +30,21 @@ export type AgentModelCatalogServiceDeps = { resolveAccountHome: (agent: string) => Promise /** Session-less listers, one per agent that has one on this host. */ probes?: Readonly>> - /** Whether the workspace's own config could pick a model other than the listed default. */ + /** Agents whose listing marks the model the account is configured to run as its default. */ + listingNamesConfiguredModel?: ReadonlySet + /** Where a session's provider runs, for deciding whether its own config scope is the account's. */ + recordWorkspacePath?: (record: AgentSessionRecord) => Promise + /** False for an agent whose model no project config can pick; its default holds everywhere. */ + agentReadsProjectModelConfig?: (agent: string) => boolean + /** Whether the workspace's own config could pick a model other than the listed default; + * `accountHomePath` is the agent's own home folder, which is account config, not a layer. */ workspaceMayOverrideDefaultModel?: (input: { agent: string workspacePath: string - accountHomePath: string + accountHomePath: string | null }) => Promise + /** Whether this host keeps any chat of the agent; with a saved catalog, what marks it in use. */ + hasChatRecords?: (agent: string) => boolean } export type AgentModelCatalogService = { @@ -43,17 +56,33 @@ export type AgentModelCatalogService = { /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`; * with a held reason past its TTL, from the probe re-checking it. */ waitForListing?: boolean + /** Answer only from the saved entry and held reason; start, join or re-check no listing. */ + savedOnly?: boolean }) => Promise + /** Saves what a running session listed as its account's catalog, so the next chat starts warm. */ + recordLiveListing: (sessionId: string, listing: AgentModelCatalogLiveListing) => void + /** Lists, in the background, every agent the user has used here (a saved catalog or a chat) + * whose catalog for the account a new chat would pin is missing or old, so a picker never meets + * a cold catalog. Resolves once those listings settle. */ + prewarm: () => Promise + /** The host is going away: start no listing, and stop the ones running. */ + stop: () => void /** A chat under this record's account proved its start: a held reason is re-checked sooner. */ providerStarted: ( record: Pick ) => void } +// At most this many agents list at once: each listing spawns that agent's CLI. +const PREWARM_CONCURRENCY = 2 + function resultFromEntry( entry: AgentModelCatalogEntry, - namesDefault: boolean -): AgentSessionModelCatalogResult { + namesDefault: boolean, + listingNamesConfiguredModel: boolean +): Exclude { + // A CLI-resolved default names the configured model even where the listing names none. + const namesConfigured = listingNamesConfiguredModel || entry.configured !== null return { origin: entry.origin, // Without a default the picker names nothing until the chat reports its model. @@ -61,7 +90,8 @@ function resultFromEntry( namesDefault ? { ...model } : { ...model, isDefault: false } ), ...(entry.fastModeSupport ? { fastModeSupport: entry.fastModeSupport } : {}), - fetchedAt: entry.fetchedAt + fetchedAt: entry.fetchedAt, + listingNamesConfiguredModel: namesDefault && namesConfigured } } @@ -75,7 +105,7 @@ async function workspaceKeepsListedDefault( if (workspacePath === undefined) { return true } - if (workspacePath === null || !accountHomePath || !deps.workspaceMayOverrideDefaultModel) { + if (workspacePath === null || !deps.workspaceMayOverrideDefaultModel) { return false } try { @@ -85,6 +115,69 @@ async function workspaceKeepsListedDefault( } } +/** The catalog a new chat of `agent` would read: the account a launch would pin right now. */ +async function newChatCatalogKey( + deps: AgentModelCatalogServiceDeps, + agent: string +): Promise<{ fingerprint: string; accountHome: AgentSessionAccountHome } | null> { + let accountHome: AgentSessionAccountHome + try { + accountHome = await deps.resolveAccountHome(agent) + } catch { + return null + } + return { + fingerprint: agentModelCatalogFingerprint({ agent, accountHome, wslDistro: null }), + accountHome + } +} + +/** A session launched with no model pick resolved its config scope's default model and effort; + * when that scope is the account's (a native workspace with no config of its own), they are the + * account's default, and a resolution naming no listed model retires the saved one. */ +async function recordConfiguredDefault( + deps: AgentModelCatalogServiceDeps, + record: AgentSessionRecord, + fingerprint: string, + choice: AgentModelCatalogConfiguredChoice | null +): Promise { + const accountHome = record.accountHome + if ( + record.location.wslDistro !== null || + !deps.recordWorkspacePath || + !deps.workspaceMayOverrideDefaultModel + ) { + return + } + const workspacePath = await deps.recordWorkspacePath(record) + if ( + !workspacePath || + (await deps.workspaceMayOverrideDefaultModel({ + agent: record.provider, + workspacePath, + accountHomePath: isLegacyAgentSessionAccountHome(accountHome) ? accountHome.path : null + })) + ) { + return + } + deps.store.recordConfiguredDefault(fingerprint, choice) +} + +async function runBounded( + items: readonly T[], + limit: number, + stopped: AbortSignal, + run: (item: T) => Promise +): Promise { + const queue = [...items] + const worker = async (): Promise => { + for (let item = queue.shift(); item !== undefined && !stopped.aborted; item = queue.shift()) { + await run(item).catch(() => {}) + } + } + await Promise.all(Array.from({ length: Math.min(limit, queue.length) }, worker)) +} + /** * Serves the host catalog to pickers, never through a session's serialize * queue. A session record names its own catalog (the account home pinned at @@ -100,6 +193,19 @@ async function workspaceKeepsListedDefault( export function createAgentModelCatalogService( deps: AgentModelCatalogServiceDeps ): AgentModelCatalogService { + // One per host: stopping it ends every listing this service started. + const lifetime = new AbortController() + // A chat's configured default still being checked against its workspace, per catalog: a read + // answers after it, so the chat that reported it is followed by an answer naming it. + const recordingDefaults = new Map>() + const listWith = + (probe: AgentModelCatalogProbe, home: AgentSessionAccountHome) => + (): Promise => + probe(home, { signal: lifetime.signal }).catch((error: unknown) => { + throw lifetime.signal.aborted + ? new AgentModelCatalogListingStoppedError(String(error)) + : error + }) return { providerStarted(record) { deps.store.expireFailure(agentModelCatalogFingerprintForRecord(record)) @@ -109,62 +215,61 @@ export function createAgentModelCatalogService( const scoped = record && record.provider === params.agent && deps.drivesRecord(record) ? record : undefined let fingerprint: string - let accountHomePath: string | null + // The account a probe lists under; null where this host cannot spawn one natively. + let probeHome: AgentSessionAccountHome | null if (scoped) { fingerprint = agentModelCatalogFingerprintForRecord(scoped) // Probes spawn natively; a WSL-pinned record has no host-side lister. - accountHomePath = - scoped.location.wslDistro === null && isLegacyAgentSessionAccountHome(scoped.accountHome) - ? scoped.accountHome.path - : null + probeHome = scoped.location.wslDistro === null ? scoped.accountHome : null } else { - let resolved: AgentSessionAccountHome - try { - resolved = await deps.resolveAccountHome(params.agent) - } catch { + const key = await newChatCatalogKey(deps, params.agent) + if (!key) { return { origin: 'unknown' } } - fingerprint = agentModelCatalogFingerprint({ - agent: params.agent, - accountHome: resolved, - wslDistro: null - }) - accountHomePath = isLegacyAgentSessionAccountHome(resolved) ? resolved.path : null + fingerprint = key.fingerprint + probeHome = key.accountHome } + const accountHomePath = + probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null + await recordingDefaults.get(fingerprint) let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] - const home = accountHomePath + const home = probeHome // Every answer carries the reason the probe last found, read when the answer is made. const answer = async ( listed: AgentModelCatalogEntry | null, extra: { listingInProgress?: true } = {} ): Promise => { const unavailable = deps.store.failure(fingerprint)?.unavailable + const result = listed + ? resultFromEntry( + listed, + await workspaceKeepsListedDefault( + deps, + params.agent, + params.workspacePath, + accountHomePath + ), + deps.listingNamesConfiguredModel?.has(params.agent) === true + ) + : null + const holdsEverywhere = + result?.listingNamesConfiguredModel === true && + deps.agentReadsProjectModelConfig?.(params.agent) === false return { - ...(listed - ? resultFromEntry( - listed, - await workspaceKeepsListedDefault( - deps, - params.agent, - params.workspacePath, - accountHomePath - ) - ) - : { origin: 'unknown' }), + ...(result ?? { origin: 'unknown' }), + ...(holdsEverywhere ? { defaultHoldsInEveryWorkspace: true as const } : {}), ...extra, ...(unavailable ? { unavailable } : {}) } } + if (params.savedOnly) { + return answer(entry) + } // Past its TTL, only the probe re-derives a held reason. The reason is served meanwhile; // only a read that asks waits for the probe's answer. - if ( - probe && - home && - deps.store.failure(fingerprint)?.unavailable && - !deps.store.hasActiveFailure(fingerprint) - ) { - const probing = deps.store.refresh(fingerprint, params.agent, probe, () => probe(home)) + if (probe && home && !lifetime.signal.aborted && deps.store.heldReasonDue(fingerprint)) { + const probing = deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) if (!params.waitForListing) { return answer(entry, { listingInProgress: true }) } @@ -173,11 +278,11 @@ export function createAgentModelCatalogService( } // Without an entry, answer from any running listing instead of starting a second one. let listing = !entry && home ? deps.store.pendingListing(fingerprint) : null - if (probe && home) { + if (probe && home && !lifetime.signal.aborted) { if (entry && deps.store.shouldRefresh(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, () => probe(home)) + void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) } else if (!entry && !listing && !deps.store.hasActiveFailure(fingerprint)) { - void deps.store.refresh(fingerprint, params.agent, probe, () => probe(home)) + void deps.store.refresh(fingerprint, params.agent, probe, listWith(probe, home)) listing = deps.store.pendingListing(fingerprint) } } @@ -192,6 +297,55 @@ export function createAgentModelCatalogService( entry = deps.store.get(fingerprint) ?? listed } return answer(entry) + }, + recordLiveListing(sessionId, listing) { + const record = deps.getRecord(sessionId) + if (!record || !deps.drivesRecord(record)) { + return + } + // The record's pinned account and host: the account this child listed under. + const fingerprint = agentModelCatalogFingerprintForRecord(record) + const { configuredDefault, ...listed } = listing + const saved = deps.store.recordSuccess( + fingerprint, + record.provider, + { ...listed, fastModeTierByModel: new Map(), origin: 'live-session' }, + 'live' + ) + if (saved && configuredDefault !== undefined) { + // In report order, so the latest chat's resolution is the one kept. + const recording = (recordingDefaults.get(fingerprint) ?? Promise.resolve()) + .then(() => recordConfiguredDefault(deps, record, fingerprint, configuredDefault)) + .catch(() => {}) + .then(() => { + if (recordingDefaults.get(fingerprint) === recording) { + recordingDefaults.delete(fingerprint) + } + }) + recordingDefaults.set(fingerprint, recording) + } + }, + async prewarm() { + if (lifetime.signal.aborted) { + return + } + const probes = deps.probes ?? {} + // An agent never used here waits for its first chat, which shows the quiet placeholder. + const used = Object.keys(probes).filter( + (agent) => deps.store.hasEntryForAgent(agent) || deps.hasChatRecords?.(agent) === true + ) + await runBounded(used, PREWARM_CONCURRENCY, lifetime.signal, async (agent) => { + const probe = probes[agent] + const key = probe ? await newChatCatalogKey(deps, agent) : null + // A fresh entry, a listing already running, or a recent failure each mean nothing to do. + if (!probe || !key || lifetime.signal.aborted || !deps.store.probeDue(key.fingerprint)) { + return + } + await deps.store.refresh(key.fingerprint, agent, probe, listWith(probe, key.accountHome)) + }) + }, + stop() { + lifetime.abort() } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts index 09bc3f977d9..33b60f0cd6f 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.test.ts @@ -47,7 +47,7 @@ describe('agent model catalog store', () => { it('serves an entry at any age and flags staleness at the refresh threshold', () => { let at = 1_000 const store = new AgentModelCatalogStore({ now: () => at }) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') const entry = store.get('fp-1')! expect(entry.models.map((model) => model.id)).toEqual(['gpt-a']) expect(store.isStale(entry)).toBe(false) @@ -60,11 +60,16 @@ describe('agent model catalog store', () => { it('never memoizes an empty list as a catalog', () => { const store = new AgentModelCatalogStore() expect( - store.recordSuccess('fp-1', 'codex', { - models: [], - fastModeTierByModel: new Map(), - origin: 'live-session' - }) + store.recordSuccess( + 'fp-1', + 'codex', + { + models: [], + fastModeTierByModel: new Map(), + origin: 'live-session' + }, + 'discovery' + ) ).toBeNull() expect(store.get('fp-1')).toBeNull() }) @@ -72,7 +77,7 @@ describe('agent model catalog store', () => { it('holds a failure under its TTL without touching the last good entry', () => { let at = 1_000 const store = new AgentModelCatalogStore({ now: () => at }) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') store.recordFailure('fp-1', 'timed out') expect(store.get('fp-1')!.models.map((model) => model.id)).toEqual(['gpt-a']) expect(store.hasActiveFailure('fp-1')).toBe(true) @@ -103,7 +108,9 @@ describe('agent model catalog store', () => { let failProbe!: (error: Error) => void const hungProbe: AgentModelCatalogProbe = () => new Promise((_resolve, reject) => (failProbe = reject)) - const probe = store.refresh('fp-1', 'codex', hungProbe, () => hungProbe('/homes/a')) + const probe = store.refresh('fp-1', 'codex', hungProbe, () => + hungProbe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) expect(store.shouldRefresh('fp-1')).toBe(false) const live = await store.refresh('fp-1', 'codex', liveLister(store), async () => @@ -216,13 +223,35 @@ describe('agent model catalog store', () => { let settleProbe!: (success: AgentModelCatalogSuccess) => void const probe: AgentModelCatalogProbe = () => new Promise((resolve) => (settleProbe = resolve)) - const pending = store.refresh('fp-1', 'codex', probe, () => probe('/homes/a')) - store.recordSuccess('fp-1', 'codex', success('gpt-live')) + const pending = store.refresh('fp-1', 'codex', probe, () => + probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) + store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'discovery') settleProbe({ ...success('gpt-probe'), origin: 'probe' }) expect((await pending)!.models[0]!.id).toBe('gpt-probe') expect(store.get('fp-1')!.models[0]!.id).toBe('gpt-live') }) + it('still writes a pending probe that resolves after a live save, keeping both', async () => { + const store = new AgentModelCatalogStore() + let settleProbe!: (success: AgentModelCatalogSuccess) => void + const probe: AgentModelCatalogProbe = () => + new Promise((resolve) => (settleProbe = resolve)) + const pending = store.refresh('fp-1', 'codex', probe, () => + probe({ variable: 'CODEX_HOME', path: '/homes/a' }) + ) + store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'live') + settleProbe({ ...success('gpt-probe'), origin: 'probe' }) + await pending + const entry = store.get('fp-1')! + expect(entry.discovered!.models[0]!.id).toBe('gpt-probe') + expect(entry.live!.models[0]!.id).toBe('gpt-live') + // A live save neither postpones the next discovery nor clears its failure back-off. + store.recordFailure('fp-1', 'timed out') + store.recordSuccess('fp-1', 'codex', success('gpt-live'), 'live') + expect(store.hasActiveFailure('fp-1')).toBe(true) + }) + it('keeps an older successful listing when the newer entry was evicted', async () => { const store = new AgentModelCatalogStore() let settleOlder!: (success: AgentModelCatalogSuccess) => void @@ -234,7 +263,7 @@ describe('agent model catalog store', () => { ) await store.refresh('fp-1', 'codex', liveLister(store), async () => success('gpt-new')) for (let index = 0; index < AGENT_MODEL_CATALOG_MAX_ENTRIES; index++) { - store.recordSuccess(`other-${index}`, 'codex', success('other')) + store.recordSuccess(`other-${index}`, 'codex', success('other'), 'discovery') } expect(store.get('fp-1')).toBeNull() const save = vi.fn() @@ -274,7 +303,7 @@ describe('agent model catalog store', () => { }) expect(fingerprintA).not.toBe(fingerprintB) const store = new AgentModelCatalogStore() - store.recordSuccess(fingerprintA, 'codex', success('gpt-a')) + store.recordSuccess(fingerprintA, 'codex', success('gpt-a'), 'discovery') expect(store.get(fingerprintB)).toBeNull() }) @@ -304,12 +333,12 @@ describe('agent model catalog store', () => { const store = new AgentModelCatalogStore({ now: () => at }) const save = vi.fn() void store.attachPersistence({ load: async () => [], save, flush: async () => {} }) - store.recordSuccess('fp', 'claude', success('opus')) + store.recordSuccess('fp', 'claude', success('opus'), 'discovery') at += AGENT_MODEL_CATALOG_FRESH_MS - store.recordSuccess('fp', 'claude', success('opus')) + store.recordSuccess('fp', 'claude', success('opus'), 'discovery') expect(save).toHaveBeenCalledTimes(1) expect(store.shouldRefresh('fp')).toBe(false) - store.recordSuccess('fp', 'claude', success('opus', 'sonnet')) + store.recordSuccess('fp', 'claude', success('opus', 'sonnet'), 'discovery') expect(save).toHaveBeenCalledTimes(2) }) @@ -334,9 +363,9 @@ describe('agent model catalog store', () => { fastModeTierByModel: new Map(), origin: 'live-session' }) - store.recordSuccess('fp', 'claude', listing('medium')) + store.recordSuccess('fp', 'claude', listing('medium'), 'live') // A session-less probe never names Claude's default. - store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }) + store.recordSuccess('fp', 'claude', { ...listing(), origin: 'probe' }, 'discovery') expect(store.get('fp')!.models[0]!.defaultEffort).toBe('medium') await store.flushPersistence() const restarted = new AgentModelCatalogStore() @@ -344,20 +373,63 @@ describe('agent model catalog store', () => { expect(restarted.get('fp')!.models[0]!.defaultEffort).toBe('medium') // A newer report replaces it; a model that stops offering it drops it. - store.recordSuccess('fp', 'claude', listing('high')) + store.recordSuccess('fp', 'claude', listing('high'), 'live') expect(store.get('fp')!.models[0]!.defaultEffort).toBe('high') - store.recordSuccess('fp', 'claude', { - ...listing(), - models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] - }) + store.recordSuccess( + 'fp', + 'claude', + { + ...listing(), + models: [{ id: 'opus', label: 'Opus', isDefault: true, efforts: [efforts[0]!] }] + }, + 'live' + ) expect(store.get('fp')!.models[0]).not.toHaveProperty('defaultEffort') }) + it('keeps a CLI-resolved configured default through later listings and a restart', async () => { + const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) + const store = new AgentModelCatalogStore() + await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + // Never on its own: there is no catalog to name a default in yet. + store.recordConfiguredDefault('fp', { modelId: 'sonnet' }) + expect(store.get('fp')).toBeNull() + + store.recordSuccess('fp', 'claude', success('opus', 'sonnet'), 'live') + store.recordConfiguredDefault('fp', { modelId: 'sonnet', effort: 'high' }) + store.recordSuccess( + 'fp', + 'claude', + { ...success('opus', 'sonnet'), origin: 'probe' }, + 'discovery' + ) + const named = (entry: ReturnType) => + entry?.models.filter((model) => model.isDefault).map((model) => model.id) + expect(named(store.get('fp'))).toEqual(['sonnet']) + + await store.flushPersistence() + const restarted = new AgentModelCatalogStore() + await restarted.attachPersistence(createAgentModelCatalogFilePersistence(directory)) + expect(restarted.get('fp')?.configured).toMatchObject({ + modelId: 'sonnet', + effort: 'high' + }) + expect(named(restarted.get('fp'))).toEqual(['sonnet']) + const sonnet = restarted.get('fp')?.models.find((model) => model.id === 'sonnet') + expect(sonnet?.defaultEffort).toBe('high') + + restarted.recordConfiguredDefault('fp', null) + expect(restarted.get('fp')?.configured).toBeNull() + expect(restarted.get('fp')?.models.find((model) => model.id === 'sonnet')).not.toHaveProperty( + 'defaultEffort' + ) + }) + it('persists successes only and hydrates them across a restart', async () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') store.recordFailure('fp-2', 'timed out') await vi.waitFor( async () => { @@ -380,7 +452,7 @@ describe('agent model catalog store', () => { const directory = mkdtempSync(join(tmpdir(), 'agent-model-catalog-')) const store = new AgentModelCatalogStore() await store.attachPersistence(createAgentModelCatalogFilePersistence(directory)) - store.recordSuccess('fp-1', 'codex', success('gpt-a')) + store.recordSuccess('fp-1', 'codex', success('gpt-a'), 'discovery') await store.flushPersistence() const persisted = await createAgentModelCatalogFilePersistence(directory).load() expect(persisted.map((entry) => entry.fingerprint)).toEqual(['fp-1']) diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts index 89d434ed11e..6a2a6e58192 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-store.ts @@ -1,50 +1,59 @@ -import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' -import type { - AgentSessionFastModeSupport, - AgentSessionModelOption -} from '../../../shared/agent-session-wire' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' import type { AgentModelCatalogPersistence } from './agent-model-catalog-persistence' +import { startSpan } from '../../observability/tracer' +import { + agentModelCatalogEntryWithSuccess, + agentModelCatalogListingKey, + entryWithConfiguredDefault, + type AgentModelCatalogConfiguredChoice, + type AgentModelCatalogEntry, + type AgentModelCatalogSource, + type AgentModelCatalogSuccess +} from './agent-model-catalog-entry' + +export type { + AgentModelCatalogEntry, + AgentModelCatalogListing, + AgentModelCatalogLiveListing, + AgentModelCatalogSource, + AgentModelCatalogSuccess +} from './agent-model-catalog-entry' +export { withLiveCatalogListing } from './agent-model-catalog-entry' import { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AgentModelCatalogFailures, + AgentModelCatalogListingStoppedError, type AgentModelCatalogFailure } from './agent-model-catalog-failures' +import { + AGENT_MODEL_CATALOG_PICKER_WAIT_MS, + AgentModelCatalogListingWaiters +} from './agent-model-catalog-listing-waiters' -export { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, type AgentModelCatalogFailure } +export { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AGENT_MODEL_CATALOG_PICKER_WAIT_MS } +export type { AgentModelCatalogFailure } // The execution host's one model catalog per (agent, launch fingerprint): // served immediately at any age, refreshed in the background when old, and // written through by every successful listing a live session already performs. +// Each entry keeps two listings: the last account-level discovery (the only +// source of the configured default and default efforts, and the only clock a +// refresh follows) and the last live session's listing (which models exist and +// their efforts). Readers see the two merged; neither write erases the other. // Success-only: a failure, timeout or empty list is never stored as a catalog // and never persisted — it is held separately under a short TTL so a burst of // picker opens does not hammer a dead binary, then dies on its own (see // `AgentModelCatalogFailures`, which also holds why no chat can start under the account). export const AGENT_MODEL_CATALOG_FRESH_MS = 10 * 60_000 -export const AGENT_MODEL_CATALOG_PICKER_WAIT_MS = 30_000 export const AGENT_MODEL_CATALOG_MAX_ENTRIES = 256 -export type AgentModelCatalogEntry = { - agent: string - fingerprint: string - models: AgentSessionModelOption[] - fastModeSupport?: AgentSessionFastModeSupport - /** Provider-advertised Fast tier per model id; derived from the same listing. */ - fastModeTierByModel: Record - origin: 'live-session' | 'probe' - fetchedAt: number -} - -export type AgentModelCatalogSuccess = { - models: AgentSessionModelOption[] - fastModeSupport?: AgentSessionFastModeSupport - fastModeTierByModel: ReadonlyMap - origin: 'live-session' | 'probe' - /** A probe that listed models but also found no chat can start (a signed-out Codex): both kept. */ - unavailable?: AgentSessionUnavailable -} - -export type AgentModelCatalogProbe = (accountHomePath: string) => Promise +/** Lists an agent's models without a session, under the account a launch would pin. `signal` + * stops the listing and its child once the host that asked is going away. */ +export type AgentModelCatalogProbe = ( + accountHome: AgentSessionAccountHome, + options?: { signal?: AbortSignal } +) => Promise /** Who lists, by identity: a live session's per-spawn handle, or the session-less probe. */ export type AgentModelCatalogLister = AgentModelCatalogSessionAccess | AgentModelCatalogProbe @@ -60,41 +69,17 @@ export type AgentModelCatalogSessionAccess = { accountHomePath: string } -function tierRecord(tiers: ReadonlyMap): Record { - return Object.fromEntries(tiers.entries()) -} - -/** A listing that names no default effort for a model keeps the one a live child reported for it, - * while that model still offers it: Claude's listing never names one, only a running child does. */ -function withKnownDefaultEfforts( - models: readonly AgentSessionModelOption[], - previous: AgentModelCatalogEntry | undefined -): AgentSessionModelOption[] { - return models.map((model) => { - const known = previous?.models.find((entry) => entry.id === model.id)?.defaultEffort - return model.defaultEffort === undefined && - known !== undefined && - model.efforts.some((choice) => choice.value === known) - ? { ...model, defaultEffort: known } - : { ...model } - }) -} - -function listingKey(entry: AgentModelCatalogEntry): string { - return JSON.stringify([ - entry.origin, - entry.models, - entry.fastModeSupport ?? null, - entry.fastModeTierByModel - ]) -} - export class AgentModelCatalogStore { private readonly entries = new Map() private readonly failures: AgentModelCatalogFailures private readonly refreshes = new Map() - private readonly listingWaiters = new Map void>>() + private readonly listingWaiters = new AgentModelCatalogListingWaiters( + (fingerprint) => this.get(fingerprint), + (fingerprint) => this.refreshes.has(fingerprint) + ) private readonly latestWrittenOrder = new Map() + // Catalogs listed under a command or env the agent no longer launches with; cleared by a discovery. + private readonly dueEntries = new Set() private nextListingOrder = 0 private persistence: AgentModelCatalogPersistence | null = null private readonly now: () => number @@ -130,8 +115,23 @@ export class AgentModelCatalogStore { return entry } + /** Whether any account's catalog of `agent` is saved here. */ + hasEntryForAgent(agent: string): boolean { + for (const entry of this.entries.values()) { + if (entry.agent === agent) { + return true + } + } + return false + } + + /** Only a discovery ages: live saves never postpone the next account-level listing. */ isStale(entry: AgentModelCatalogEntry): boolean { - return this.now() - entry.fetchedAt >= AGENT_MODEL_CATALOG_FRESH_MS + return ( + this.dueEntries.has(entry.fingerprint) || + !entry.discovered || + this.now() - entry.discovered.at >= AGENT_MODEL_CATALOG_FRESH_MS + ) } failureDetail(fingerprint: string): string | null { @@ -156,57 +156,85 @@ export class AgentModelCatalogStore { this.failures.expire(fingerprint) } + /** The agent's command or launch env changed: each of its catalogs and held reasons is due. */ + expireAgent(agent: string): void { + for (const entry of this.entries.values()) { + if (entry.agent === agent) { + this.dueEntries.add(entry.fingerprint) + } + } + this.failures.expireAgent(agent) + } + + /** The one ingestion step every listing goes through, whoever listed it. */ recordSuccess( fingerprint: string, agent: string, - success: AgentModelCatalogSuccess + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource ): AgentModelCatalogEntry | null { - const entry = this.writeSuccess(fingerprint, agent, success, ++this.nextListingOrder) - this.notifyListingWaiters(fingerprint) + const entry = this.writeSuccess(fingerprint, agent, success, source, ++this.nextListingOrder) + this.listingWaiters.notify(fingerprint) return entry } private entryFromSuccess( fingerprint: string, agent: string, - success: AgentModelCatalogSuccess + success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource ): AgentModelCatalogEntry | null { - if (success.models.length === 0) { - // An empty list identifies no model; it is doubt, not a catalog. - return null - } + return agentModelCatalogEntryWithSuccess( + this.entries.get(fingerprint), + { agent, fingerprint }, + success, + source, + this.now() + ) + } + + /** Records which model and effort the account's own config resolves to, for an agent whose + * listing names none; null forgets a default that config no longer resolves to. Superseded by + * the next chat that resolves it; never creates an entry on its own. */ + recordConfiguredDefault( + fingerprint: string, + choice: AgentModelCatalogConfiguredChoice | null + ): void { const previous = this.entries.get(fingerprint) - return { - agent, - fingerprint, - models: withKnownDefaultEfforts(success.models, previous), - ...(success.fastModeSupport ? { fastModeSupport: success.fastModeSupport } : {}), - fastModeTierByModel: tierRecord(success.fastModeTierByModel), - origin: success.origin, - fetchedAt: this.now() + const entry = previous && entryWithConfiguredDefault(previous, choice, this.now()) + if (!entry) { + return } + this.entries.set(fingerprint, entry) + this.persistence?.save([...this.entries.values()]) + this.listingWaiters.notify(fingerprint) } private writeSuccess( fingerprint: string, agent: string, success: AgentModelCatalogSuccess, + source: AgentModelCatalogSource, order: number ): AgentModelCatalogEntry | null { - const entry = this.entryFromSuccess(fingerprint, agent, success) + const entry = this.entryFromSuccess(fingerprint, agent, success, source) if (!entry) { return null } const previous = this.entries.get(fingerprint) this.entries.delete(fingerprint) this.entries.set(fingerprint, entry) - if (this.refreshes.has(fingerprint)) { + if (source === 'discovery' && this.refreshes.has(fingerprint)) { this.latestWrittenOrder.set(fingerprint, order) } - this.failures.listed(fingerprint, agent, success.origin, success.unavailable) + // A live listing is not the account's answer: it neither clears a failure nor a probe's verdict. + if (source === 'discovery') { + this.dueEntries.delete(fingerprint) + this.failures.listed(fingerprint, agent, success.origin, success.unavailable) + } this.evictOverCap() // Live sessions re-list every turn; an unchanged listing only refreshes the in-memory age. - if (!previous || listingKey(previous) !== listingKey(entry)) { + if (!previous || agentModelCatalogListingKey(previous) !== agentModelCatalogListingKey(entry)) { this.persistence?.save([...this.entries.values()]) } return entry @@ -216,7 +244,7 @@ export class AgentModelCatalogStore { this.failures.chatFailed(fingerprint, detail, agent) } - /** Joins an in-flight refresh by the same lister rather than starting a second. Never + /** A discovery listing. Joins an in-flight refresh by the same lister rather than starting a second. Never * joins another lister's: a probe or another chat's Codex that hangs must not decide * whether this chat starts. Resolves with the entry on success, null on failure. */ refresh( @@ -236,28 +264,37 @@ export class AgentModelCatalogStore { this.refreshes.delete(fingerprint) this.latestWrittenOrder.delete(fingerprint) } - this.notifyListingWaiters(fingerprint) + this.listingWaiters.notify(fingerprint) } const order = ++this.nextListingOrder + // Agent, outcome and duration only: a slow listing shows in the trace log without its content. + const span = startSpan('agentModelCatalog.discovery', { + attributes: { agent, lister: typeof lister === 'function' ? 'probe' : 'session' } + }) const run = listModels().then( (success) => { - // An older session still receives its own result, but cannot replace a newer catalog. + span.setAttribute('models', success.models.length) + span.end() + // An older lister still receives its own result, but cannot replace a newer discovery. const superseded = (this.latestWrittenOrder.get(fingerprint) ?? 0) > order && this.entries.has(fingerprint) if (superseded && success.origin === 'probe') { this.failures.listed(fingerprint, agent, success.origin, success.unavailable) } const entry = superseded - ? this.entryFromSuccess(fingerprint, agent, success) - : this.writeSuccess(fingerprint, agent, success, order) + ? this.entryFromSuccess(fingerprint, agent, success, 'discovery') + : this.writeSuccess(fingerprint, agent, success, 'discovery', order) settle() return entry }, (error: unknown) => { + span.fail(error instanceof Error ? error : String(error)) settle() // Probes are functions; a live session lists through its access object. if (typeof lister === 'function') { - this.failures.probeFailed(fingerprint, agent, error) + if (!(error instanceof AgentModelCatalogListingStoppedError)) { + this.failures.probeFailed(fingerprint, agent, error) + } } else { this.recordFailure( fingerprint, @@ -276,44 +313,7 @@ export class AgentModelCatalogStore { /** A picker follows the current account work until a catalog lands, all work ends, * or its fixed deadline expires. */ pendingListing(fingerprint: string): Promise | null { - if (!this.refreshes.has(fingerprint)) { - return null - } - return new Promise((resolve) => { - const waiters = this.listingWaiters.get(fingerprint) ?? new Set<() => void>() - let settled = false - const finish = (entry: AgentModelCatalogEntry | null): void => { - if (settled) { - return - } - settled = true - clearTimeout(deadline) - waiters.delete(check) - if (waiters.size === 0) { - this.listingWaiters.delete(fingerprint) - } - resolve(entry) - } - const check = (): void => { - const entry = this.get(fingerprint) - if (entry || !this.refreshes.has(fingerprint)) { - finish(entry) - } - } - const deadline = setTimeout( - () => finish(this.get(fingerprint)), - AGENT_MODEL_CATALOG_PICKER_WAIT_MS - ) - waiters.add(check) - this.listingWaiters.set(fingerprint, waiters) - check() - }) - } - - private notifyListingWaiters(fingerprint: string): void { - for (const check of this.listingWaiters.get(fingerprint) ?? []) { - check() - } + return this.listingWaiters.wait(fingerprint) } /** True when a read should kick a background refresh: nothing known or the @@ -326,12 +326,30 @@ export class AgentModelCatalogStore { return !entry || this.isStale(entry) } + /** A probe's reason past its TTL. Only a probe re-derives it, however fresh the catalog beside + * it, so only probe paths ask; a chat's own listing would never clear it. */ + heldReasonDue(fingerprint: string): boolean { + return ( + this.failures.get(fingerprint)?.unavailable !== undefined && + !this.hasActiveFailure(fingerprint) + ) + } + + /** Whether a session-less probe should list now: the refresh rule, or a held reason due. */ + probeDue(fingerprint: string): boolean { + return ( + this.shouldRefresh(fingerprint) || + (!this.refreshes.has(fingerprint) && this.heldReasonDue(fingerprint)) + ) + } + private evictOverCap(): void { for (const key of this.entries.keys()) { if (this.entries.size <= AGENT_MODEL_CATALOG_MAX_ENTRIES) { return } this.entries.delete(key) + this.dueEntries.delete(key) } } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts index 87868ef82f0..c822ccc14c6 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-unavailable.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire' import { agentModelCatalogFingerprint } from './agent-model-catalog-fingerprint' import { createAgentModelCatalogService } from './agent-model-catalog-service' -import { expireAgentModelCatalogFailuresForSettings } from './agent-model-catalog-account-expiry' +import { + agentsWithChangedLaunchSettings, + createAgentModelCatalogSettingsExpiry, + expireAgentModelCatalogFailuresForSettings +} from './agent-model-catalog-account-expiry' import { AGENT_MODEL_CATALOG_FAILURE_TTL_MS, AgentModelCatalogStore, @@ -11,11 +15,13 @@ import { } from './agent-model-catalog-store' import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' // Why a chat cannot start, as the probe found it: the reason rides every catalog answer until a // later probe answers again, and a chat's own listing never replaces it. const HOME = '/homes/selected' +const ACCOUNT_HOME = { variable: 'CODEX_HOME', path: HOME } as const const FINGERPRINT = agentModelCatalogFingerprint({ agent: 'codex', accountHomeVariable: 'CODEX_HOME', @@ -49,12 +55,14 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { let now = 1_000 let answer = initialProbe const store = new AgentModelCatalogStore({ now: () => now }) - const probe = vi.fn((home: string) => answer(home)) + const probe = vi.fn((home: AgentSessionAccountHome, options?: { signal?: AbortSignal }) => + answer(home, options) + ) const service = createAgentModelCatalogService({ store, getRecord: () => undefined, drivesRecord: () => true, - resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: HOME }), + resolveAccountHome: async () => ACCOUNT_HOME, probes: { codex: probe } }) const chat = { store, fingerprint: FINGERPRINT, accountHomePath: HOME } @@ -64,7 +72,7 @@ function rig(initialProbe: AgentModelCatalogProbe = signedOut) { service, chat, /** Lets the probe's verdict land. */ - probed: () => store.refresh(FINGERPRINT, 'codex', probe, () => probe(HOME)), + probed: () => store.refresh(FINGERPRINT, 'codex', probe, () => probe(ACCOUNT_HOME)), answerWith: (next: AgentModelCatalogProbe) => (answer = next), pastTtl: () => (now += AGENT_MODEL_CATALOG_FAILURE_TTL_MS) } @@ -188,7 +196,7 @@ describe('a catalog read that finds a held reason', () => { it('past the TTL, serves it at once and starts one probe; a waiting read gets its answer', async () => { const { store, probe, probed, pastTtl, answerWith, service } = rig() await probed() - store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session')) + store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session'), 'live') pastTtl() const signIn = deferred() answerWith(() => signIn.promise) @@ -241,3 +249,85 @@ describe('a catalog read that finds a held reason', () => { }) }) }) + +describe('a probe the host stops', () => { + it('leaves a held reason as it was', async () => { + const { store, service, probed, answerWith, pastTtl } = rig() + await probed() + pastTtl() + answerWith( + (_home, options) => + new Promise((_resolve, reject) => + options?.signal?.addEventListener('abort', () => reject(new Error('session stopped'))) + ) + ) + await service.read({ agent: 'codex' }) + service.stop() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT) + expect(store.hasActiveFailure(FINGERPRINT)).toBe(false) + }) +}) + +describe('a prewarm probe', () => { + it('sets and clears the reason as a read’s probe does, re-checking it however fresh the catalog', async () => { + const { store, probe, service, answerWith, pastTtl } = rig(async () => ({ + ...listing('gpt-gateway', 'probe'), + unavailable: SIGNED_OUT + })) + // A saved catalog marks the agent as used here. + store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-live', 'live-session'), 'live') + await service.prewarm() + expect(store.failure(FINGERPRINT)?.unavailable).toEqual(SIGNED_OUT) + answerWith(async () => listing('gpt-probe', 'probe')) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(1) + pastTtl() + expect(store.isStale(store.get(FINGERPRINT)!)).toBe(false) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(2) + expect(store.failure(FINGERPRINT)).toBeNull() + }) +}) + +describe("an agent's command or environment change", () => { + it('re-lists its fresh catalog and re-checks a held "not installed" at the next prewarm', async () => { + const { store, probe, service, probed, answerWith } = rig(() => + Promise.reject(new AgentModelCatalogUnavailableError({ reason: 'cliMissing' })) + ) + store.recordSuccess(FINGERPRINT, 'codex', listing('gpt-saved', 'probe'), 'discovery') + await probed() + expect(store.failure(FINGERPRINT)?.unavailable).toEqual({ reason: 'cliMissing' }) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(1) + const expire = createAgentModelCatalogSettingsExpiry(store, { agentCmdOverrides: {} }) + const fixed = { agentCmdOverrides: { codex: '/opt/codex/bin/codex' } } + expire(fixed, fixed) + answerWith(async () => listing('gpt-new', 'probe')) + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(2) + expect(store.failure(FINGERPRINT)).toBeNull() + expect(store.get(FINGERPRINT)!.models.map((model) => model.id)).toEqual(['gpt-new']) + // The new listing is the fresh one: nothing is due until the next change. + await service.prewarm() + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('touches only the agents whose command or env changed', () => { + expect( + agentsWithChangedLaunchSettings( + { + agentCmdOverrides: { codex: 'codex', claude: 'claude' }, + agentDefaultEnv: { claude: { A: '1', B: '2' } } + }, + { + agentCmdOverrides: { codex: 'codex', claude: 'claude' }, + agentDefaultEnv: { claude: { B: '2', A: '1' }, pi: { KEY: 'x' } } + } + ) + ).toEqual(['pi']) + expect(agentsWithChangedLaunchSettings({ agentCmdOverrides: { codex: 'codex' } }, {})).toEqual([ + 'codex' + ]) + }) +}) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts new file mode 100644 index 00000000000..fce96f166aa --- /dev/null +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts @@ -0,0 +1,172 @@ +import { parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { parse as parseToml } from 'smol-toml' +import { parse as parseYaml } from 'yaml' + +// Which keys in a project's own agent config can move a new chat off the account's model or +// effort. A file that sets none (permissions, hooks, MCP servers) leaves the account default. + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value)) + : null +} + +function setsAny(table: Record, keys: readonly string[]): boolean { + return keys.some((key) => table[key] !== undefined) +} + +// Claude settings keys that pick the model, its effort or effort cap, or which models Default may +// resolve to; `agent` applies that agent's model to the main thread. +const CLAUDE_MODEL_SETTINGS = [ + 'model', + 'agent', + 'effortLevel', + 'maxEffortLevel', + 'modelSettings', + 'availableModels', + 'enforceAvailableModels', + 'modelOverrides' +] as const + +/** The model, alias-target and effort env vars Claude Code reads, plus the provider switches + * that change which model ids exist at all. */ +function isClaudeModelEnvKey(key: string): boolean { + return ( + key === 'ANTHROPIC_MODEL' || + key === 'CLAUDE_CODE_EFFORT_LEVEL' || + /^ANTHROPIC_DEFAULT_([A-Z]+_)?MODEL$/.test(key) || + /^CLAUDE_CODE_USE_(BEDROCK|VERTEX|FOUNDRY|ANTHROPIC_AWS|ANTHROPIC_GOOGLE_CLOUD|MANTLE|GATEWAY)$/.test( + key + ) + ) +} + +/** True when a Claude settings file's text could pick a model or effort; unreadable JSON counts. */ +export function claudeSettingsMayPickModel(text: string): boolean { + let settings: Record | null + try { + settings = record(JSON.parse(text)) + } catch { + return true + } + if (!settings) { + return true + } + if (setsAny(settings, CLAUDE_MODEL_SETTINGS)) { + return true + } + const env = record(settings.env) + return env !== null && Object.keys(env).some(isClaudeModelEnvKey) +} + +const CODEX_MODEL_KEYS = ['model', 'model_reasoning_effort', 'model_provider'] as const + +/** True when a Codex `config.toml`'s text could pick a model or effort, directly or through a + * profile; unparseable TOML counts. */ +export function codexConfigMayPickModel(text: string): boolean { + let config: Record + try { + config = parseToml(text) + } catch { + return true + } + if (setsAny(config, CODEX_MODEL_KEYS) || config.profile !== undefined) { + return true + } + const profiles = record(config.profiles) + return ( + profiles !== null && + Object.values(profiles).some((profile) => { + const table = record(profile) + return table === null || setsAny(table, CODEX_MODEL_KEYS) + }) + ) +} + +// OpenCode config keys (1.x and 2.x schemas) that pick the chat's model or effort, or the providers +// a model-less config picks its default from. +const OPENCODE_MODEL_KEYS = [ + 'model', + 'default_agent', + 'provider', + 'providers', + 'enabled_providers', + 'disabled_providers' +] as const +// Tables of agents, each of which may carry its own model or variant (effort), or turn one off. +const OPENCODE_AGENT_TABLES = ['agent', 'agents', 'mode'] as const +const OPENCODE_AGENT_MODEL_KEYS = ['model', 'variant', 'disable'] as const + +/** True when an `opencode.json(c)`'s text could pick a model or effort; invalid JSONC counts. */ +export function openCodeConfigMayPickModel(text: string): boolean { + const errors: ParseError[] = [] + const config = record(parseJsonc(text, errors, { allowTrailingComma: true })) + if (errors.length > 0 || !config) { + return true + } + if (setsAny(config, OPENCODE_MODEL_KEYS)) { + return true + } + return OPENCODE_AGENT_TABLES.some((key) => { + if (config[key] === undefined) { + return false + } + const agents = record(config[key]) + return ( + agents === null || + Object.values(agents).some((agent) => { + const table = record(agent) + return table === null || setsAny(table, OPENCODE_AGENT_MODEL_KEYS) + }) + ) + }) +} + +/** True when an OpenCode agent file's frontmatter could pick a model or effort; unreadable counts. */ +export function openCodeAgentFileMayPickModel(text: string): boolean { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text) + if (!match) { + return false + } + try { + const frontmatter = record(parseYaml(match[1] ?? '')) + return frontmatter !== null && setsAny(frontmatter, OPENCODE_AGENT_MODEL_KEYS) + } catch { + return true + } +} + +// OMP settings that pick the model (its role models, the models and providers it may choose +// from) or its thinking level, plus the older Pi names for the same. +const OMP_MODEL_SETTINGS = [ + 'modelRoles', + 'enabledModels', + 'disabledProviders', + 'modelProviderOrder', + 'modelTags', + 'providers', + 'defaultThinkingLevel', + 'defaultModel', + 'defaultProvider' +] as const + +function setsOmpModelSetting(settings: Record): boolean { + return Object.keys(settings).some((key) => + OMP_MODEL_SETTINGS.some((setting) => key === setting || key.startsWith(`${setting}.`)) + ) +} + +/** True when a settings file OMP merges into its project settings could pick a model or + * thinking level; one it can't parse counts. `format` is the file's syntax. */ +export function ompSettingsMayPickModel(text: string, format: 'json' | 'yaml' | 'toml'): boolean { + let settings: Record | null + try { + const parsed: unknown = + format === 'json' ? JSON.parse(text) : format === 'yaml' ? parseYaml(text) : parseToml(text) + // An empty YAML file is no settings. + settings = parsed === null && format === 'yaml' ? {} : record(parsed) + } catch { + return true + } + return settings === null || setsOmpModelSetting(settings) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts index 0e78922ae7f..75bddb13249 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts @@ -12,9 +12,9 @@ function write(path: string, content = ''): void { } function mayOverride( - agent: 'claude' | 'codex', + agent: string, workspacePath: string, - accountHomePath = '/homes/a' + accountHomePath: string | null = '/homes/a' ) { return workspaceMayOverrideDefaultModel({ agent, workspacePath, accountHomePath }) } @@ -61,7 +61,148 @@ describe('workspaceMayOverrideDefaultModel', () => { expect(await mayOverride('codex', worktree, join(worktree, '.codex'))).toBe(false) }) - it('never vouches for a Claude default, whose user settings can pick the model', async () => { - expect(await mayOverride('claude', join(root, 'anywhere'))).toBe(true) + it('finds a Claude project settings file, shared or local, but not the account home', async () => { + const worktree = join(root, 'claude-repo') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + expect(await mayOverride('claude', worktree)).toBe(false) + // The user's own `.claude` is account config, which the CLI's resolution already covers. + write(join(worktree, '.claude', 'settings.json'), '{"model":"sonnet"}') + expect(await mayOverride('claude', worktree, join(worktree, '.claude'))).toBe(false) + expect(await mayOverride('claude', worktree)).toBe(true) + rmSync(join(worktree, '.claude', 'settings.json')) + write(join(worktree, '.claude', 'settings.local.json'), '{"effortLevel":"low"}') + expect(await mayOverride('claude', worktree)).toBe(true) + }) + + it('ignores Claude project settings that pick no model or effort', async () => { + const worktree = join(root, 'claude-permissions') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + // A committed permissions/hooks file, and the local file "always allow" writes. + write( + join(worktree, '.claude', 'settings.json'), + JSON.stringify({ permissions: { allow: ['Bash(pnpm test)'] }, hooks: {}, env: { CI: '1' } }) + ) + write( + join(worktree, '.claude', 'settings.local.json'), + JSON.stringify({ permissions: { allow: ['WebFetch'] } }) + ) + expect(await mayOverride('claude', worktree)).toBe(false) + }) + + it.each([ + ['a model', { model: 'opus' }], + ['a per-model effort', { modelSettings: { 'claude-opus-5-5': { effortLevel: 'high' } } }], + ['the model env var', { env: { ANTHROPIC_MODEL: 'claude-sonnet-5' } }], + ['an alias target', { env: { ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5' } }], + ['the effort env var', { env: { CLAUDE_CODE_EFFORT_LEVEL: 'low' } }], + ['another provider', { env: { CLAUDE_CODE_USE_BEDROCK: '1' } }], + ['an effort cap', { maxEffortLevel: 'medium' }], + ['a main-thread agent', { agent: 'reviewer' }], + ['Anthropic on AWS', { env: { CLAUDE_CODE_USE_ANTHROPIC_AWS: '1' } }], + ['Anthropic on Google Cloud', { env: { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' } }] + ])('counts Claude project settings that set %s', async (_name, settings) => { + const worktree = join(root, 'claude-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.claude', 'settings.json'), JSON.stringify(settings)) + expect(await mayOverride('claude', worktree)).toBe(true) + }) + + it('counts unreadable project config as an override', async () => { + const worktree = join(root, 'broken') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.claude', 'settings.json'), '{ "model": ') + write(join(worktree, '.codex', 'config.toml'), 'model = ') + expect(await mayOverride('claude', worktree)).toBe(true) + expect(await mayOverride('codex', worktree)).toBe(true) + }) + + it('ignores Codex project config that picks no model, effort or profile', async () => { + const worktree = join(root, 'codex-mcp') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.codex', 'config.toml'), '[mcp_servers.docs]\ncommand = "docs-mcp"\n') + expect(await mayOverride('codex', worktree)).toBe(false) + write( + join(worktree, '.codex', 'config.toml'), + '[profiles.deep]\nmodel_reasoning_effort = "xhigh"\n' + ) + expect(await mayOverride('codex', worktree)).toBe(true) + write(join(worktree, '.codex', 'config.toml'), 'profile = "deep"\n') + expect(await mayOverride('codex', worktree)).toBe(true) + }) + + it('never vouches for an agent whose project config it does not know', async () => { + expect(await mayOverride('pi', join(root, 'anywhere'))).toBe(true) + }) + + it('ignores OpenCode project config that picks no model, effort or provider', async () => { + const worktree = join(root, 'oc-plain') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + expect(await mayOverride('opencode', worktree, null)).toBe(false) + // JSONC with comments, MCP servers, permissions and a subagent prompt. + write( + join(worktree, 'opencode.jsonc'), + '{\n // team MCP\n "mcp": { "docs": { "type": "local" } },\n "permission": { "edit": "ask" },\n "agent": { "review": { "prompt": "be brief" } },\n}\n' + ) + write(join(worktree, '.opencode', 'agent', 'review.md'), '---\ndescription: Reviews\n---\nBody') + expect(await mayOverride('opencode', worktree, null)).toBe(false) + }) + + it.each([ + ['a model', 'opencode.json', { model: 'anthropic/claude-sonnet-5' }], + ['a default agent', 'opencode.json', { default_agent: 'plan' }], + ['an agent model', '.opencode/opencode.json', { agent: { build: { model: 'x/y' } } }], + ['a 2.x agent variant', 'opencode.jsonc', { agents: { build: { variant: 'high' } } }], + ['a mode model', 'opencode.json', { mode: { build: { model: 'x/y' } } }], + ['its providers', 'opencode.json', { enabled_providers: ['openai'] }], + ['a 2.x provider table', 'opencode.json', { providers: { openai: {} } }] + ])('counts OpenCode project config that sets %s', async (_name, file, config) => { + const worktree = join(root, 'oc-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, file), JSON.stringify(config)) + expect(await mayOverride('opencode', worktree, null)).toBe(true) + }) + + it('counts an OpenCode agent file whose frontmatter picks a model, and invalid JSONC', async () => { + const worktree = join(root, 'oc-agent-file') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.opencode', 'agents', 'nested', 'build.md'), '---\nmodel: x/y\n---\n') + expect(await mayOverride('opencode', worktree, null)).toBe(true) + rmSync(join(worktree, '.opencode'), { recursive: true }) + write(join(worktree, 'opencode.json'), '{ "model": ') + expect(await mayOverride('opencode', worktree, null)).toBe(true) + }) + + it('reads OpenCode config above the repository root too, as OpenCode 2.x does', async () => { + const repo = join(root, 'oc-above', 'repo') + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(root, 'oc-above', 'opencode.json'), '{"model":"x/y"}') + expect(await mayOverride('opencode', repo, null)).toBe(true) + }) + + it.each([ + ['.omp/config.yml', 'modelRoles:\n default: openai/gpt-6\n'], + ['.omp/settings.json', '{"defaultThinkingLevel":"low"}'], + ['.omp/settings.json', '{"enabledModels":["openai/*"]}'], + ['.claude/settings.json', '{"modelRoles":{"default":"x/y"}}'], + ['opencode.json', '{"modelProviderOrder":["openai"]}'], + ['.codex/config.toml', 'defaultThinkingLevel = "low"\n'], + ['.omp/config.yml', 'modelRoles: [\n'] + ])( + 'counts OMP project settings in %s that pick a model or thinking level', + async (file, text) => { + const worktree = join(root, 'omp-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, file), text) + expect(await mayOverride('omp', worktree, join(root, 'omp-home'))).toBe(true) + } + ) + + it('ignores OMP project settings that pick no model', async () => { + const worktree = join(root, 'omp-plain') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, '.omp', 'config.yml'), 'theme.dark: nord\n') + write(join(worktree, '.claude', 'settings.json'), '{"model":"opus"}') + write(join(worktree, 'opencode.json'), '{"mcp":{}}') + expect(await mayOverride('omp', worktree, join(root, 'omp-home'))).toBe(false) }) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index b9fefa7e19d..92023c03851 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -1,9 +1,17 @@ -import { stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { readdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' +import { + claudeSettingsMayPickModel, + codexConfigMayPickModel, + ompSettingsMayPickModel, + openCodeAgentFileMayPickModel, + openCodeConfigMayPickModel +} from './agent-project-model-config-keys' // A listing's default is the account's, but a chat runs in a workspace whose own -// config can pick another model. These checks only look for such config; they -// never read what it picks, so a hit means "name no default", not a model. +// config can pick another model. These checks only ask whether such config sets a +// model or effort key; they never use what it picks, so a hit means "name no default". async function exists(path: string): Promise { try { @@ -43,27 +51,165 @@ async function projectConfigDirectories(cwd: string): Promise { } } -/** Codex skips the `.codex` that is its own home; any other one with a config is a layer. */ -function directoryMayOverride(dir: string, accountHomePath: string): Promise { - const layer = join(dir, '.codex') - return layer === resolve(accountHomePath) - ? Promise.resolve(false) - : exists(join(layer, 'config.toml')) +type ProjectModelFile = { + path: string + mayPickModel: (text: string) => boolean +} + +type ProjectModelLayers = { + /** Files, relative to each directory of the chain, that the agent reads a model from. */ + files: readonly ProjectModelFile[] + /** Folders of `.md` agent definitions whose frontmatter may pick the model. */ + agentFolders?: readonly { path: string; recursive: boolean }[] + /** The agent reads every ancestor of the workspace, not only up to its project root. */ + wholeAncestry?: boolean +} + +const files = ( + paths: readonly string[], + mayPickModel: (text: string) => boolean +): ProjectModelFile[] => paths.map((path) => ({ path, mayPickModel })) + +// The project files each agent's CLI reads a model from. +const PROJECT_MODEL_LAYERS: Readonly> = { + codex: { files: files(['.codex/config.toml'], codexConfigMayPickModel) }, + claude: { + files: files( + ['.claude/settings.json', '.claude/settings.local.json'], + claudeSettingsMayPickModel + ) + }, + // OpenCode 2.x discovers `opencode.json(c)` and `.opencode/` in every ancestor of the workspace. + opencode: { + wholeAncestry: true, + files: files( + ['opencode.json', 'opencode.jsonc', '.opencode/opencode.json', '.opencode/opencode.jsonc'], + openCodeConfigMayPickModel + ), + agentFolders: [ + { path: '.opencode/agent', recursive: true }, + { path: '.opencode/agents', recursive: true }, + { path: '.opencode/mode', recursive: false }, + { path: '.opencode/modes', recursive: false } + ] + }, + // OMP merges its own `.omp` files and these other agents' project settings into its settings. + omp: { + files: [ + ...files( + [ + '.omp/settings.json', + '.claude/settings.json', + '.cursor/settings.json', + '.gemini/settings.json', + 'opencode.json' + ], + (text) => ompSettingsMayPickModel(text, 'json') + ), + ...files(['.omp/config.yml', '.omp/config.yaml'], (text) => + ompSettingsMayPickModel(text, 'yaml') + ), + ...files(['.codex/config.toml'], (text) => ompSettingsMayPickModel(text, 'toml')) + ] + } +} + +function isMissing(error: unknown): boolean { + const code = error instanceof Error && 'code' in error ? error.code : null + return code === 'ENOENT' || code === 'ENOTDIR' +} + +/** A missing file is no layer; one that exists but can't be read might pick anything. */ +async function fileMayPickModel( + path: string, + mayPickModel: (text: string) => boolean +): Promise { + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + return !isMissing(error) + } + return mayPickModel(text) +} + +// More agent files than this are not read one by one; they may pick anything. +const MAX_AGENT_FILES = 200 + +async function agentFolderMayPickModel(folder: string, recursive: boolean): Promise { + let entries: Dirent[] + try { + entries = await readdir(folder, { withFileTypes: true, recursive }) + } catch (error) { + return !isMissing(error) + } + const markdown = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + if (markdown.length > MAX_AGENT_FILES) { + return true + } + const found = await Promise.all( + markdown.map((entry) => + fileMayPickModel(join(entry.parentPath, entry.name), openCodeAgentFileMayPickModel) + ) + ) + return found.some(Boolean) +} + +/** A folder that is the agent's own home is account config, not a layer; any other file or agent + * folder setting a model or effort is one. */ +async function directoryMayOverride( + dir: string, + layers: ProjectModelLayers, + accountHomePath: string | null +): Promise { + const home = accountHomePath === null ? null : resolve(accountHomePath) + const found = await Promise.all([ + ...layers.files + .filter((file) => dirname(join(dir, file.path)) !== home) + .map((file) => fileMayPickModel(join(dir, file.path), file.mayPickModel)), + ...(layers.agentFolders ?? []).map((folder) => + agentFolderMayPickModel(join(dir, folder.path), folder.recursive) + ) + ]) + return found.some(Boolean) +} + +/** Every directory from the workspace up to the filesystem root. */ +function ancestry(cwd: string): string[] { + const chain: string[] = [] + for (let dir = resolve(cwd); ; dir = dirname(dir)) { + chain.push(dir) + if (dirname(dir) === dir) { + return chain + } + } +} + +/** Whether the agent's CLI may read its model from a project's own config. Grok reads its default + * model from user, managed and env config only. */ +export function agentReadsProjectModelConfig(agent: string): boolean { + return agent !== 'grok' } /** True when a new chat in `workspacePath` could run a model other than the listed default. */ export async function workspaceMayOverrideDefaultModel(input: { agent: string workspacePath: string - accountHomePath: string + accountHomePath: string | null }): Promise { - // Claude's user settings or env can pick another model wherever it runs; only Codex's listed default is its configured one. - if (input.agent !== 'codex') { + if (!agentReadsProjectModelConfig(input.agent)) { + return false + } + // An agent whose project layers are not known here may pick another model anywhere. + const layers = PROJECT_MODEL_LAYERS[input.agent] + if (!layers) { return true } - const dirs = await projectConfigDirectories(input.workspacePath) + const dirs = layers.wholeAncestry + ? ancestry(input.workspacePath) + : await projectConfigDirectories(input.workspacePath) const results = await Promise.all( - dirs.map((dir) => directoryMayOverride(dir, input.accountHomePath)) + dirs.map((dir) => directoryMayOverride(dir, layers, input.accountHomePath)) ) return results.some(Boolean) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 9aa03a27ab0..98aab1b6ba2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -52,6 +52,14 @@ import type { AgentSessionPromptResponse } from '../../../shared/agent-session-q import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' + +/** A live options read, plus what the child listed for its account's saved catalog. The host + * saves that listing and strips it before answering; an adapter whose listing reaches the + * catalog another way omits it. */ +export type StructuredAgentSessionLiveOptions = AgentSessionOptionsResult & { + catalogListing?: AgentModelCatalogLiveListing +} export class AgentSessionAcquisitionRefusal extends Error { readonly code = 'agent_session_operation_invalid' @@ -232,6 +240,8 @@ export type StructuredAgentSessionStartedEvent = { restoreSkippedOptions: readonly string[] /** Values the child showed it cannot run: a report naming the same value is not persisted. */ retiredOptions?: Readonly> + /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ + catalogListing?: AgentModelCatalogLiveListing /** The attempt's `optionRevision()` as the read behind this report began: a pick the host took * since makes it out of date. An earlier report never does, whenever the host took it. */ optionRevision: number @@ -416,7 +426,10 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise<() => AgentSessionOptionsResult> | undefined - readOptions?(input: { sessionId: string; fence: number }): Promise + readOptions?(input: { + sessionId: string + fence: number + }): Promise /** Effective options already known after acquisition, without discovering picker choices. */ readAcquisitionOptions?(input: { sessionId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 6b2bdaba959..9b370959be0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -16,6 +16,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { journalOpenReadRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentDefinition } from './structured-agent-definition' import type { StructuredAgentRegistry } from './structured-agent-registry' +import type { StructuredAgentSessionLiveOptions } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionOptionModels } from './structured-agent-session-option-models' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -130,7 +131,7 @@ export async function readStructuredAgentSessionOptions( options: await adapter.readOptions({ sessionId, fence: child.fence }) } }) - const live = + const live: StructuredAgentSessionLiveOptions | null = started.kind === 'live' ? started.options : started.kind === 'prepared' @@ -141,7 +142,17 @@ export async function readStructuredAgentSessionOptions( }) ) : null - const options = live ?? (await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId)) + let options: Omit + if (live) { + const { catalogListing, ...answer } = live + if (catalogListing) { + // What a running child listed is its account's catalog too, so the next chat opens warm. + context.deps.modelCatalog?.recordLiveListing(sessionId, catalogListing) + } + options = answer + } else { + options = await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId) + } // Re-acquired after the reads above: the handle they saw may have closed and reopened since. const session = await context.conversation(sessionId) const phase = store.getRecord(sessionId)?.rewind?.phase diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 5c59dbf0b3f..7543580e430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -54,7 +54,13 @@ function startHost(phase: 'ready' | 'starting'): void { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - modelCatalog: { read: vi.fn(), providerStarted }, + modelCatalog: { + read: vi.fn(), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 734f262f7b5..786c7360e46 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -6,6 +6,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { @@ -36,12 +37,14 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let lifecycle: Promise[] let statuses: AgentSessionStatusEvent[] +let savedListings: { sessionId: string; listing: AgentModelCatalogLiveListing }[] beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-provider-started-')) resetHostTestOperationIds() lifecycle = [] statuses = [] + savedListings = [] // A CLI whose own default is not the catalog's: startup reports it through get_settings, // since system/init arrives only with the first command. const claude = fakeClaude({ @@ -86,6 +89,13 @@ beforeEach(async () => { journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', + modelCatalog: { + read: async () => ({ origin: 'unknown' }), + recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), + prewarm: async () => {}, + stop: () => {}, + providerStarted: () => {} + }, now: () => NOW }) host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) @@ -163,4 +173,20 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('ready') }) + it('saves the account listing the child read at startup, though no view asks for options', async () => { + await host.attach(CALLER, claudeParams()) + expect(savedListings).toEqual([]) + + await claudeStartupSettled(adapter, SESSION) + await Promise.all(lifecycle) + + expect(savedListings).toEqual([ + { + sessionId: SESSION, + listing: expect.objectContaining({ + models: [expect.objectContaining({ id: 'claude-sonnet' })] + }) + } + ]) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 7aa18579ade..c25df2408bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -66,6 +66,9 @@ export async function settleStructuredAgentSessionProviderStarted( context.runtimeState.startupAttempts.ready(event.sessionId, child) const delivered = context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) + if (event.catalogListing) { + context.deps.modelCatalog?.recordLiveListing(event.sessionId, event.catalogListing) + } context.publishStatus?.(event.sessionId) return { delivered } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 28be32c1575..4c8c2ab2e6c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -70,7 +70,6 @@ export async function withdrawQueuedMessagesForOperation( }) } - /** Draft actions run like any mutation: admitted on the session's lane, the * conversation opened for the write. */ function mutateQueued( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 7efd39d0072..a99d7f7010a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -90,7 +90,13 @@ async function startHost(): Promise { setOption, ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) }, - modelCatalog: { read: vi.fn(), providerStarted }, + modelCatalog: { + read: vi.fn(), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, diff --git a/src/main/opencode/opencode-model-catalog-listing.test.ts b/src/main/opencode/opencode-model-catalog-listing.test.ts new file mode 100644 index 00000000000..999fcadf94a --- /dev/null +++ b/src/main/opencode/opencode-model-catalog-listing.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { parseOpenCodeModelListing } from './opencode-model-catalog-listing' + +// What `opencode models --verbose` prints: each id, then `JSON.stringify(model, null, 2)`. +function listing(models: Record): string { + return Object.entries(models) + .map(([id, model]) => `${id}\n${JSON.stringify(model, null, 2)}\n`) + .join('') +} + +describe('OpenCode verbose model listing', () => { + it('reads every model and its variants through multiline metadata', () => { + const stdout = listing({ + 'anthropic/claude-sonnet': { + id: 'claude-sonnet', + providerID: 'anthropic', + name: 'Claude Sonnet', + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + options: { nested: { '}': '{' } }, + variants: { high: { thinking: { budget: 16000 } }, max: { thinking: { budget: 32000 } } } + }, + 'opencode/grok-code': { + id: 'grok-code', + name: 'Grok Code', + variants: { default: {}, fast: {} } + }, + 'openai/gpt-plain': { id: 'gpt-plain', name: 'GPT Plain' } + }) + expect(parseOpenCodeModelListing(stdout)).toEqual([ + { + id: 'anthropic/claude-sonnet', + label: 'anthropic/Claude Sonnet', + isDefault: false, + efforts: [ + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, + { value: 'default', label: 'Default' } + ], + // No `default` variant: a new session runs the first one, as OpenCode's own ACP does. + defaultEffort: 'high' + }, + { + id: 'opencode/grok-code', + label: 'opencode/Grok Code', + isDefault: false, + efforts: [ + { value: 'default', label: 'Default' }, + { value: 'fast', label: 'Fast' } + ], + defaultEffort: 'default' + }, + { id: 'openai/gpt-plain', label: 'openai/GPT Plain', isDefault: false, efforts: [] } + ]) + }) + + it('reads the plain listing, CRLF endings and an empty object, and skips stray lines', () => { + const stdout = [ + 'Models cache refreshed', + 'openai/gpt-a', + 'openai/gpt-b', + '{}', + 'openai/gpt-c', + '{', + ' "name": "GPT C"', + '}', + '' + ].join('\r\n') + expect(parseOpenCodeModelListing(stdout).map((model) => [model.id, model.label])).toEqual([ + ['openai/gpt-a', 'openai/gpt-a'], + ['openai/gpt-b', 'openai/gpt-b'], + ['openai/gpt-c', 'openai/GPT C'] + ]) + }) + + it('keeps a model whose metadata does not parse, with no effort menu', () => { + const stdout = 'openai/gpt-broken\n{\n "name": \n}\nopenai/gpt-next\n' + expect(parseOpenCodeModelListing(stdout)).toEqual([ + { id: 'openai/gpt-broken', label: 'openai/gpt-broken', isDefault: false, efforts: [] }, + { id: 'openai/gpt-next', label: 'openai/gpt-next', isDefault: false, efforts: [] } + ]) + }) +}) diff --git a/src/main/opencode/opencode-model-catalog-listing.ts b/src/main/opencode/opencode-model-catalog-listing.ts new file mode 100644 index 00000000000..7f667cf3a49 --- /dev/null +++ b/src/main/opencode/opencode-model-catalog-listing.ts @@ -0,0 +1,88 @@ +// OpenCode's session-free model listing: `opencode models --verbose` prints each `provider/model` id +// on its own line, followed by that model's metadata as indented JSON closed by a bare `}` line. + +import { z } from 'zod' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit' + +export const OPENCODE_MODEL_LISTING_ARGS = ['models', '--verbose'] as const + +// OpenCode's effort sentinel for "no variant override", always offered beside a model's variants. +const DEFAULT_VARIANT = 'default' +const MODEL_JSON_LIMITS = { structuralTokens: 64 * 1024, nestingDepth: 32 } as const + +const modelMetaSchema = z.looseObject({ + name: z.string().min(1).optional(), + variants: z.record(z.string(), z.unknown()).optional() +}) + +/** Effort labels as OpenCode's own effort option spells them: `high-thinking` → `High Thinking`. */ +function variantLabel(variant: string): string { + return variant + .split(/[_-]/) + .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) + .join(' ') +} + +function modelOption(id: string, metadata: unknown): AgentSessionModelOption { + const meta = modelMetaSchema.safeParse(metadata) + const name = meta.success ? meta.data.name : undefined + const variants = meta.success ? Object.keys(meta.data.variants ?? {}) : [] + const providerId = id.slice(0, id.indexOf('/')) + return { + id, + label: name ? `${providerId}/${name}` : id, + // The listing names the account's models, not which one its config picks. + isDefault: false, + ...(variants.length > 0 + ? { + efforts: [...new Set([...variants, DEFAULT_VARIANT])].map((value) => ({ + value, + label: variantLabel(value) + })), + // What a new OpenCode session runs: its `default` variant, else the first it lists. + defaultEffort: variants.includes(DEFAULT_VARIANT) ? DEFAULT_VARIANT : variants[0] + } + : { efforts: [] }) + } +} + +function isModelIdLine(line: string): boolean { + const slash = line.indexOf('/') + return slash > 0 && slash < line.length - 1 && !/\s/.test(line) && !line.startsWith('{') +} + +/** Every listed model; one whose metadata does not parse keeps its id with no effort menu. */ +export function parseOpenCodeModelListing(stdout: string): AgentSessionModelOption[] { + const lines = stdout.split(/\r?\n/) + const models = new Map() + // A pretty-printed object closes on an unindented `}`; an empty one is `{}` on one line. + const closesObject = (line: string, first: boolean): boolean => + line.trimEnd() === '}' || (first && line.trimEnd() === '{}') + for (let index = 0; index < lines.length; index++) { + const id = (lines[index] ?? '').trim() + if (!isModelIdLine(id)) { + continue + } + let metadata: unknown + if (lines[index + 1]?.startsWith('{')) { + const start = index + 1 + let end = start + while (end < lines.length && !closesObject(lines[end] ?? '', end === start)) { + end++ + } + const text = lines.slice(start, end + 1).join('\n') + index = end + try { + assertJsonTextStructureWithinLimits(text, MODEL_JSON_LIMITS) + metadata = JSON.parse(text) + } catch { + metadata = undefined + } + } + if (!models.has(id)) { + models.set(id, modelOption(id, metadata)) + } + } + return [...models.values()] +} diff --git a/src/main/pi/rpc-model-catalog-probe.test.ts b/src/main/pi/rpc-model-catalog-probe.test.ts new file mode 100644 index 00000000000..d775c3520c2 --- /dev/null +++ b/src/main/pi/rpc-model-catalog-probe.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' + +const TABLE = [ + 'provider model context max-out thinking images', + 'anthropic claude-sonnet-4 200K 64K yes yes', + 'openai gpt-4o-mini 128K 16K no yes' +].join('\n') + +describe('Pi model catalog', () => { + it('reuses the listing identity, with the levels every reasoning model keeps and no default', () => { + expect(piModelCatalogFromListing(TABLE)).toEqual([ + { + id: 'anthropic/claude-sonnet-4', + label: expect.any(String), + isDefault: false, + efforts: ['off', 'minimal', 'low', 'medium', 'high'].map((level) => ({ + value: level, + label: level + })) + }, + { id: 'openai/gpt-4o-mini', label: expect.any(String), isDefault: false, efforts: [] } + ]) + }) + + it('lists under the pinned account with the chat launch env, through the shared runner', async () => { + const runListing = vi.fn(async () => TABLE) + const probe = createPiModelCatalogProbe({ + resolveEnvironment: async () => ({ + PATH: '/usr/bin', + HOME: '/home/user', + ORCA_PANE_KEY: 'p' + }), + resolveCommand: () => '/opt/pi/bin/pi', + probeVersion: async () => true, + homePath: '/home/user', + runListing + }) + const success = await probe({ variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi-a' }) + expect(success.models).toHaveLength(2) + expect(runListing).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/opt/pi/bin/pi', + args: ['--list-models'], + env: expect.objectContaining({ PI_CODING_AGENT_DIR: '/homes/pi-a' }) + }), + { site: 'pi-model-catalog-probe' } + ) + }) + + it('lists nothing from a Pi that runs no structured chat', async () => { + const runListing = vi.fn() + const probe = createPiModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/pi/bin/pi', + probeVersion: async () => false, + runListing + }) + await expect(probe({ variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi-a' })).rejects.toThrow( + /does not run structured chats/ + ) + expect(runListing).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts new file mode 100644 index 00000000000..b9c0f526ae8 --- /dev/null +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -0,0 +1,66 @@ +import { homedir } from 'node:os' +import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { parsePiModels } from '../../shared/commit-message-model-parsers' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import type { probeAgentCliVersion } from '../agent-cli-version-probe' +import type { + AgentModelCatalogProbe, + AgentModelCatalogSuccess +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { runAgentModelCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' +import { buildPiRpcLaunch } from './rpc-launch' +import { piRpcVersionSupported, resolvePiRpcCommand } from './rpc-launch-resolution' +import { PI_UNMAPPED_THINKING_LEVELS } from './rpc-options' + +export type PiModelCatalogProbeDeps = { + resolveEnvironment: () => Promise + resolveCommandSettings?: () => StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + /** Test seams; production runs the shared supervised listing. */ + probeVersion?: typeof probeAgentCliVersion + runListing?: typeof runAgentModelCatalogListing + homePath?: string +} + +/** Pi's table names each model and whether it reasons; its live thinking menu is model-specific, + * so a reasoning model lists the levels every map keeps and names no default. */ +export function piModelCatalogFromListing(stdout: string): AgentModelCatalogSuccess['models'] { + return parsePiModels(stdout).map((model) => ({ + id: model.id, + label: model.label, + isDefault: false, + efforts: model.thinkingLevels + ? PI_UNMAPPED_THINKING_LEVELS.map((level) => ({ value: level, label: level })) + : [] + })) +} + +/** Lists Pi's models with `pi --list-models`, under the binary and environment its chats run. */ +export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentModelCatalogProbe { + return async (accountHome, options): Promise => { + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } + } + env.PI_CODING_AGENT_DIR = requireLegacyAgentSessionAccountHome(accountHome).path + const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) + const cwd = deps.homePath ?? homedir() + if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { + throw new Error(`pi at ${command} does not run structured chats`) + } + // The chat launch's own env rules; only the arguments differ. + const launch = buildPiRpcLaunch({ command, cwd, env, fullAccess: true }) + const stdout = await (deps.runListing ?? runAgentModelCatalogListing)( + { ...launch, args: ['--list-models'] }, + { site: 'pi-model-catalog-probe', signal: options?.signal } + ) + const models = piModelCatalogFromListing(stdout) + if (models.length === 0) { + throw new Error('pi listed no models') + } + return { models, fastModeTierByModel: new Map(), origin: 'probe' } + } +} diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts index 9b59bec398a..4f75e064c6e 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -33,6 +33,10 @@ const commandsSchema = z.object({ }) const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const +/** The levels a reasoning model offers unless its thinking map removes one; the rest need a map entry. */ +export const PI_UNMAPPED_THINKING_LEVELS = THINKING_LEVELS.filter( + (level) => level !== 'xhigh' && level !== 'max' +) export function piModelOptionId(provider: string, modelId: string): string { if (!provider || !modelId || provider.includes('/')) { diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index fca0a4b411f..ac78e88301a 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -5,6 +5,7 @@ import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/s import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import type { StructuredAgentAdapterContext, + StructuredAgentModelCatalogContext, StructuredAgentRuntimeAdapter, StructuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations' @@ -15,6 +16,18 @@ import { resolvePiRpcCommand } from './rpc-launch-resolution' import { PiRpcSessionAdapter } from './rpc-session-adapter' +import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' + +/** The environment every Pi child starts from: a chat's launch and a catalog listing alike. */ +function piEnvironment({ + deps, + environment +}: StructuredAgentModelCatalogContext): () => Promise { + return async () => ({ + ...(await environment.resolveBaseEnvironment()), + ...deps.resolveAgentLaunchEnv?.('pi') + }) +} function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context @@ -22,10 +35,7 @@ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredA resolveLaunch: createPiRpcLaunchResolver({ store: context.store, resolveWorkspacePath: deps.resolveWorkspacePath, - resolveEnvironment: async () => ({ - ...(await context.environment.resolveBaseEnvironment()), - ...deps.resolveAgentLaunchEnv?.('pi') - }), + resolveEnvironment: piEnvironment(context), ...(deps.resolveAgentCommandSettings ? { resolveCommandSettings: deps.resolveAgentCommandSettings } : {}) @@ -51,6 +61,17 @@ function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredA export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { definition: PI_RPC_AGENT, createAdapter: createPiRpcAdapter, + modelCatalog: (context) => ({ + kind: 'probe', + // `--list-models` marks no model as the one Pi runs by default. + listingNamesConfiguredModel: false, + probe: createPiModelCatalogProbe({ + resolveEnvironment: piEnvironment(context), + ...(context.deps.resolveAgentCommandSettings + ? { resolveCommandSettings: context.deps.resolveAgentCommandSettings } + : {}) + }) + }), supportsLocation: supportsSupervisedProviderChildLocation, supportsLaunch: async ({ cwd, env, commandSettings }) => { let command: string diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 8f4feb7e59e..ee0a13c42c4 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -20,6 +20,7 @@ import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolu import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' import { applyPiRpcSessionOption, readPiRpcSessionOptions } from './rpc-options' +import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' @@ -225,8 +226,10 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { const session = this.session(input.sessionId, input.fence) return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) } - readOptions: NonNullable = (input) => - readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) + readOptions: NonNullable = async (input) => + withLiveCatalogListing( + await readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) + ) readCommands = (id: string) => this.sessions.get(id)?.commands readOptionRestoreFailures(id: string): readonly string[] { return this.sessions.get(id)?.skipped ?? [] diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index c73b0b8ccb0..31a869202a0 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -3,7 +3,8 @@ import { RetryableProcessExitProof } from '../../shared/child-process/retryable- import type { ProviderProcessLaunch } from './provider-process-launch' import { PROVIDER_SUPERVISOR_MAX_STOP_MS, - createProviderSpawnSpec + createProviderSpawnSpec, + type ProviderSupervisorLifetime } from './provider-process-supervisor' import { terminateProviderProcessTree, @@ -38,6 +39,8 @@ type ManagedProviderProcessOptions = { inheritedEnv?: NodeJS.ProcessEnv /** Defaults to "the root is gone". */ acceptClose?: (result: ProviderProcessCloseResult) => boolean + /** Defaults to `session`; a one-shot's stdin end completes its request instead of stopping it. */ + lifetime?: ProviderSupervisorLifetime /** Any stdout or stderr chunk: the child is doing something. */ onOutput?: () => void } @@ -73,7 +76,8 @@ export function spawnManagedProviderProcess( const closePolicy = options.policy ?? rootOnlyProviderClosePolicy const spec = createProviderSpawnSpec(launch, options.inheritedEnv ?? process.env, platform, { // A gone owner gets the close this provider's own close would make under the supervisor. - closeRequest: closePolicy(true).signalSupervisorOnClose ? 'stdin-end-and-sigterm' : 'stdin-end' + closeRequest: closePolicy(true).signalSupervisorOnClose ? 'stdin-end-and-sigterm' : 'stdin-end', + ...(options.lifetime ? { lifetime: options.lifetime } : {}) }) const policy = closePolicy(spec.supervised) if (spec.supervised && !(policy.gracefulExitMs >= PROVIDER_SUPERVISOR_MAX_STOP_MS)) { diff --git a/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts b/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts index dcd92f86cf6..96e5f5ca204 100644 --- a/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts +++ b/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts @@ -35,6 +35,26 @@ describe('native-chat settings RPC', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it.each(['opencode', 'pi', 'grok'])('saves picks for %s like any other agent', async (agent) => { + const updateClientNativeChatSessionOptions = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this method's handler reads only the two runtime members stubbed here. + const runtime = { + getRuntimeId: () => 'test-runtime', + updateClientNativeChatSessionOptions + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + const mutation = { + type: 'apply-picks' as const, + agent, + picks: [{ modelId: 'model-a', optionId: 'model' as const, value: 'model-a' }] + } + + const response = await dispatcher.dispatch(request(mutation)) + + expect(updateClientNativeChatSessionOptions).toHaveBeenCalledExactlyOnceWith(mutation) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('rejects malformed option deltas', async () => { const updateClientNativeChatSessionOptions = vi.fn() const runtime = { @@ -47,7 +67,7 @@ describe('native-chat settings RPC', () => { { type: 'apply-picks', agent: 'codex', picks: [] }, { type: 'apply-picks', - agent: 'opencode', + agent: 'not-an-agent', picks: [{ modelId: 'model', optionId: 'model', value: 'model' }] }, { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index bfd15a7c732..56db442ce2c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -103,6 +103,11 @@ describe('agentSession.modelCatalog', () => { ) expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, waitForListing: true }) }) + + it('passes a saved-only read through to the catalog', async () => { + await call('agentSession.modelCatalog', { agent: 'grok', savedOnly: true }, STRUCTURED_CLIENT) + expect(read).toHaveBeenCalledWith({ agent: 'grok', savedOnly: true }) + }) }) describe('agentSession.modelCatalog before anything built the host', () => { diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts new file mode 100644 index 00000000000..544676dc929 --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -0,0 +1,133 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import { registeredModelCatalogDiscovery } from './structured-agent-model-catalog-wiring' +import { acpModelCatalogDiscovery } from './structured-agent-model-catalog-discovery' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' + +// What each probe would have spawned; nothing is. +const spawned = vi.hoisted((): { commands: string[] } => ({ commands: [] })) +vi.mock('../provider-process/managed-provider-process', () => ({ + spawnManagedProviderProcess: (launch: { command: string }) => { + spawned.commands.push(launch.command) + throw new Error('no spawn in this test') + } +})) +vi.mock('../agent-cli-version-probe', () => ({ + probeAgentCliVersion: async (input: { program: string }) => { + spawned.commands.push(input.program) + throw new Error('no spawn in this test') + } +})) + +function context(): StructuredAgentModelCatalogContext { + const unused = async (): Promise => { + throw new Error('building a probe resolves nothing') + } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building a probe only captures resolvers; none of these deps is read until a probe runs. + deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: unused, + resolveCodexEnvironment: unused, + resolveClaudeInheritedEnv: unused + } + } +} + +describe('the model catalog contract on every registration', () => { + it.each( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + (registration) => [registration.definition.agent, registration] as const + ) + )('%s declares how its models are listed without a session', (_agent, registration) => { + const discovery = registration.modelCatalog(context()) + expect(['probe', 'unavailable']).toContain(discovery.kind) + if (discovery.kind === 'probe') { + expect(discovery.probe).toBeTypeOf('function') + } else { + expect(discovery.reason).not.toBe('') + } + }) + + it('gives the catalog service a probe for exactly the registrations that have one', () => { + const { probes } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + const listing = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter( + (registration) => registration.modelCatalog(context()).kind === 'probe' + ).map((registration) => registration.definition.agent) + expect(Object.keys(probes).sort()).toEqual([...listing].sort()) + // Every agent registered today but OMP lists its models without a session. + expect(listing.sort()).toEqual( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map((registration) => registration.definition.agent) + .filter((agent) => agent !== 'omp') + .sort() + ) + }) + + it('says per registration whether a listing names the model a new chat runs', () => { + const { listingNamesConfiguredModel } = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + // Codex's `model/list` names it. Grok's session-free `currentModelId` can differ from what a + // session runs, so Grok, like the rest, learns it from a chat with no pick. + expect([...listingNamesConfiguredModel].sort()).toEqual(['codex']) + }) + + it('maps an ACP agent with no session-free listing to an unavailable registration', () => { + const spec = acpLaunchSpecFor('grok')! + const discovery = acpModelCatalogDiscovery( + { ...spec, modelDiscovery: { kind: 'unavailable', reason: 'no listing without a session' } }, + context() + ) + expect(discovery).toEqual({ kind: 'unavailable', reason: 'no listing without a session' }) + }) + + it('lists through the command each agent is set to run, never a bare name', async () => { + const rig = mkdtempSync(join(tmpdir(), 'orca-catalog-overrides-')) + const home = join(rig, 'home') + const standIn = (agent: string): string => join(rig, `${agent}-standin`) + for (const agent of ['claude', 'codex', 'grok']) { + // Never run: the spawn is mocked; the override must only be a runnable file. + writeFileSync(standIn(agent), '#!/bin/sh\nexit 1\n') + chmodSync(standIn(agent), 0o755) + } + const settings = { + agentCmdOverrides: { + claude: standIn('claude'), + codex: standIn('codex'), + grok: standIn('grok') + } + } + const env = async () => ({ PATH: '/usr/bin' }) + const { probes } = registeredModelCatalogDiscovery(STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a probe reads only the command, env and auth resolvers given here. + deps: { + stateDirectory: '/state', + resolveClaudeCommand: () => resolveStructuredAgentCommand('claude', settings), + resolveCodexCommand: (options) => resolveStructuredAgentCommand('codex', settings, options), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: false }), + resolveAgentCommandSettings: () => settings + } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: env, + resolveCodexEnvironment: env, + resolveClaudeInheritedEnv: env + } + }) + const variables = { claude: 'CLAUDE_CONFIG_DIR', codex: 'CODEX_HOME', grok: 'GROK_HOME' } + for (const [agent, variable] of Object.entries(variables)) { + spawned.commands.length = 0 + await expect(probes[agent]!({ variable, path: home })).rejects.toThrow() + expect(spawned.commands, agent).toEqual([standIn(agent)]) + } + rmSync(rig, { recursive: true, force: true }) + }) +}) diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.ts b/src/main/runtime/structured-agent-model-catalog-discovery.ts new file mode 100644 index 00000000000..5bf0b86428e --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-discovery.ts @@ -0,0 +1,66 @@ +// Each registered agent's catalog probe, built from the SAME resolvers its session launches use: +// a probe under another binary or env could list models the user's sessions cannot see. + +import { createAcpModelCatalogProbe } from '../acp/acp-model-catalog-probe' +import type { AcpLaunchSpec } from '../acp/acp-launch-specs' +import { createClaudeModelCatalogProbe } from '../claude/claude-model-catalog-probe' +import { createCodexModelCatalogProbe } from '../codex/codex-model-catalog-probe' +import type { AgentModelCatalogDiscovery } from '../native-chat/agent-model-catalog/agent-model-catalog-discovery' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' + +export function codexModelCatalogDiscovery({ + deps, + environment +}: StructuredAgentModelCatalogContext): AgentModelCatalogDiscovery { + return { + kind: 'probe', + // `model/list` marks the account's configured model as its default. + listingNamesConfiguredModel: true, + probe: createCodexModelCatalogProbe({ + resolveAccountKind: deps.resolveCodexAccountKind, + ...(deps.prepareCodexCatalogProbeHome + ? { prepareHome: deps.prepareCodexCatalogProbeHome } + : {}), + resolveEnvironment: environment.resolveCodexEnvironment, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }) + } +} + +export function claudeModelCatalogDiscovery({ + deps, + environment +}: StructuredAgentModelCatalogContext): AgentModelCatalogDiscovery { + return { + kind: 'probe', + // Claude's settings or env can pick a model other than the one its listing recommends. + listingNamesConfiguredModel: false, + probe: createClaudeModelCatalogProbe({ + resolveInheritedEnv: environment.resolveClaudeInheritedEnv, + resolveAuthPolicy: deps.resolveClaudeAuthPolicy, + ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) + }) + } +} + +export function acpModelCatalogDiscovery( + spec: AcpLaunchSpec, + { deps, environment }: StructuredAgentModelCatalogContext +): AgentModelCatalogDiscovery { + const discovery = spec.modelDiscovery + if (discovery.kind === 'unavailable') { + return { kind: 'unavailable', reason: discovery.reason } + } + return { + kind: 'probe', + listingNamesConfiguredModel: discovery.listingNamesConfiguredModel, + probe: createAcpModelCatalogProbe(spec, { + resolveEnvironment: environment.resolveBaseEnvironment, + ...(deps.resolveAgentLaunchEnv ? { resolveLaunchEnv: deps.resolveAgentLaunchEnv } : {}), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}) + }) + } +} diff --git a/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts new file mode 100644 index 00000000000..192bce87c4d --- /dev/null +++ b/src/main/runtime/structured-agent-model-catalog-first-frame.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionModelOption } from '../../shared/agent-session-wire' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { AcpStructuredOptions } from '../acp/acp-structured-options' +import { GROK_ACP_DIALECT } from '../acp/acp-dialects/grok-dialect' +import { grokModelCatalogFromState } from '../acp/acp-dialects/grok-model-catalog' +import { agentModelCatalogFingerprintForRecord } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import { workspaceMayOverrideDefaultModel } from '../native-chat/agent-model-catalog/agent-project-model-override' +import { + AgentModelCatalogStore, + withLiveCatalogListing, + type AgentModelCatalogProbe +} from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from './structured-agent-runtime-registrations' +import type { StructuredAgentModelCatalogContext } from './structured-agent-runtime-registrations' +import { registeredModelCatalogDiscovery } from './structured-agent-model-catalog-wiring' + +// The first frame a new chat paints, from the real registration list through the host service to +// the shared picker: nothing here passes the "names its default" answer by hand. + +function context(): StructuredAgentModelCatalogContext { + const unused = async (): Promise => { + throw new Error('building a probe resolves nothing') + } + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building a probe only captures resolvers; none of these deps is read until a probe runs. + deps: { stateDirectory: '/state' } as StructuredAgentModelCatalogContext['deps'], + environment: { + resolveBaseEnvironment: unused, + resolveCodexEnvironment: unused, + resolveClaudeInheritedEnv: unused + } + } +} + +const HOMES: Record = { + grok: { variable: 'GROK_HOME', path: '/homes/grok' }, + pi: { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' }, + claude: { variable: 'CLAUDE_CONFIG_DIR', path: '/homes/claude' } +} + +const GROK_EFFORT_META = { + supportsReasoningEffort: true, + reasoningEfforts: [ + { value: 'low', id: 'low' }, + { value: 'high', id: 'high', default: true } + ] +} + +// What Grok's `initialize` reports with no session; its `currentModelId` names no default. +const GROK_LISTING = grokModelCatalogFromState({ + currentModelId: 'grok-4', + availableModels: [ + { modelId: 'grok-4', name: 'Grok 4', _meta: GROK_EFFORT_META }, + { modelId: 'grok-3-mini', name: 'Grok 3 Mini' } + ] +}) + +function grokRecord(): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only provider, accountHome and location. + return { + sessionId: 'grok-session', + provider: 'grok', + accountHome: HOMES.grok, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord +} + +function service( + store: AgentModelCatalogStore, + listings: Record, + records: AgentSessionRecord[] = [] +) { + const registered = registeredModelCatalogDiscovery( + STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + context() + ) + const probes: Record = {} + for (const [agent, models] of Object.entries(listings)) { + probes[agent] = async () => ({ models, fastModeTierByModel: new Map(), origin: 'probe' }) + } + return createAgentModelCatalogService({ + store, + getRecord: (sessionId) => records.find((record) => record.sessionId === sessionId), + drivesRecord: () => true, + resolveAccountHome: async (agent) => HOMES[agent]!, + probes, + workspaceMayOverrideDefaultModel, + listingNamesConfiguredModel: registered.listingNamesConfiguredModel + }) +} + +async function newChatFirstFrame( + catalog: ReturnType, + agent: string, + workspacePath?: string +) { + const host = await catalog.read({ + agent, + waitForListing: true, + ...(workspacePath ? { workspacePath } : {}) + }) + const seed = structuredAgentSessionSeedCatalog(agent) + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent, seed), + seed, + host, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const select = (id: string) => { + const descriptor = snapshot.find((entry) => entry.id === id) + return descriptor?.kind.type === 'select' ? descriptor.kind : null + } + return { host, state, model: select('model'), effort: select('effort') } +} + +describe('a new chat’s first frame from the host catalog', () => { + it('lists a warm Grok account’s models without naming the one `initialize` computed', async () => { + const catalog = service(new AgentModelCatalogStore(), { grok: GROK_LISTING }) + const { host, state, model } = await newChatFirstFrame(catalog, 'grok', '/repo') + // A session can run another model; a chat started with no pick names the default instead. + expect(host).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) + expect(model?.choices.map((choice) => choice.value)).toEqual(['grok-4', 'grok-3-mini']) + expect(model?.currentValue).toBeUndefined() + expect(state.record.model).toBeUndefined() + }) + + it('lists a warm Pi account’s models without naming one its listing does not name', async () => { + const catalog = service(new AgentModelCatalogStore(), { + pi: [ + { + id: 'anthropic/sonnet', + label: 'Sonnet', + isDefault: false, + efforts: [{ value: 'high', label: 'high' }] + } + ] + }) + const { host, model } = await newChatFirstFrame(catalog, 'pi') + expect(host).toMatchObject({ listingNamesConfiguredModel: false }) + expect(model?.choices.map((choice) => choice.value)).toEqual(['anthropic/sonnet']) + expect(model?.currentValue).toBeUndefined() + }) +}) + +describe('a running chat’s listing, saved for the next chat', () => { + it('keeps a Grok account warm from a live session, without its picks becoming defaults', async () => { + const store = new AgentModelCatalogStore() + const record = grokRecord() + const catalog = service(store, { grok: GROK_LISTING }, [record]) + await newChatFirstFrame(catalog, 'grok') + // The running chat switched to the mini model and reports a list with one more model. + const options = new AcpStructuredOptions(GROK_ACP_DIALECT) + options.adoptSession({ + models: { + currentModelId: 'grok-3-mini', + availableModels: [ + { modelId: 'grok-4', name: 'Grok 4', _meta: GROK_EFFORT_META }, + { modelId: 'grok-3-mini', name: 'Grok 3 Mini' }, + { modelId: 'grok-code', name: 'Grok Code' } + ] + } + }) + catalog.recordLiveListing( + record.sessionId, + withLiveCatalogListing(options.read()).catalogListing + ) + + const { host, model, effort } = await newChatFirstFrame(catalog, 'grok') + expect(host.origin).toBe('live-session') + expect(model?.choices.map((choice) => choice.value)).toEqual([ + 'grok-4', + 'grok-3-mini', + 'grok-code' + ]) + // A loaded session's model is its own, never the account's default. + expect(model?.currentValue).toBeUndefined() + expect(effort).toBeNull() + }) + + it('keeps per-model efforts a session only knows for the model it runs', async () => { + const store = new AgentModelCatalogStore() + const record: AgentSessionRecord = { + ...grokRecord(), + provider: 'opencode', + sessionId: 'oc', + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } } + } + const listed: AgentSessionModelOption[] = [ + { id: 'a', label: 'A', isDefault: false, efforts: [{ value: 'max', label: 'Max' }] }, + { id: 'b', label: 'B', isDefault: false, efforts: [{ value: 'low', label: 'Low' }] } + ] + const catalog = service(store, {}, [record]) + store.recordSuccess( + agentModelCatalogFingerprintForRecord(record), + 'opencode', + { + models: listed, + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + catalog.recordLiveListing(record.sessionId, { + models: [ + { id: 'a', label: 'A', isDefault: false, efforts: [{ value: 'high', label: 'High' }] }, + { id: 'b', label: 'B', isDefault: false, efforts: [] } + ] + }) + const read = await catalog.read({ agent: 'opencode', sessionId: 'oc' }) + expect(read.origin === 'unknown' ? null : read.models.map((m) => m.efforts)).toEqual([ + [{ value: 'high', label: 'High' }], + [{ value: 'low', label: 'Low' }] + ]) + }) + + it('saves a Claude live listing through the same step as every other agent', async () => { + const store = new AgentModelCatalogStore() + const record = { ...grokRecord(), provider: 'claude', accountHome: HOMES.claude! } + const catalog = service(store, {}, [record]) + catalog.recordLiveListing(record.sessionId, { + models: [{ id: 'opus', label: 'Opus', isDefault: false, efforts: [] }] + }) + const read = await catalog.read({ agent: 'claude', sessionId: record.sessionId }) + expect(read.origin === 'unknown' ? null : read.models.map((m) => m.id)).toEqual(['opus']) + }) +}) diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index f6b22651f3a..f72f5eb4b4f 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -5,15 +5,19 @@ import { type AgentModelCatalogService, type AgentModelCatalogServiceDeps } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { createCodexModelCatalogProbe } from '../codex/codex-model-catalog-probe' -import { createClaudeModelCatalogProbe } from '../claude/claude-model-catalog-probe' -import { workspaceMayOverrideDefaultModel } from '../native-chat/agent-model-catalog/agent-project-model-override' -import type { ClaudeStructuredLaunchResolverDeps } from '../claude/claude-structured-launch-resolution' -import type { CodexStructuredLaunchResolverDeps } from '../codex/codex-structured-launch-resolution' +import { + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel +} from '../native-chat/agent-model-catalog/agent-project-model-override' +import type { AgentModelCatalogProbe } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { AgentSessionRecordStore } from './agent-session-record-store' -import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' +import type { + StructuredAgentModelCatalogContext, + StructuredAgentRuntimeRegistration +} from './structured-agent-runtime-registrations' import type { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { agentDrivesSession } from '../native-chat/agent-session-wire/structured-agent-session-provider-support' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' // The store is process-global; hydrate it from disk at most once per process. let persistenceAttached = false @@ -34,36 +38,43 @@ export async function attachAgentModelCatalogPersistenceOnce( } } +type CatalogRegistration = Pick + +/** What the catalog service learns from the registration list, the only agent roster. */ +export function registeredModelCatalogDiscovery( + registrations: readonly CatalogRegistration[], + context: StructuredAgentModelCatalogContext +): { + probes: Record + listingNamesConfiguredModel: Set +} { + const probes: Record = {} + const listingNamesConfiguredModel = new Set() + for (const registration of registrations) { + const { agent } = registration.definition + const discovery = registration.modelCatalog(context) + if (discovery.kind === 'probe') { + probes[agent] = discovery.probe + if (discovery.listingNamesConfiguredModel) { + listingNamesConfiguredModel.add(agent) + } + } + } + return { probes, listingNamesConfiguredModel } +} + /** * The host-deps slice for the catalog surface: hydrates the store from disk * once, then builds the service — or nothing, when the runtime cannot name * the currently selected account, in which case every catalog read answers * `unknown` rather than guessing a key. - * - * The probes list through the SAME invocation resolvers session launches use; - * a probe under a different env or binary could list models the user's - * sessions cannot see, under their key. */ export async function modelCatalogHostDeps(input: { - store: Pick + store: Pick agents: Pick - deps: Pick< - StructuredAgentSessionRuntimeDeps, - | 'stateDirectory' - | 'resolveAgentAccountHome' - | 'resolveCodexAccountKind' - | 'prepareCodexCatalogProbeHome' - | 'resolveCodexCommand' - | 'resolveClaudeCommand' - | 'resolveClaudeLaunchEnv' - | 'resolveClaudeAuthPolicy' - > - envResolvers: { - resolveCodexEnvironment: NonNullable - resolveClaudeInheritedEnv: NonNullable< - ClaudeStructuredLaunchResolverDeps['resolveInheritedEnv'] - > - } + registrations: readonly CatalogRegistration[] + deps: StructuredAgentModelCatalogContext['deps'] + environment: StructuredAgentModelCatalogContext['environment'] }): Promise<{ modelCatalog?: AgentModelCatalogService }> { await attachAgentModelCatalogPersistenceOnce(input.deps.stateDirectory) const { deps } = input @@ -75,27 +86,27 @@ export async function modelCatalogHostDeps(input: { getRecord: (sessionId) => input.store.getRecord(sessionId) ?? undefined, drivesRecord: (record) => agentDrivesSession(input.agents, record), resolveAccountHome: deps.resolveAgentAccountHome, + recordWorkspacePath: async (record) => + record.launchDirectory ?? + (await deps.resolveWorkspacePath(record.location.workspaceId).catch(() => null)), + agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel, - probes: { - codex: createCodexModelCatalogProbe({ - resolveAccountKind: deps.resolveCodexAccountKind, - ...(deps.prepareCodexCatalogProbeHome - ? { prepareHome: deps.prepareCodexCatalogProbeHome } - : {}), - resolveEnvironment: input.envResolvers.resolveCodexEnvironment, - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }), - claude: createClaudeModelCatalogProbe({ - resolveInheritedEnv: input.envResolvers.resolveClaudeInheritedEnv, - resolveAuthPolicy: deps.resolveClaudeAuthPolicy, - ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), - ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) - }) - } + hasChatRecords: (agent) => + input.store.listRecords().some((record) => record.provider === agent), + ...registeredModelCatalogDiscovery(input.registrations, { + deps, + environment: input.environment + }) }) return { modelCatalog } } +/** The installed host lists what a changed account or agent setting made cold; with no host yet, + * its install lists everything anyway. */ +export function prewarmStructuredAgentModelCatalogs(): void { + void getStructuredAgentSessionHost()?.deps.modelCatalog?.prewarm() +} + // Re-exported so the runtime deps type can reference the resolver shape without // importing the service module directly. export type RuntimeAgentAccountHomeResolver = AgentModelCatalogServiceDeps['resolveAccountHome'] diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index b3fda4e25e2..bba20e17847 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -51,6 +51,12 @@ import { } from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' import { PI_RPC_RUNTIME_REGISTRATION } from '../pi/rpc-runtime-registration' +import type { AgentModelCatalogDiscovery } from '../native-chat/agent-model-catalog/agent-model-catalog-discovery' +import { + acpModelCatalogDiscovery, + claudeModelCatalogDiscovery, + codexModelCatalogDiscovery +} from './structured-agent-model-catalog-discovery' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { @@ -91,9 +97,18 @@ export type StructuredAgentAccountHomeServices = { workspaceTrustSettings: () => Parameters[0]['settings'] } +/** What a catalog probe is built from: the same runtime resolvers its sessions launch through. */ +export type StructuredAgentModelCatalogContext = Pick< + StructuredAgentAdapterContext, + 'deps' | 'environment' +> + export type StructuredAgentRuntimeRegistration = { definition: StructuredAgentDefinition createAdapter: (context: StructuredAgentAdapterContext) => StructuredAgentRuntimeAdapter + /** How this agent's models are listed before any session of it runs. Required: an agent that + * cannot list says so, rather than being left out of the shared catalog. */ + modelCatalog: (context: StructuredAgentModelCatalogContext) => AgentModelCatalogDiscovery /** Whether this agent's chats can run at `location`; answered without building the host. */ supportsLocation: (location: AgentSessionExecutionLocation) => boolean /** Whether the agent installed on this host runs a structured chat, asked at create with the @@ -187,8 +202,7 @@ function createClaudeAdapter( onDispatchSettledLate: followUps.onDispatchSettledLate, onSessionIdle: followUps.releaseUnansweredDispatches, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - modelCatalog: agentModelCatalogStore + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) } @@ -215,6 +229,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio } : {}), resolveAccountHome: ({ launchEnv }) => spec.account.resolve({ launchEnv }), + modelCatalog: (context) => acpModelCatalogDiscovery(spec, context), createAdapter: (context) => { const { deps, store, followUps } = context const readJournal = (sessionId: string) => @@ -279,6 +294,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, + modelCatalog: codexModelCatalogDiscovery, supportsLocation: (location) => supportsCodexStructuredLocation(location), resolveAccountHome: async (request, services) => agentSessionAccountHome( @@ -289,6 +305,7 @@ export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRun { definition: CLAUDE_STRUCTURED_AGENT, createAdapter: createClaudeAdapter, + modelCatalog: claudeModelCatalogDiscovery, supportsLocation: supportsClaudeStructuredLocation, resolveAccountHome: async ({ launchEnv, location }, services) => agentSessionAccountHome( diff --git a/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts new file mode 100644 index 00000000000..7fe0c594586 --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime-model-catalog-lifetime.test.ts @@ -0,0 +1,61 @@ +// The host's catalog listings live exactly as long as the host: installing it starts the +// runtime-start prewarm, and tearing it down (which quit does too) stops every listing it started. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' +import type * as ModelCatalogWiring from './structured-agent-model-catalog-wiring' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +const catalog = vi.hoisted((): { service: AgentModelCatalogService | null } => ({ service: null })) + +vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => ({ + ...(await importOriginal()), + modelCatalogHostDeps: async () => (catalog.service ? { modelCatalog: catalog.service } : {}) +})) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-catalog-lifetime-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + catalog.service = null + await rm(root, { recursive: true, force: true }) +}) + +describe('the host catalog lifetime', () => { + it('prewarms once the host is installed and stops its listings at teardown', async () => { + const service = { + read: vi.fn(async () => ({ origin: 'unknown' as const })), + recordLiveListing: vi.fn(), + prewarm: vi.fn(async () => {}), + stop: vi.fn(), + providerStarted: vi.fn() + } + catalog.service = service + await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) + expect(service.prewarm).toHaveBeenCalledOnce() + expect(service.stop).not.toHaveBeenCalled() + + await stopStructuredAgentSessionRuntime() + expect(service.stop).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 8853d7428f2..5c7d87623c2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -326,7 +326,13 @@ async function installOnJournal( ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), - ...(await modelCatalogHostDeps({ store, agents, deps, envResolvers })) + ...(await modelCatalogHostDeps({ + store, + agents, + registrations: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + deps, + environment: envResolvers + })) }) if (deps.attentionDelivery) { const installed = host @@ -347,6 +353,9 @@ async function installOnJournal( }) } setStructuredAgentSessionHost(host) + // The host starts with its runtime, local or remote, so this is the runtime-start listing. + const modelCatalog = host.deps.modelCatalog + void modelCatalog?.prewarm() installAgentSessionAttachments({ stateDirectory: deps.stateDirectory, store, @@ -367,6 +376,9 @@ async function installOnJournal( adapter, journalDatabase, waitForRecovery: lifecycle.drain, - ...(stopVisualsSweep ? { stopBackgroundWork: stopVisualsSweep } : {}) + stopBackgroundWork: () => { + stopVisualsSweep?.() + modelCatalog?.stop() + } } } diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4533ccc3e88..e55e8dcbd74 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -45,7 +45,6 @@ export type StructuredClaudeRuntimeAdapterDeps = { attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] - modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] onLifecycleEvent: (event: StructuredAgentSessionLifecycleEvent) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] @@ -167,8 +166,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.logger ? { logger: deps.logger } : {}), ...openClaudeConnectionOf(deps), - ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), - ...(deps.modelCatalog ? { modelCatalog: deps.modelCatalog } : {}) + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) } diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts index b4ad5117d80..716b4830ef8 100644 --- a/src/main/startup/codex-launch-preparation.ts +++ b/src/main/startup/codex-launch-preparation.ts @@ -60,3 +60,19 @@ export async function prepareCodexRuntimeHomeForLaunch( } return runtimeHomePath } + +/** The runtime's structured Codex home resolvers: a launch prepares the home that a record-less + * catalog read resolves without side effects, so a new chat's picker and the chat share one key. */ +export const codexStructuredLaunchHomeResolvers = { + prepareCodexStructuredLaunch: ({ launchEnv }: { launchEnv: NodeJS.ProcessEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), + // Why throw like prepare does: a null from an uninitialized service would + // map to the system home and key a catalog read to the wrong account. + resolveCodexStructuredLaunchHome: ({ launchEnv }: { launchEnv: NodeJS.ProcessEnv }) => { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome) { + throw new Error('Codex runtime home service is not initialized') + } + return runtimeHome.resolveHostCodexHomePathForLaunchReadOnly(launchEnv) + } +} diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 07581d44911..d1ad8e1b3bb 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -27,10 +27,23 @@ import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-sele import { agentHookServer } from '../agent-hooks/server' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' import { browserManager } from '../browser/browser-manager' +import { prewarmStructuredAgentModelCatalogs } from '../runtime/structured-agent-model-catalog-wiring' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { expireAgentModelCatalogFailuresForSettings } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' +import { createAgentModelCatalogSettingsExpiry } from '../native-chat/agent-model-catalog/agent-model-catalog-account-expiry' import { mainProcessState as state } from './main-process-state' +// Settings that change which account or binary a new chat's agent runs under. +const MODEL_CATALOG_ACCOUNT_SETTINGS = [ + 'activeCodexManagedAccountId', + 'activeCodexManagedAccountIdsByRuntime', + 'codexManagedAccounts', + 'activeClaudeManagedAccountId', + 'activeClaudeManagedAccountIdsByRuntime', + 'claudeManagedAccounts', + 'agentDefaultEnv', + 'agentCmdOverrides' +] as const + export function initializeMainProcessAccountServices(): void { const store = state.store if ( @@ -79,12 +92,20 @@ export function initializeMainProcessAccountServices(): void { state.rateLimits, store.getSettings() ) + const expireModelCatalogs = createAgentModelCatalogSettingsExpiry( + agentModelCatalogStore, + store.getSettings() + ) store.onSettingsChanged((updates, settings) => { - expireAgentModelCatalogFailuresForSettings(agentModelCatalogStore, updates) + expireModelCatalogs(updates, settings) // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. void syncAccountRuntimeTargets(updates, settings).catch((error) => console.warn('[rate-limits] Failed to apply account runtime target:', error) ) + // An account switch would otherwise leave the next chat's picker on a cold catalog. + if (MODEL_CATALOG_ACCOUNT_SETTINGS.some((key) => key in updates)) { + prewarmStructuredAgentModelCatalogs() + } if ('opencodeSessionCookie' in updates || 'opencodeWorkspaceId' in updates) { state.rateLimits?.invalidateOpenCodeGoCredentialState() void state.rateLimits?.refresh().catch((error: unknown) => { diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 5639540083a..ffaf17c45a6 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -25,7 +25,10 @@ import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import { mainProcessState as state } from './main-process-state' -import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { + codexStructuredLaunchHomeResolvers, + prepareCodexRuntimeHomeForLaunch +} from './codex-launch-preparation' import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' import { SkillCloudService } from '../skills/skill-cloud-service' @@ -143,17 +146,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()), preparePinnedLaunchHome: (home) => prepareCodexPinnedLaunchHome(home) }), - prepareCodexStructuredLaunch: ({ launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv), - // Why throw like prepare does: a null from an uninitialized service would - // map to the system home and key a catalog read to the wrong account. - resolveCodexStructuredLaunchHome: ({ launchEnv }) => { - const runtimeHome = state.codexRuntimeHome - if (!runtimeHome) { - throw new Error('Codex runtime home service is not initialized') - } - return runtimeHome.resolveHostCodexHomePathForLaunchReadOnly(launchEnv) - }, + ...codexStructuredLaunchHomeResolvers, prepareCodexCatalogProbeHome: (homePath) => state.codexRuntimeHome?.prepareHostCodexHomeForReadOnlyAppServer( homePath, diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index bab4c09e1e8..ebe65572811 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/os import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useHostStructuredAgentsSync } from '../runtime/host-structured-agents-sync' +import { useHostModelCatalogSnapshotsSync } from '../runtime/host-model-catalog-snapshots-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' @@ -43,6 +44,7 @@ export function useAppShellServices(): void { useWebSessionTabsSync() useLocalStructuredSessionTabsSync() useHostStructuredAgentsSync() + useHostModelCatalogSnapshotsSync() // Subscribe to IPC push events useIpcEvents() useProfileStateSaveDelayNotice() diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx deleted file mode 100644 index a6221046e46..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment happy-dom - -// Against the real picker: a `/model` request opens it once with the search focused, and a period -// while the host still lists models neither opens it later nor reopens one the user closed. - -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - SessionOptionDescriptor, - SessionOptionsSurface -} from '../../../../shared/native-chat-session-options' - -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string, values?: Record) => - values - ? Object.entries(values).reduce( - (text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)), - fallback - ) - : fallback -})) - -import { TooltipProvider } from '@/components/ui/tooltip' -import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' -import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' - -function model(pending: boolean): SessionOptionDescriptor { - return { - id: 'model', - label: 'Model', - category: 'model', - kind: { - type: 'select', - currentValue: 'opus', - choices: [ - { value: 'opus', label: 'Opus 4.8' }, - { value: 'sonnet', label: 'Sonnet 5' } - ] - }, - valueSource: 'applied', - transport: 'agent-session', - settable: !pending, - ...(pending ? { choicesPending: true as const } : {}) - } -} - -const surface: SessionOptionsSurface = { - getSnapshot: () => [], - setOption: vi.fn(async () => ({ snapshot: [] })), - invokeAction: vi.fn(async () => ({ snapshot: [] })), - subscribe: () => () => {} -} - -function view(pending: boolean, request: NativeChatOptionPickerRequest | null): React.JSX.Element { - return ( - -