From d0a19ca766b865b470c105f109807a7c5d5591e7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:31:30 -0700 Subject: [PATCH 1/8] fix(native-chat): a new chat's effort pill keeps its first-frame value before the agent starts A chat with no model pick runs the catalog's default model at that model's default effort. The at-rest options answer now says so for every agent, not only those whose live child reports the model's default, so the effort pill no longer blanks and then jumps when the agent starts. --- .../structured-agent-session-options-read.ts | 5 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 228 ++++++++++++++++++ 2 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts 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 7810ace25677..71bb547c2d3d 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 @@ -74,10 +74,11 @@ async function readStructuredAgentSessionOptionsAtRest( const model = saved.model ?? (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) - // As a live child answers: the pick, else the model's default where the agent reports that. + // As a live child answers: the pick, else the model's default where the agent reports that. With + // no model pick, the catalog's default model runs at its default effort, as the first frame shows. const effort = saved.effort ?? - (rules?.effortDefaultsToModel + (rules?.effortDefaultsToModel || saved.model === undefined ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts new file mode 100644 index 000000000000..c3ff35eb341d --- /dev/null +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -0,0 +1,228 @@ +// A new chat reads its options at rest before its agent starts. That answer must name the effort +// the catalog's first frame showed, or the effort pill blanks and later jumps when the agent starts. + +import { describe, expect, it } from 'vitest' +import type { AgentSessionAccountHome, 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, + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../shared/structured-agent-session-options' +import { grokModelCatalogFromState } from '../acp/acp-dialects/grok-model-catalog' +import { acpLaunchSpecFor } from '../acp/acp-launch-specs' +import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +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 { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' +import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' +import { PI_RPC_AGENT } from '../pi/rpc-agent-definition' +import { piModelCatalogFromListing } from '../pi/rpc-model-catalog-probe' + +const unused = (): never => { + throw new Error('an at-rest read never calls the adapter') +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry only checks that declared capabilities have these methods; the at-rest read calls none. +const ADAPTER = { + compact: unused, + changeThreadGoal: unused, + rewind: unused, + recoverRewind: unused +} as unknown as StructuredAgentSessionAdapter + +const AGENTS = new StructuredAgentRegistry( + [ + CODEX_STRUCTURED_AGENT, + PI_RPC_AGENT, + acpStructuredAgentDefinition(acpLaunchSpecFor('grok')!) + ].map((definition) => ({ definition, adapter: ADAPTER })) +) + +const HOMES: Record = { + codex: { variable: 'CODEX_HOME', path: '/homes/codex' }, + pi: { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' }, + grok: { variable: 'GROK_HOME', path: '/homes/grok' } +} + +// `model/list` marks the configured model as its default, at the effort its own row names. +const CODEX_LISTING: AgentSessionModelOption[] = [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-5.5-mini', + label: 'GPT-5.5 Mini', + isDefault: false, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } +] + +const GROK_LISTING = grokModelCatalogFromState({ + currentModelId: 'grok-4', + availableModels: [ + { + modelId: 'grok-4', + name: 'Grok 4', + _meta: { + supportsReasoningEffort: true, + reasoningEfforts: [ + { value: 'low', id: 'low' }, + { value: 'medium', id: 'medium', default: true }, + { value: 'high', id: 'high' } + ] + } + }, + { modelId: 'grok-3-mini', name: 'Grok 3 Mini' } + ] +}) + +const PI_LISTING = piModelCatalogFromListing( + [ + 'provider model context max-out thinking images', + 'anthropic claude-sonnet-4 200K 64K yes yes', + 'openai gpt-6 400K 128K yes yes' + ].join('\n') +) + +type Account = { + models: AgentSessionModelOption[] + /** What an earlier chat with no pick resolved, for an agent whose listing names no default. */ + configured?: { modelId: string; effort: string } +} + +const ACCOUNTS: Record = { + codex: { models: CODEX_LISTING }, + grok: { models: GROK_LISTING, configured: { modelId: 'grok-4', effort: 'medium' } }, + pi: { models: PI_LISTING, configured: { modelId: 'openai/gpt-6', effort: 'high' } } +} + +function newChatRecord(agent: string, options: Record = {}): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service and the at-rest read touch only these fields. + return { + sessionId: `${agent}-new`, + provider: agent, + accountHome: HOMES[agent]!, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + }, + options + } as AgentSessionRecord +} + +/** A host whose catalog for `agent`'s account is warm, holding one new chat that never sent. */ +function host(agent: string, record: AgentSessionRecord) { + const store = new AgentModelCatalogStore() + const fingerprint = agentModelCatalogFingerprintForRecord(record) + const account = ACCOUNTS[agent]! + store.recordSuccess( + fingerprint, + agent, + { models: account.models, fastModeTierByModel: new Map(), origin: 'probe' }, + 'discovery' + ) + if (account.configured) { + store.recordConfiguredDefault(fingerprint, account.configured) + } + const modelCatalog = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), + drivesRecord: () => true, + resolveAccountHome: async (name) => HOMES[name]!, + listingNamesConfiguredModel: new Set(['codex']) + }) + const resting = { + child: null, + params: { provider: agent }, + journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the at-rest read touches only these members. + const context = { + deps: { adapter: {}, agents: AGENTS, store: { getRecord: () => record }, modelCatalog }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + return { modelCatalog, context } +} + +function pills(state: StructuredAgentSessionOptionState) { + const snapshot = structuredAgentSessionOptionSnapshot(state) + const pill = (id: string) => { + const kind = snapshot.find((descriptor) => descriptor.id === id)?.kind + return kind?.type === 'select' + ? { value: kind.currentValue, choices: kind.choices.map((choice) => choice.value) } + : null + } + return { model: pill('model'), effort: pill('effort') } +} + +/** The pane's frames: the host catalog first, then the chat's own options read at rest. */ +async function newChatFrames(agent: string, options: Record = {}) { + const record = newChatRecord(agent, options) + const { modelCatalog, context } = host(agent, record) + const seed = structuredAgentSessionSeedCatalog(agent) + const first = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent, seed), + seed, + await modelCatalog.read({ agent, waitForListing: true }), + { newLaunch: true } + ) + // Read before applying the answer: applying updates the state's option record in place. + const firstPills = pills(first) + const atRest = await readStructuredAgentSessionOptions(context, record.sessionId) + const settled = applyStructuredAgentSessionOptions(first, seed, atRest) + return { atRest, first: firstPills, settled: pills(settled) } +} + +describe("a new chat's effort pill before its agent starts", () => { + it.each([ + ['codex', 'gpt-5.5', 'medium'], + ['grok', 'grok-4', 'medium'], + ['pi', 'openai/gpt-6', 'high'] + ])( + '%s keeps the first frame’s model and effort through the at-rest read', + async (agent, model, effort) => { + const { atRest, first, settled } = await newChatFrames(agent) + + expect(first.model?.value).toBe(model) + expect(first.effort?.value).toBe(effort) + expect(settled).toEqual(first) + expect(atRest.current).toEqual({ model, effort }) + } + ) + + it('keeps a saved effort over the default model’s', async () => { + const { atRest, settled } = await newChatFrames('grok', { effort: 'high' }) + + expect(atRest.current).toEqual({ model: 'grok-4', effort: 'high' }) + expect(settled.effort?.value).toBe('high') + }) + + it('leaves the effort of a saved model to the agent, as its running child reports it', async () => { + const { atRest } = await newChatFrames('codex', { model: 'gpt-5.5-mini' }) + + expect(atRest.current).toEqual({ model: 'gpt-5.5-mini' }) + }) +}) From 8a327bfa44a8546098213522348522c9091b0663 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:50:15 -0700 Subject: [PATCH 2/8] fix(native-chat): read a chat's at-rest options for the folder it runs in The at-rest options read asked the model catalog with no workspace, so it always named the account's default model and effort, even in a repo whose own agent config could pick another. The new chat's first frame, read for that repo, names nothing there. The at-rest read now asks for the session's own folder, so both frames agree on the model and the effort. --- .../agent-model-catalog-service.ts | 33 +++++--- .../structured-agent-session-options-read.ts | 6 +- ...ured-agent-new-chat-effort-at-rest.test.ts | 82 ++++++++++++++++--- 3 files changed, 94 insertions(+), 27 deletions(-) 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 92dff5766e7b..a6654d8ad139 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 @@ -53,6 +53,8 @@ export type AgentModelCatalogService = { sessionId?: string /** Where a new chat would run; null when one was named but is not a local directory. */ workspacePath?: string | null + /** Answer for the folder the named session runs in, as its new-chat read did. */ + inSessionWorkspace?: true /** 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 @@ -132,6 +134,17 @@ async function newChatCatalogKey( } } +/** The local folder a session's agent runs in; null where this host cannot look into it. */ +async function sessionWorkspacePath( + deps: AgentModelCatalogServiceDeps, + record: AgentSessionRecord +): Promise { + if (record.location.wslDistro !== null || !deps.recordWorkspacePath) { + return null + } + return deps.recordWorkspacePath(record).catch(() => null) +} + /** 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. */ @@ -142,14 +155,10 @@ async function recordConfiguredDefault( choice: AgentModelCatalogConfiguredChoice | null ): Promise { const accountHome = record.accountHome - if ( - record.location.wslDistro !== null || - !deps.recordWorkspacePath || - !deps.workspaceMayOverrideDefaultModel - ) { + if (!deps.workspaceMayOverrideDefaultModel) { return } - const workspacePath = await deps.recordWorkspacePath(record) + const workspacePath = await sessionWorkspacePath(deps, record) if ( !workspacePath || (await deps.workspaceMayOverrideDefaultModel({ @@ -231,6 +240,11 @@ export function createAgentModelCatalogService( } const accountHomePath = probeHome && isLegacyAgentSessionAccountHome(probeHome) ? probeHome.path : null + const workspacePath = params.inSessionWorkspace + ? record + ? await sessionWorkspacePath(deps, record) + : null + : params.workspacePath await recordingDefaults.get(fingerprint) let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] @@ -244,12 +258,7 @@ export function createAgentModelCatalogService( const result = listed ? resultFromEntry( listed, - await workspaceKeepsListedDefault( - deps, - params.agent, - params.workspacePath, - accountHomePath - ), + await workspaceKeepsListedDefault(deps, params.agent, workspacePath, accountHomePath), deps.listingNamesConfiguredModel?.has(params.agent) === true ) : null 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 71bb547c2d3d..709776a04f24 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 @@ -59,7 +59,7 @@ async function readStructuredAgentSessionOptionsAtRest( } const rules = restingOptionRules(deps.agents, record) const catalog = (await deps.modelCatalog - ?.read({ agent: record.provider, sessionId }) + ?.read({ agent: record.provider, sessionId, inSessionWorkspace: true }) .catch(() => null)) ?? { origin: 'unknown' as const } // With no catalog for the account, the list a running child of this agent falls back to. const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models @@ -74,8 +74,8 @@ async function readStructuredAgentSessionOptionsAtRest( const model = saved.model ?? (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) - // As a live child answers: the pick, else the model's default where the agent reports that. With - // no model pick, the catalog's default model runs at its default effort, as the first frame shows. + // The pick, else the model's default where a live child reports that. With no model pick, the + // chat runs the catalog's default at its listed default effort, as its first frame showed. const effort = saved.effort ?? (rules?.effortDefaultsToModel || saved.model === undefined diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index c3ff35eb341d..c3dd7640ee09 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -1,7 +1,10 @@ -// A new chat reads its options at rest before its agent starts. That answer must name the effort -// the catalog's first frame showed, or the effort pill blanks and later jumps when the agent starts. +// A new chat reads its options at rest before its agent starts. That answer must name the model and +// effort the catalog's first frame showed for its workspace, or the picker changes before any start. -import { describe, expect, it } from 'vitest' +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 type { AgentSessionModelOption } from '../../shared/agent-session-wire' import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' @@ -19,6 +22,10 @@ import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definiti 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 { 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 { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionMutationContext } from '../native-chat/agent-session-wire/structured-agent-session-host-mutations' import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' @@ -87,7 +94,8 @@ const GROK_LISTING = grokModelCatalogFromState({ reasoningEfforts: [ { value: 'low', id: 'low' }, { value: 'medium', id: 'medium', default: true }, - { value: 'high', id: 'high' } + { value: 'high', id: 'high' }, + { value: 'max', id: 'max' } ] } }, @@ -111,16 +119,42 @@ type Account = { const ACCOUNTS: Record = { codex: { models: CODEX_LISTING }, - grok: { models: GROK_LISTING, configured: { modelId: 'grok-4', effort: 'medium' } }, + // The account's config runs Grok 4 at max, not the listing's own default of medium. + grok: { models: GROK_LISTING, configured: { modelId: 'grok-4', effort: 'max' } }, pi: { models: PI_LISTING, configured: { modelId: 'openai/gpt-6', effort: 'high' } } } -function newChatRecord(agent: string, options: Record = {}): AgentSessionRecord { +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-new-chat-effort-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** A repo checkout holding `files`, as the project's own agent config. */ +function workspace(files: Record = {}): string { + const dir = join(root, 'repo') + 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 newChatRecord( + agent: string, + workspacePath: string, + options: Record +): AgentSessionRecord { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service and the at-rest read touch only these fields. return { sessionId: `${agent}-new`, provider: agent, accountHome: HOMES[agent]!, + launchDirectory: workspacePath, location: { executionHostId: 'local', wslDistro: null, @@ -150,7 +184,10 @@ function host(agent: string, record: AgentSessionRecord) { getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), drivesRecord: () => true, resolveAccountHome: async (name) => HOMES[name]!, - listingNamesConfiguredModel: new Set(['codex']) + listingNamesConfiguredModel: new Set(['codex']), + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel }) const resting = { child: null, @@ -178,15 +215,21 @@ function pills(state: StructuredAgentSessionOptionState) { return { model: pill('model'), effort: pill('effort') } } -/** The pane's frames: the host catalog first, then the chat's own options read at rest. */ -async function newChatFrames(agent: string, options: Record = {}) { - const record = newChatRecord(agent, options) +/** The pane's frames: the host catalog for the chat's workspace first, as the new chat's read + * names it, then the chat's own options read at rest. */ +async function newChatFrames( + agent: string, + options: Record = {}, + projectFiles: Record = {} +) { + const workspacePath = workspace(projectFiles) + const record = newChatRecord(agent, workspacePath, options) const { modelCatalog, context } = host(agent, record) const seed = structuredAgentSessionSeedCatalog(agent) const first = applyStructuredAgentSessionModelCatalog( createStructuredAgentSessionOptionState(agent, seed), seed, - await modelCatalog.read({ agent, waitForListing: true }), + await modelCatalog.read({ agent, waitForListing: true, workspacePath }), { newLaunch: true } ) // Read before applying the answer: applying updates the state's option record in place. @@ -199,7 +242,7 @@ async function newChatFrames(agent: string, options: Record = {} describe("a new chat's effort pill before its agent starts", () => { it.each([ ['codex', 'gpt-5.5', 'medium'], - ['grok', 'grok-4', 'medium'], + ['grok', 'grok-4', 'max'], ['pi', 'openai/gpt-6', 'high'] ])( '%s keeps the first frame’s model and effort through the at-rest read', @@ -213,6 +256,21 @@ describe("a new chat's effort pill before its agent starts", () => { } ) + it.each([ + ['codex', '.codex/config.toml', 'model_reasoning_effort = "high"\n'], + ['pi', '.pi/settings.json', '{ "defaultThinkingLevel": "low" }'] + ])( + '%s names no default model or effort at rest where the project’s config may pick one', + async (agent, file, text) => { + const { atRest, first, settled } = await newChatFrames(agent, {}, { [file]: text }) + + expect(first.model?.value).toBeUndefined() + expect(first.effort).toBeNull() + expect(settled).toEqual(first) + expect(atRest.current).toEqual({}) + } + ) + it('keeps a saved effort over the default model’s', async () => { const { atRest, settled } = await newChatFrames('grok', { effort: 'high' }) From 23378a1c3ba99bc568e5200e319e0c6a54c0874c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:34:27 -0700 Subject: [PATCH 3/8] fix(native-chat): scope the at-rest options read only for clients that take no model A client that needs a model in every options answer (mobile today, older desktops) refuses an answer naming none, and loses that read's commands with it. Only a client advertising the optional-model capability gets the answer for the chat's own folder; any other keeps today's account-default answer. --- ...structured-agent-session-host-mutations.ts | 6 +- .../structured-agent-session-options-read.ts | 18 +- .../structured-agent-session-options-read.ts | 5 +- ...nt-session-options-workspace-scope.test.ts | 155 ++++++++++++++++++ ...ured-agent-new-chat-effort-at-rest.test.ts | 34 +++- 5 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index cb9cd10fea68..578ffaa35934 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -46,7 +46,8 @@ export type { StructuredAgentSessionMutationContext } from './structured-agent-s import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { readStructuredAgentSessionOptions, - recordStructuredAgentSessionOptionIntent + recordStructuredAgentSessionOptionIntent, + type StructuredAgentSessionOptionsReader } from './structured-agent-session-options-read' export function sendStructuredAgentSessionTurn( @@ -244,6 +245,7 @@ export function structuredAgentSessionMutationDelegates( caller: StructuredAgentSessionCaller, params: Parameters[2] ) => changeStructuredAgentSessionThreadGoal(context(), caller, params), - readOptions: (sessionId: string) => readStructuredAgentSessionOptions(context(), sessionId) + readOptions: (sessionId: string, reader?: StructuredAgentSessionOptionsReader) => + readStructuredAgentSessionOptions(context(), sessionId, reader) } } 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 709776a04f24..44545666cd50 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 @@ -24,6 +24,10 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick +/** What the asking client takes. One that reads an answer naming no model is answered for the + * chat's own folder, as its first frame is; any other keeps the account's default. */ +export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } + /** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ export function structuredAgentSessionOptionOverridesRefusal( agents: Pick, @@ -51,7 +55,8 @@ function restingOptionRules( async function readStructuredAgentSessionOptionsAtRest( deps: Pick, - sessionId: string + sessionId: string, + reader: StructuredAgentSessionOptionsReader ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { @@ -59,7 +64,11 @@ async function readStructuredAgentSessionOptionsAtRest( } const rules = restingOptionRules(deps.agents, record) const catalog = (await deps.modelCatalog - ?.read({ agent: record.provider, sessionId, inSessionWorkspace: true }) + ?.read({ + agent: record.provider, + sessionId, + ...(reader.readsWithoutModel ? { inSessionWorkspace: true as const } : {}) + }) .catch(() => null)) ?? { origin: 'unknown' as const } // With no catalog for the account, the list a running child of this agent falls back to. const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models @@ -126,7 +135,8 @@ export async function readStructuredAgentSessionOptions( StructuredAgentSessionMutationContext, 'deps' | 'serialize' | 'openConversation' | 'conversation' >, - sessionId: string + sessionId: string, + reader: StructuredAgentSessionOptionsReader = { readsWithoutModel: false } ): Promise { const { adapter, agents, store } = context.deps const started = await context.serialize(sessionId, async () => { @@ -169,7 +179,7 @@ export async function readStructuredAgentSessionOptions( } options = answer } else { - options = await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId) + options = await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) } // Re-acquired after the reads above: the handle they saw may have closed and reopened since. const session = await context.conversation(sessionId) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 2b0683d08a36..346b17ee8f0f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -44,10 +44,11 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { + const readsWithoutModel = clientReadsOptionsWithoutModel(ctx) const result = await ( await requireInstalledStructuredHost(ctx, params.sessionId) - ).readOptions(params.sessionId) - if (result.current.model === undefined && !clientReadsOptionsWithoutModel(ctx)) { + ).readOptions(params.sessionId, { readsWithoutModel }) + if (result.current.model === undefined && !readsWithoutModel) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'clientCapabilityMissing' }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts new file mode 100644 index 000000000000..95187dc9c7a2 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -0,0 +1,155 @@ +import '../unused-default-rpc-methods.test-fixture' +// A chat at rest in a repo whose own config may pick its model: a client that takes an answer naming +// no model gets the folder's answer, while one that needs a model keeps the account default and +// every other part of the read, never a refusal. + +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../../shared/agent-session-optional-model-capability' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +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 { 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 { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + createRestTestRig, + foundRestTestChat, + REST_TEST_SESSION as SESSION, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const OLDER_CLIENT = { + clientId: 'phone-1', + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: 'connection-1' +} +const CURRENT_CLIENT = { + ...OLDER_CLIENT, + clientId: 'desktop-1', + clientKind: 'runtime' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY + ] +} + +let rig: RestTestRig +let dispatcher: RpcDispatcher + +async function readOptions(client: typeof OLDER_CLIENT | typeof CURRENT_CLIENT) { + const replies: unknown[] = [] + await dispatcher.dispatchStreaming( + { + id: 'request-1', + authToken: 'token', + method: 'agentSession.options', + params: { sessionId: SESSION } + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + return replies[0] +} + +/** A Codex chat with no pick, at rest in a repo whose `.codex/config.toml` sets the effort. */ +async function chatInConfiguredRepo(): Promise { + await foundRestTestChat(rig) + // The chat never picked: its next start runs whatever its config resolves. + await rig.store.replaceSessionOptions({ + sessionId: SESSION, + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + options: {}, + now: rig.clock.now + }) + const repo = join(rig.root, 'repo') + await mkdir(join(repo, '.codex'), { recursive: true }) + await writeFile(join(repo, '.git'), 'gitdir: /elsewhere') + await writeFile(join(repo, '.codex', 'config.toml'), 'model_reasoning_effort = "high"\n') + const store = new AgentModelCatalogStore() + store.recordSuccess( + agentModelCatalogFingerprintForRecord(rig.store.getRecord(SESSION)!), + '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: 'probe' + }, + 'discovery' + ) + const modelCatalog = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => rig.store.getRecord(sessionId) ?? undefined, + drivesRecord: () => true, + resolveAccountHome: async () => rig.store.getRecord(SESSION)!.accountHome, + listingNamesConfiguredModel: new Set(['codex']), + recordWorkspacePath: async () => repo, + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel + }) + await rig.restart({ modelCatalog }) + setStructuredAgentSessionHost(rig.host) + rig.adapter.acquire.mockClear() +} + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + setStructuredAgentSessionHost(rig.host) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getClientSettings').mockImplementation( + () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the RPC gate reads only this one setting. + ({ experimentalNativeChat: true }) as ReturnType + ) + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +describe('options at rest in a repo whose config may pick the model', () => { + it('keeps the account default and the commands for a client that needs a model', async () => { + await chatInConfiguredRepo() + + expect(await readOptions(OLDER_CLIENT)).toMatchObject({ + ok: true, + result: { + current: { model: 'gpt-5.5', effort: 'medium' }, + conversationCommands: ['clear'] + } + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + }) + + it('names no model or effort for a client that takes an answer without one', async () => { + await chatInConfiguredRepo() + + const reply = await readOptions(CURRENT_CLIENT) + + expect(reply).toMatchObject({ ok: true, result: { conversationCommands: ['clear'] } }) + expect(reply).not.toHaveProperty('result.current.model') + expect(reply).not.toHaveProperty('result.current.effort') + }) +}) diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index c3dd7640ee09..58b04e5adee7 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -219,11 +219,15 @@ function pills(state: StructuredAgentSessionOptionState) { * names it, then the chat's own options read at rest. */ async function newChatFrames( agent: string, - options: Record = {}, - projectFiles: Record = {} + chat: { + saved?: Record + projectFiles?: Record + /** False for a client that refuses an answer naming no model, as mobile does today. */ + readsWithoutModel?: boolean + } = {} ) { - const workspacePath = workspace(projectFiles) - const record = newChatRecord(agent, workspacePath, options) + const workspacePath = workspace(chat.projectFiles) + const record = newChatRecord(agent, workspacePath, chat.saved ?? {}) const { modelCatalog, context } = host(agent, record) const seed = structuredAgentSessionSeedCatalog(agent) const first = applyStructuredAgentSessionModelCatalog( @@ -234,7 +238,9 @@ async function newChatFrames( ) // Read before applying the answer: applying updates the state's option record in place. const firstPills = pills(first) - const atRest = await readStructuredAgentSessionOptions(context, record.sessionId) + const atRest = await readStructuredAgentSessionOptions(context, record.sessionId, { + readsWithoutModel: chat.readsWithoutModel ?? true + }) const settled = applyStructuredAgentSessionOptions(first, seed, atRest) return { atRest, first: firstPills, settled: pills(settled) } } @@ -262,7 +268,9 @@ describe("a new chat's effort pill before its agent starts", () => { ])( '%s names no default model or effort at rest where the project’s config may pick one', async (agent, file, text) => { - const { atRest, first, settled } = await newChatFrames(agent, {}, { [file]: text }) + const { atRest, first, settled } = await newChatFrames(agent, { + projectFiles: { [file]: text } + }) expect(first.model?.value).toBeUndefined() expect(first.effort).toBeNull() @@ -271,15 +279,25 @@ describe("a new chat's effort pill before its agent starts", () => { } ) + it('keeps today’s answer, with its commands, for a client that needs a model', async () => { + const { atRest } = await newChatFrames('codex', { + projectFiles: { '.codex/config.toml': 'model_reasoning_effort = "high"\n' }, + readsWithoutModel: false + }) + + expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium' }) + expect(atRest.conversationCommands).toEqual(['clear', 'compact']) + }) + it('keeps a saved effort over the default model’s', async () => { - const { atRest, settled } = await newChatFrames('grok', { effort: 'high' }) + const { atRest, settled } = await newChatFrames('grok', { saved: { effort: 'high' } }) expect(atRest.current).toEqual({ model: 'grok-4', effort: 'high' }) expect(settled.effort?.value).toBe('high') }) it('leaves the effort of a saved model to the agent, as its running child reports it', async () => { - const { atRest } = await newChatFrames('codex', { model: 'gpt-5.5-mini' }) + const { atRest } = await newChatFrames('codex', { saved: { model: 'gpt-5.5-mini' } }) expect(atRest.current).toEqual({ model: 'gpt-5.5-mini' }) }) From acc4ada4f71c6ea3eefcfafb882eac3e02cc8341 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:32:09 -0700 Subject: [PATCH 4/8] test: run the at-rest workspace-scope RPC test in the Node SQLite project --- config/scripts/vitest-sqlite-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index d839482b65c9..bc02a84f8cc1 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -218,6 +218,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', 'src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts', From 717d4207a2f53497615e2751ebb15231cc425f78 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:54:03 -0700 Subject: [PATCH 5/8] test: build catalog listings so the fixtures compile before and after main drops the tier map --- ...nt-session-options-workspace-scope.test.ts | 34 ++++++++++--------- ...ured-agent-new-chat-effort-at-rest.test.ts | 13 +++---- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts index 95187dc9c7a2..45807619ba63 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -75,25 +75,27 @@ async function chatInConfiguredRepo(): Promise { await writeFile(join(repo, '.git'), 'gitdir: /elsewhere') await writeFile(join(repo, '.codex', 'config.toml'), 'model_reasoning_effort = "high"\n') const store = new AgentModelCatalogStore() + // Not inline: this base's listing still requires the tier map that main has since dropped. + const listing = { + 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: 'probe' as const + } store.recordSuccess( agentModelCatalogFingerprintForRecord(rig.store.getRecord(SESSION)!), '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: 'probe' - }, + listing, 'discovery' ) const modelCatalog = createAgentModelCatalogService({ diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 58b04e5adee7..91d6cbb28ccb 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -170,12 +170,13 @@ function host(agent: string, record: AgentSessionRecord) { const store = new AgentModelCatalogStore() const fingerprint = agentModelCatalogFingerprintForRecord(record) const account = ACCOUNTS[agent]! - store.recordSuccess( - fingerprint, - agent, - { models: account.models, fastModeTierByModel: new Map(), origin: 'probe' }, - 'discovery' - ) + // Not inline: this base's listing still requires the tier map that main has since dropped. + const listing = { + models: account.models, + fastModeTierByModel: new Map(), + origin: 'probe' as const + } + store.recordSuccess(fingerprint, agent, listing, 'discovery') if (account.configured) { store.recordConfiguredDefault(fingerprint, account.configured) } From dd21d90dc6f3e8effede9fed5b5fa0d101dd32ae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:59:39 -0700 Subject: [PATCH 6/8] =?UTF-8?q?fix(native-chat):=20one=20at-rest=20rule=20?= =?UTF-8?q?=E2=80=94=20the=20chat's=20folder=20decides=20its=20default=20f?= =?UTF-8?q?or=20every=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder-scoped catalog read now decides the default for every client. Where it names one, the answer names that model and its effort. Where it names none, a client that takes a model-less answer gets none, and one that needs a model gets the account default model with no effort: exactly what it got before. The reader is now required, and the first frame and the at-rest read share one rule for which locations name a local folder. --- ...structured-host-configured-default.test.ts | 8 +++- .../acp-structured-host-live-listing.test.ts | 2 +- .../acp-structured-host-option-hang.test.ts | 8 +++- ...claude-context-usage-unloaded-turn.test.ts | 2 +- ...-structured-effort-default-at-rest.test.ts | 10 ++--- .../agent-model-catalog-service.ts | 6 ++- ...structured-agent-session-host-mutations.ts | 2 +- ...tructured-agent-session-idle-sweep.test.ts | 4 +- ...uctured-agent-session-options-read.test.ts | 19 +++++--- .../structured-agent-session-options-read.ts | 43 +++++++++++++------ ...red-agent-session-provider-started.test.ts | 2 +- ...gent-session-registered-definition.test.ts | 2 +- ...t-session-restart-offer-withdrawal.test.ts | 2 +- ...startup-unanswered-control-request.test.ts | 4 +- ...structured-agent-session-create-support.ts | 4 +- ...nt-session-options-workspace-scope.test.ts | 36 +++++++++++----- ...ured-agent-new-chat-effort-at-rest.test.ts | 11 ++++- src/shared/execution-host.ts | 8 ++++ 18 files changed, 119 insertions(+), 54 deletions(-) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index 1099746e82e9..9e12296e64c3 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -149,7 +149,9 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { 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' }) + expect((await host.readOptions(SESSION, { readsWithoutModel: true })).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' }) @@ -170,7 +172,9 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { const prompt = await rig.frame('session/prompt') rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) - expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + expect((await host.readOptions(SESSION, { readsWithoutModel: true })).current).toMatchObject({ + model: 'grok-4.6' + }) const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) expect(answer).toMatchObject({ listingNamesConfiguredModel: 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 index 9e2512981d39..e31b48ed3ca5 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -22,7 +22,7 @@ describe('a live Grok listing', () => { } const { host } = await openAttachedHostRig({}, undefined, modelCatalog) - await host.readOptions(SESSION) + await host.readOptions(SESSION, { readsWithoutModel: true }) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) const [sessionId, listing] = vi.mocked(modelCatalog.recordLiveListing).mock.calls[0]! diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 32f0a6df64f6..9e45dc930935 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -73,7 +73,9 @@ describe('a Grok model pick Grok never answers', () => { await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(failed(await within(picking, 2_000))).toBe(true) // The chat's queue moved on: a later operation runs. - expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') + expect(await within(host.readOptions(SESSION, { readsWithoutModel: true }), 1_000)).not.toBe( + 'pending' + ) const [frame] = framesOf(rig.child(), 'session/set_config_option') rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => @@ -81,7 +83,9 @@ describe('a Grok model pick Grok never answers', () => { ) }) await waitFor(async () => - expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + expect((await host.readOptions(SESSION, { readsWithoutModel: true })).current).toMatchObject({ + model: 'grok-4.6' + }) ) await host.close(SESSION, 'user-close') }) diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index c1ce921caca2..6ad422fa1606 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -104,7 +104,7 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist openConversation: async () => running, conversation: async () => running } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, SESSION) + return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) } function attachTail(journal: AgentSessionJournal, limit: number): StructuredAgentSessionState { 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 d6162b77f16c..3db070eee5d2 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 @@ -136,7 +136,7 @@ function restingRecord(options: Record): AgentSessionRecord { return { provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: ACCOUNT_HOME }, - location: { wslDistro: null }, + location: { executionHostId: 'local', wslDistro: null }, // Why released: a chat at rest has no live claim, so its next start reads the current account. lease: { claimStatus: 'released' }, options @@ -180,7 +180,7 @@ function readAtRest(store: AgentModelCatalogStore, record: AgentSessionRecord) { openConversation: async () => resting, conversation: async () => resting } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, SESSION) + return readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) } function pickerEffort(result: Awaited>, agent = 'claude') { @@ -260,7 +260,7 @@ describe('Claude effort default at rest', () => { const record = { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - location: { wslDistro: null }, + location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-unlisted', effort: 'high' } } as unknown as AgentSessionRecord const listing = { @@ -340,7 +340,7 @@ describe('Claude effort default at rest', () => { const record = { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - location: { wslDistro: null }, + location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-5.5', effort: 'high' } } as unknown as AgentSessionRecord @@ -357,7 +357,7 @@ describe('Claude effort default at rest', () => { const record = { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - location: { wslDistro: null }, + location: { executionHostId: 'local', wslDistro: null }, options: { model: 'gpt-5.5' } } as unknown as AgentSessionRecord store.recordSuccess( 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 a6654d8ad139..f8753e51248c 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 @@ -4,6 +4,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isLegacyAgentSessionAccountHome } from '../../../shared/agent-session-account-home' +import { isNativeLocalLocation } from '../../../shared/execution-host' import { agentModelCatalogFingerprint, agentModelCatalogFingerprintForRecord @@ -134,12 +135,13 @@ async function newChatCatalogKey( } } -/** The local folder a session's agent runs in; null where this host cannot look into it. */ +/** The local folder a session's agent runs in; null where this host cannot look into it, as a new + * chat's own read answers. */ async function sessionWorkspacePath( deps: AgentModelCatalogServiceDeps, record: AgentSessionRecord ): Promise { - if (record.location.wslDistro !== null || !deps.recordWorkspacePath) { + if (!isNativeLocalLocation(record.location) || !deps.recordWorkspacePath) { return null } return deps.recordWorkspacePath(record).catch(() => null) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 578ffaa35934..709f495936de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -245,7 +245,7 @@ export function structuredAgentSessionMutationDelegates( caller: StructuredAgentSessionCaller, params: Parameters[2] ) => changeStructuredAgentSessionThreadGoal(context(), caller, params), - readOptions: (sessionId: string, reader?: StructuredAgentSessionOptionsReader) => + readOptions: (sessionId: string, reader: StructuredAgentSessionOptionsReader) => readStructuredAgentSessionOptions(context(), sessionId, reader) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 7de8ad28581f..5e95a77426ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -86,7 +86,9 @@ describe('the idle sweep', () => { expect(reader.events.some((event) => event.type === 'end')).toBe(false) // Everything a chat at rest answers still answers, and the next send starts a new agent. - await expect(rig.host.readOptions(SESSION)).resolves.toMatchObject({ current: {} }) + await expect(rig.host.readOptions(SESSION, { readsWithoutModel: true })).resolves.toMatchObject( + { current: {} } + ) const sent = await rig.host.send(CALLER, restTestSend('carry on', fence())) expect(sent.ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 49f321b379fd..3bba13ff774d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -19,7 +19,7 @@ function restingRecord(): AgentSessionRecord { return { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, - location: { wslDistro: null }, + location: { executionHostId: 'local', wslDistro: null }, options: {} } as unknown as AgentSessionRecord } @@ -55,12 +55,17 @@ describe('options at rest', () => { conversation: async () => resting } as unknown as StructuredAgentSessionMutationContext - const result = await readStructuredAgentSessionOptions(context, SESSION) + const result = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: true + }) expect(probe).toHaveBeenCalledTimes(provider === 'codex' ? 1 : 0) expect(result.models).toEqual([]) expect(result.current).not.toHaveProperty('model') record.options = { model: 'later-model', effort: 'off' } - expect((await readStructuredAgentSessionOptions(context, SESSION)).current).toEqual({ + expect( + (await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true })) + .current + ).toEqual({ model: 'later-model', effort: 'off' }) @@ -99,7 +104,7 @@ describe('live Codex option reads', () => { conversation: async () => live } as unknown as StructuredAgentSessionMutationContext - const reading = readStructuredAgentSessionOptions(context, SESSION) + const reading = readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) await vi.waitFor(() => expect(prepareReadOptions).toHaveBeenCalledWith({ sessionId: SESSION, fence: 7 }) ) @@ -136,7 +141,9 @@ describe('live Codex option reads', () => { openConversation: async () => live } as unknown as StructuredAgentSessionMutationContext - await expect(readStructuredAgentSessionOptions(context, SESSION)).rejects.toBe(failure) + await expect( + readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + ).rejects.toBe(failure) await expect(queue.serialize(SESSION, async () => 'Stop can proceed')).resolves.toBe( 'Stop can proceed' ) @@ -168,7 +175,7 @@ describe('live Codex option reads', () => { conversation: async () => live } as unknown as StructuredAgentSessionMutationContext - const reading = readStructuredAgentSessionOptions(context, SESSION) + const reading = readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) await vi.waitFor(() => expect(prepareReadOptions).toHaveBeenCalledOnce()) await queue.serialize(SESSION, async () => { live.child = { fence: 8 } 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 44545666cd50..b3264c29cfbc 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 @@ -7,6 +7,7 @@ import { refuse, + type AgentSessionModelCatalogResult, type AgentSessionOptionResult, type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' @@ -24,8 +25,7 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick -/** What the asking client takes. One that reads an answer naming no model is answered for the - * chat's own folder, as its first frame is; any other keeps the account's default. */ +/** What the asking client takes: whether it reads an answer that names no model. */ export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } /** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ @@ -63,17 +63,32 @@ async function readStructuredAgentSessionOptionsAtRest( throw new Error('agent_session_identity_required') } const rules = restingOptionRules(deps.agents, record) - const catalog = (await deps.modelCatalog - ?.read({ - agent: record.provider, - sessionId, - ...(reader.readsWithoutModel ? { inSessionWorkspace: true as const } : {}) - }) - .catch(() => null)) ?? { origin: 'unknown' as const } + const readCatalog = async ( + inSessionWorkspace: boolean + ): Promise => + (await deps.modelCatalog + ?.read({ + agent: record.provider, + sessionId, + ...(inSessionWorkspace ? { inSessionWorkspace: true as const } : {}) + }) + .catch(() => null)) ?? { origin: 'unknown' } + const saved = record.options ?? {} + // The chat's own folder decides its default, as its first frame did. + const scoped = await readCatalog(true) + const folderDefault = + scoped.origin === 'unknown' ? undefined : scoped.models.find((entry) => entry.isDefault) + // Where the folder names none, a client that needs a model keeps the account default it got before. + const catalog = + saved.model === undefined && + !folderDefault && + scoped.origin !== 'unknown' && + !reader.readsWithoutModel + ? await readCatalog(false) + : scoped // With no catalog for the account, the list a running child of this agent falls back to. const listed = catalog.origin === 'unknown' ? (rules?.fallbackModels() ?? null) : catalog.models const models = listed ?? [] - const saved = record.options ?? {} const fastMode = saved.fastMode === undefined ? null @@ -83,11 +98,11 @@ async function readStructuredAgentSessionOptionsAtRest( const model = saved.model ?? (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) - // The pick, else the model's default where a live child reports that. With no model pick, the - // chat runs the catalog's default at its listed default effort, as its first frame showed. + // The pick, else the model's default where a live child reports that, or where the chat's folder + // names that model as what it runs, as its first frame showed. const effort = saved.effort ?? - (rules?.effortDefaultsToModel || saved.model === undefined + (rules?.effortDefaultsToModel || (saved.model === undefined && folderDefault) ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -136,7 +151,7 @@ export async function readStructuredAgentSessionOptions( 'deps' | 'serialize' | 'openConversation' | 'conversation' >, sessionId: string, - reader: StructuredAgentSessionOptionsReader = { readsWithoutModel: false } + reader: StructuredAgentSessionOptionsReader ): Promise { const { adapter, agents, store } = context.deps const started = await context.serialize(sessionId, async () => { 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 a35b13db74e4..af4320e6814b 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 @@ -217,7 +217,7 @@ describe('a publish-first Claude create whose init is slow', () => { await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') - await host.readOptions(SESSION) + await host.readOptions(SESSION, { readsWithoutModel: true }) expect(savedListings.at(-1)).toEqual({ sessionId: SESSION, listing: expect.objectContaining({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 76e4942ffb6d..09807326245d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -84,7 +84,7 @@ function readAtRest(agents: StructuredAgentRegistry) { openConversation: async () => resting, conversation: async () => resting } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, RECORD.sessionId) + return readStructuredAgentSessionOptions(context, RECORD.sessionId, { readsWithoutModel: true }) } describe('a registered definition', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 89e039689a5f..1cd400fad42b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -82,7 +82,7 @@ it('keeps the offer through every way of looking at the chat (R-02)', async () = const { host, root, acquire } = await offered() const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) await host.history({ sessionId: SESSION, direction: 'tail' }) - await host.readOptions(SESSION) + await host.readOptions(SESSION, { readsWithoutModel: true }) host.readCommands(SESSION) await host.handoffStatus(SESSION) await host.revealSession(SESSION) diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index f4d63c020040..288de82daff5 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -57,7 +57,9 @@ async function setOption( /** What the picker is handed: the value it shows, and which ones the CLI vouched for. */ async function picker(host: StructuredAgentSessionHost) { - const { model, effort, confirmed } = (await host.readOptions(SESSION)).current + const { model, effort, confirmed } = ( + await host.readOptions(SESSION, { readsWithoutModel: true }) + ).current return { model, effort, confirmed } } diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c7e3c1d79a12..b81144a2a1ac 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -6,7 +6,7 @@ import { resolveCommittedStructuredAgentSessionAdoptionIntent, resolveStructuredAgentSessionAdoptionForCreate } from './structured-agent-session-create-adoption' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' @@ -137,7 +137,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR /** Where a structured chat here would run, when that is a directory on this machine. */ async resolveStructuredAgentSessionLocalWorkspacePath(worktreeSelector: string) { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) - if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro) { + if (!isNativeLocalLocation(location)) { return null } return (await this.resolveRuntimeFileTarget(worktreeSelector)).worktree.path diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts index 45807619ba63..0f6ccc398fbe 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -60,8 +60,9 @@ async function readOptions(client: typeof OLDER_CLIENT | typeof CURRENT_CLIENT) return replies[0] } -/** A Codex chat with no pick, at rest in a repo whose `.codex/config.toml` sets the effort. */ -async function chatInConfiguredRepo(): Promise { +/** A Codex chat with no pick, at rest in a repo; `configured` gives the repo a `.codex/config.toml` + * that sets the effort. */ +async function chatAtRest(repoConfig: { configured: boolean }): Promise { await foundRestTestChat(rig) // The chat never picked: its next start runs whatever its config resolves. await rig.store.replaceSessionOptions({ @@ -73,7 +74,9 @@ async function chatInConfiguredRepo(): Promise { const repo = join(rig.root, 'repo') await mkdir(join(repo, '.codex'), { recursive: true }) await writeFile(join(repo, '.git'), 'gitdir: /elsewhere') - await writeFile(join(repo, '.codex', 'config.toml'), 'model_reasoning_effort = "high"\n') + if (repoConfig.configured) { + await writeFile(join(repo, '.codex', 'config.toml'), 'model_reasoning_effort = "high"\n') + } const store = new AgentModelCatalogStore() // Not inline: this base's listing still requires the tier map that main has since dropped. const listing = { @@ -132,21 +135,21 @@ afterEach(async () => { }) describe('options at rest in a repo whose config may pick the model', () => { - it('keeps the account default and the commands for a client that needs a model', async () => { - await chatInConfiguredRepo() + it('keeps the account default model, with no effort, for a client that needs a model', async () => { + await chatAtRest({ configured: true }) - expect(await readOptions(OLDER_CLIENT)).toMatchObject({ + const reply = await readOptions(OLDER_CLIENT) + + expect(reply).toMatchObject({ ok: true, - result: { - current: { model: 'gpt-5.5', effort: 'medium' }, - conversationCommands: ['clear'] - } + result: { current: { model: 'gpt-5.5' }, conversationCommands: ['clear'] } }) + expect(reply).not.toHaveProperty('result.current.effort') expect(rig.adapter.acquire).not.toHaveBeenCalled() }) it('names no model or effort for a client that takes an answer without one', async () => { - await chatInConfiguredRepo() + await chatAtRest({ configured: true }) const reply = await readOptions(CURRENT_CLIENT) @@ -155,3 +158,14 @@ describe('options at rest in a repo whose config may pick the model', () => { expect(reply).not.toHaveProperty('result.current.effort') }) }) + +describe('options at rest in a repo with no config of its own', () => { + it('names the default model and its effort for a client that needs a model', async () => { + await chatAtRest({ configured: false }) + + expect(await readOptions(OLDER_CLIENT)).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-5.5', effort: 'medium' } } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts index 91d6cbb28ccb..3492c0e09ca2 100644 --- a/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -280,16 +280,23 @@ describe("a new chat's effort pill before its agent starts", () => { } ) - it('keeps today’s answer, with its commands, for a client that needs a model', async () => { + it('keeps the account default model, with no effort, for a client that needs a model', async () => { const { atRest } = await newChatFrames('codex', { projectFiles: { '.codex/config.toml': 'model_reasoning_effort = "high"\n' }, readsWithoutModel: false }) - expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium' }) + // The account default is not what this repo runs, so its effort is never claimed. + expect(atRest.current).toEqual({ model: 'gpt-5.5' }) expect(atRest.conversationCommands).toEqual(['clear', 'compact']) }) + it('names the folder’s default effort for a client that needs a model too', async () => { + const { atRest } = await newChatFrames('codex', { readsWithoutModel: false }) + + expect(atRest.current).toEqual({ model: 'gpt-5.5', effort: 'medium' }) + }) + it('keeps a saved effort over the default model’s', async () => { const { atRest, settled } = await newChatFrames('grok', { saved: { effort: 'high' } }) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 0a6d6956b217..d3a1c1426193 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -10,6 +10,14 @@ export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId +/** Whether work at this location runs natively on this machine, in a folder this process can read. */ +export function isNativeLocalLocation(location: { + executionHostId: string + wslDistro: string | null +}): boolean { + return location.executionHostId === LOCAL_EXECUTION_HOST_ID && !location.wslDistro +} + export type ParsedExecutionHost = | { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID } | { kind: 'ssh'; id: `ssh:${string}`; targetId: string } From 9ee9c75677dff6bb0279ec9397104fa8b5c885be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:18:31 -0700 Subject: [PATCH 7/8] fix(native-chat): keep test callers of the host's options read untouched Only in-process callers omit the reader at the host's entry, and they read as the RPC gate treats an in-process caller. The shared read itself still requires one. This leaves main's newer test call sites unconflicted. --- .../acp/acp-structured-host-configured-default.test.ts | 8 ++------ src/main/acp/acp-structured-host-live-listing.test.ts | 2 +- src/main/acp/acp-structured-host-option-hang.test.ts | 8 ++------ .../structured-agent-session-host-mutations.ts | 8 ++++++-- .../structured-agent-session-idle-sweep.test.ts | 4 +--- .../structured-agent-session-options-read.ts | 5 +++++ .../structured-agent-session-provider-started.test.ts | 2 +- ...uctured-agent-session-restart-offer-withdrawal.test.ts | 2 +- ...-structured-startup-unanswered-control-request.test.ts | 4 +--- 9 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index 9e12296e64c3..1099746e82e9 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -149,9 +149,7 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { 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, { readsWithoutModel: true })).current).toMatchObject({ - model: 'grok-4.6' - }) + 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' }) @@ -172,9 +170,7 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { const prompt = await rig.frame('session/prompt') rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) - expect((await host.readOptions(SESSION, { readsWithoutModel: true })).current).toMatchObject({ - model: 'grok-4.6' - }) + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) expect(answer).toMatchObject({ listingNamesConfiguredModel: 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 index e31b48ed3ca5..9e2512981d39 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -22,7 +22,7 @@ describe('a live Grok listing', () => { } const { host } = await openAttachedHostRig({}, undefined, modelCatalog) - await host.readOptions(SESSION, { readsWithoutModel: true }) + await host.readOptions(SESSION) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) const [sessionId, listing] = vi.mocked(modelCatalog.recordLiveListing).mock.calls[0]! diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 9e45dc930935..32f0a6df64f6 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -73,9 +73,7 @@ describe('a Grok model pick Grok never answers', () => { await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(failed(await within(picking, 2_000))).toBe(true) // The chat's queue moved on: a later operation runs. - expect(await within(host.readOptions(SESSION, { readsWithoutModel: true }), 1_000)).not.toBe( - 'pending' - ) + expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') const [frame] = framesOf(rig.child(), 'session/set_config_option') rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => @@ -83,9 +81,7 @@ describe('a Grok model pick Grok never answers', () => { ) }) await waitFor(async () => - expect((await host.readOptions(SESSION, { readsWithoutModel: true })).current).toMatchObject({ - model: 'grok-4.6' - }) + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) ) await host.close(SESSION, 'user-close') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 709f495936de..c2c655a406e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -46,6 +46,7 @@ export type { StructuredAgentSessionMutationContext } from './structured-agent-s import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { readStructuredAgentSessionOptions, + IN_PROCESS_OPTIONS_READER, recordStructuredAgentSessionOptionIntent, type StructuredAgentSessionOptionsReader } from './structured-agent-session-options-read' @@ -245,7 +246,10 @@ export function structuredAgentSessionMutationDelegates( caller: StructuredAgentSessionCaller, params: Parameters[2] ) => changeStructuredAgentSessionThreadGoal(context(), caller, params), - readOptions: (sessionId: string, reader: StructuredAgentSessionOptionsReader) => - readStructuredAgentSessionOptions(context(), sessionId, reader) + // The RPC handler always names its client's reader; only in-process callers omit it. + readOptions: ( + sessionId: string, + reader: StructuredAgentSessionOptionsReader = IN_PROCESS_OPTIONS_READER + ) => readStructuredAgentSessionOptions(context(), sessionId, reader) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 5e95a77426ed..7de8ad28581f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -86,9 +86,7 @@ describe('the idle sweep', () => { expect(reader.events.some((event) => event.type === 'end')).toBe(false) // Everything a chat at rest answers still answers, and the next send starts a new agent. - await expect(rig.host.readOptions(SESSION, { readsWithoutModel: true })).resolves.toMatchObject( - { current: {} } - ) + await expect(rig.host.readOptions(SESSION)).resolves.toMatchObject({ current: {} }) const sent = await rig.host.send(CALLER, restTestSend('carry on', fence())) expect(sent.ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) 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 b3264c29cfbc..dbbedce15ae5 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 @@ -28,6 +28,11 @@ type RestingOptions = Pick, 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 af4320e6814b..a35b13db74e4 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 @@ -217,7 +217,7 @@ describe('a publish-first Claude create whose init is slow', () => { await claudeStartupSettled(adapter, SESSION) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') - await host.readOptions(SESSION, { readsWithoutModel: true }) + await host.readOptions(SESSION) expect(savedListings.at(-1)).toEqual({ sessionId: SESSION, listing: expect.objectContaining({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 1cd400fad42b..89e039689a5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -82,7 +82,7 @@ it('keeps the offer through every way of looking at the chat (R-02)', async () = const { host, root, acquire } = await offered() const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) await host.history({ sessionId: SESSION, direction: 'tail' }) - await host.readOptions(SESSION, { readsWithoutModel: true }) + await host.readOptions(SESSION) host.readCommands(SESSION) await host.handoffStatus(SESSION) await host.revealSession(SESSION) diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 288de82daff5..f4d63c020040 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -57,9 +57,7 @@ async function setOption( /** What the picker is handed: the value it shows, and which ones the CLI vouched for. */ async function picker(host: StructuredAgentSessionHost) { - const { model, effort, confirmed } = ( - await host.readOptions(SESSION, { readsWithoutModel: true }) - ).current + const { model, effort, confirmed } = (await host.readOptions(SESSION)).current return { model, effort, confirmed } } From faf3aae2b7550e84e228a43e2165af79f22c47ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:18:07 -0700 Subject: [PATCH 8/8] refactor: one native-local location check for Claude, Codex and the at-rest folder read --- src/main/claude/claude-structured-location-support.ts | 4 ++-- src/main/codex/codex-structured-launch-resolution.ts | 4 ++-- src/main/codex/codex-structured-location-support.ts | 4 ++-- src/shared/execution-host.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/claude/claude-structured-location-support.ts b/src/main/claude/claude-structured-location-support.ts index 41b456835468..efc9ba8d74eb 100644 --- a/src/main/claude/claude-structured-location-support.ts +++ b/src/main/claude/claude-structured-location-support.ts @@ -1,6 +1,6 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isNativeLocalLocation } from '../../shared/execution-host' export function supportsClaudeStructuredLocation(location: AgentSessionExecutionLocation): boolean { - return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null + return isNativeLocalLocation(location) } diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 8016c938ae83..86b3f222773d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -9,7 +9,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isNativeLocalLocation } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' @@ -79,7 +79,7 @@ export function createCodexStructuredLaunchResolver( // This adapter spawns a child on the machine the runtime itself runs on. // A session pinned elsewhere belongs to that host's runtime, and quietly // starting it here would put a second writer on the same thread. - if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + if (!isNativeLocalLocation(location)) { throw new Error( `codex structured sessions run on the local host, not ${location.executionHostId}` ) diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts index 618a415ebb26..21e1269d15b6 100644 --- a/src/main/codex/codex-structured-location-support.ts +++ b/src/main/codex/codex-structured-location-support.ts @@ -1,6 +1,6 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { isNativeLocalLocation } from '../../shared/execution-host' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean { - return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null + return isNativeLocalLocation(location) } diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index d3a1c1426193..c8708e148a3d 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -15,7 +15,7 @@ export function isNativeLocalLocation(location: { executionHostId: string wslDistro: string | null }): boolean { - return location.executionHostId === LOCAL_EXECUTION_HOST_ID && !location.wslDistro + return location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null } export type ParsedExecutionHost =