Skip to content
Merged
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-context-usage-unloaded-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions src/main/claude/claude-structured-effort-default-at-rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function restingRecord(options: Record<string, string>): 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
Expand Down Expand Up @@ -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<ReturnType<typeof readAtRest>>, agent = 'claude') {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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

Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-location-support.ts
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 2 additions & 2 deletions src/main/codex/codex-structured-launch-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}`
)
Expand Down
4 changes: 2 additions & 2 deletions src/main/codex/codex-structured-location-support.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null> {
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. */
Expand All @@ -142,14 +157,10 @@ async function recordConfiguredDefault(
choice: AgentModelCatalogConfiguredChoice | null
): Promise<void> {
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({
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -244,6 +246,10 @@ export function structuredAgentSessionMutationDelegates(
caller: StructuredAgentSessionCaller,
params: Parameters<typeof changeStructuredAgentSessionThreadGoal>[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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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'
})
Expand Down Expand Up @@ -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 })
)
Expand Down Expand Up @@ -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'
)
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import {
refuse,
type AgentSessionModelCatalogResult,
type AgentSessionOptionResult,
type AgentSessionOptionsResult
} from '../../../shared/agent-session-wire'
Expand All @@ -24,6 +25,14 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s

type RestingOptions = Pick<AgentSessionOptionsResult, 'models' | 'fastModeSupport' | 'current'>

/** 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<StructuredAgentRegistry, 'definition'>,
Expand Down Expand Up @@ -51,20 +60,40 @@ function restingOptionRules(

async function readStructuredAgentSessionOptionsAtRest(
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'agents' | 'modelCatalog'>,
sessionId: string
sessionId: string,
reader: StructuredAgentSessionOptionsReader
): Promise<RestingOptions> {
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<AgentSessionModelCatalogResult> =>
(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
Expand All @@ -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 {
Expand Down Expand Up @@ -125,7 +155,8 @@ export async function readStructuredAgentSessionOptions(
StructuredAgentSessionMutationContext,
'deps' | 'serialize' | 'openConversation' | 'conversation'
>,
sessionId: string
sessionId: string,
reader: StructuredAgentSessionOptionsReader
): Promise<AgentSessionOptionsResult> {
const { adapter, agents, store } = context.deps
const started = await context.serialize(sessionId, async () => {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading