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', 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/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/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..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 @@ -53,6 +54,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 +135,18 @@ async function newChatCatalogKey( } } +/** 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 (!isNativeLocalLocation(record.location) || !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 +157,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 +242,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 +260,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-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index cb9cd10fea68..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,7 +46,9 @@ export type { StructuredAgentSessionMutationContext } from './structured-agent-s import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { readStructuredAgentSessionOptions, - recordStructuredAgentSessionOptionIntent + IN_PROCESS_OPTIONS_READER, + recordStructuredAgentSessionOptionIntent, + type StructuredAgentSessionOptionsReader } from './structured-agent-session-options-read' export function sendStructuredAgentSessionTurn( @@ -244,6 +246,10 @@ export function structuredAgentSessionMutationDelegates( caller: StructuredAgentSessionCaller, params: Parameters[2] ) => changeStructuredAgentSessionThreadGoal(context(), caller, params), - readOptions: (sessionId: string) => readStructuredAgentSessionOptions(context(), sessionId) + // 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-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 7810ace25677..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 @@ -7,6 +7,7 @@ import { refuse, + type AgentSessionModelCatalogResult, type AgentSessionOptionResult, type AgentSessionOptionsResult } from '../../../shared/agent-session-wire' @@ -24,6 +25,14 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s type RestingOptions = Pick +/** What the asking client takes: whether it reads an answer that names no model. */ +export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } + +/** A same-build caller in this process reads as the RPC gate treats one: it takes no model. */ +export const IN_PROCESS_OPTIONS_READER: StructuredAgentSessionOptionsReader = { + readsWithoutModel: true +} + /** Initial explicit picks follow the same rules as a pick made while the chat is at rest. */ export function structuredAgentSessionOptionOverridesRefusal( agents: Pick, @@ -51,20 +60,40 @@ function restingOptionRules( async function readStructuredAgentSessionOptionsAtRest( deps: Pick, - sessionId: string + sessionId: string, + reader: StructuredAgentSessionOptionsReader ): Promise { const record = deps.store.getRecord(sessionId) if (!record) { throw new Error('agent_session_identity_required') } const rules = restingOptionRules(deps.agents, record) - const catalog = (await deps.modelCatalog - ?.read({ agent: record.provider, sessionId }) - .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 @@ -74,10 +103,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. + // 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 + (rules?.effortDefaultsToModel || (saved.model === undefined && folderDefault) ? models.find((entry) => entry.id === model)?.defaultEffort : undefined) return { @@ -125,7 +155,8 @@ export async function readStructuredAgentSessionOptions( StructuredAgentSessionMutationContext, 'deps' | 'serialize' | 'openConversation' | 'conversation' >, - sessionId: string + sessionId: string, + reader: StructuredAgentSessionOptionsReader ): Promise { const { adapter, agents, store } = context.deps const started = await context.serialize(sessionId, async () => { @@ -168,7 +199,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/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/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-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..0f6ccc398fbe --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -0,0 +1,171 @@ +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; `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({ + 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') + 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 = { + 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', + listing, + '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 model, with no effort, for a client that needs a model', async () => { + await chatAtRest({ configured: true }) + + const reply = await readOptions(OLDER_CLIENT) + + expect(reply).toMatchObject({ + ok: true, + 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 chatAtRest({ configured: true }) + + 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') + }) +}) + +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 new file mode 100644 index 000000000000..3492c0e09ca2 --- /dev/null +++ b/src/main/runtime/structured-agent-new-chat-effort-at-rest.test.ts @@ -0,0 +1,312 @@ +// 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 { 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' +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 { + 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' +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' }, + { value: 'max', id: 'max' } + ] + } + }, + { 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 }, + // 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' } } +} + +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, + 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]! + // 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) + } + const modelCatalog = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => (sessionId === record.sessionId ? record : undefined), + drivesRecord: () => true, + resolveAccountHome: async (name) => HOMES[name]!, + listingNamesConfiguredModel: new Set(['codex']), + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel + }) + 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 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, + 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(chat.projectFiles) + const record = newChatRecord(agent, workspacePath, chat.saved ?? {}) + const { modelCatalog, context } = host(agent, record) + const seed = structuredAgentSessionSeedCatalog(agent) + const first = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent, seed), + seed, + await modelCatalog.read({ agent, waitForListing: true, workspacePath }), + { 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, { + readsWithoutModel: chat.readsWithoutModel ?? true + }) + 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', 'max'], + ['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.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, { + projectFiles: { [file]: text } + }) + + expect(first.model?.value).toBeUndefined() + expect(first.effort).toBeNull() + expect(settled).toEqual(first) + expect(atRest.current).toEqual({}) + } + ) + + 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 + }) + + // 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' } }) + + 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', { saved: { model: 'gpt-5.5-mini' } }) + + expect(atRest.current).toEqual({ model: 'gpt-5.5-mini' }) + }) +}) diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index 0a6d6956b217..c8708e148a3d 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 === null +} + export type ParsedExecutionHost = | { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID } | { kind: 'ssh'; id: `ssh:${string}`; targetId: string }