diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 85a6f048eb5b..981ff7b96019 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -5,6 +5,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', + 'src/main/pi/rpc-configured-default.test.ts', 'src/main/acp/acp-structured-omp-recordings.test.ts', 'src/main/acp/acp-text-stream-identity.test.ts', 'src/main/acp/acp-text-message-ownership.test.ts', diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 0ee9d5d8b5ef..4d0ea8658432 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -12,7 +12,10 @@ import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' import { AcpRpcError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpDialect } from './acp-dialects/acp-dialect' -import type { AgentModelCatalogConfiguredChoice } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { + unpickedSessionConfiguredChoice, + type AgentModelCatalogConfiguredChoice +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { SessionConfigSelectGroupSchema, SessionConfigSelectOptionSchema, @@ -171,21 +174,13 @@ export class AcpStructuredOptions { * resolution: that scope's configured default. Null when that names no listed model; undefined * when this session can't say. */ configuredDefault(): AgentModelCatalogConfiguredChoice | null | undefined { - if (!this.resolvesConfig || this.picked.has('model')) { - return undefined - } const { models, current } = this.read() - if (!current.model) { - return undefined - } - if (!models.some((model) => model.id === current.model)) { - return null - } - return { - modelId: current.model, - // An effort this session picked is its own and says nothing of the config's. - ...(this.picked.has('effort') ? {} : { effort: current.effort ?? null }) - } + return unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked: this.picked, + models, + current + }) } /** The values the agent reports now, as the record's options would name them. */ diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index 3e31cc41400d..7d03032f9ace 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -63,6 +63,35 @@ export type AgentModelCatalogConfiguredDefault = { at: number } +/** What a session that resolves its agent's own config reports as that scope's configured default: + * only a new session that restored no model pick and was sent none runs it. Undefined when the + * session can't say (it listed or runs no model); null when it runs a model it didn't list. */ +export function unpickedSessionConfiguredChoice(session: { + resolvesConfig: boolean + picked: ReadonlySet + models: readonly { id: string }[] + current: { model?: string; effort?: string } +}): AgentModelCatalogConfiguredChoice | null | undefined { + const { models, current } = session + // An empty listing (a signed-out agent) is doubt, not a catalog. + if ( + !session.resolvesConfig || + session.picked.has('model') || + !current.model || + models.length === 0 + ) { + return undefined + } + if (!models.some((model) => model.id === current.model)) { + return null + } + return { + modelId: current.model, + // An effort this session picked is its own and says nothing of the config's. + ...(session.picked.has('effort') ? {} : { effort: current.effort ?? null }) + } +} + /** A live options answer whose model rows are the account's listing as the child reported it, * with what its no-pick launch resolved when the session can say. */ export function withLiveCatalogListing< @@ -109,13 +138,18 @@ function mergedModels( return (newer?.models ?? []).map((model) => { const listed = discovered?.models.find((entry) => entry.id === model.id) const reported = live?.models.find((entry) => entry.id === model.id) - const efforts = - model.efforts.length > 0 - ? model.efforts - : (older?.models.find((entry) => entry.id === model.id)?.efforts ?? []) + const olderEfforts = older?.models.find((entry) => entry.id === model.id)?.efforts ?? [] const runsConfigured = configured?.modelId === model.id || configured?.sameModelIds?.includes(model.id) === true const configuredEffort = runsConfigured ? configured?.effort : undefined + const offersConfigured = (menu: AgentSessionModelOption['efforts']): boolean => + menu.some((choice) => choice.value === configuredEffort) + // A coarser newer menu (Pi's listing stops at high) must not drop the effort a chat ran. + const efforts = + model.efforts.length > 0 && + (offersConfigured(model.efforts) || !offersConfigured(olderEfforts)) + ? model.efforts + : olderEfforts const defaultEffort = [configuredEffort, listed?.defaultEffort, reported?.defaultEffort].find( (effort) => effort !== undefined && efforts.some((choice) => choice.value === effort) ) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts index fce96f166aa9..28c9ca0b75a0 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-config-keys.ts @@ -170,3 +170,27 @@ export function ompSettingsMayPickModel(text: string, format: 'json' | 'yaml' | } return settings === null || setsOmpModelSetting(settings) } + +// Pi settings that pick the startup model or thinking level, or load project extension code, which +// may register providers or set the model itself. +const PI_MODEL_SETTINGS = [ + 'defaultProvider', + 'defaultModel', + 'defaultThinkingLevel', + 'modelThinkingLevels', + 'enabledModels', + 'extensions', + 'packages' +] as const + +/** True when a Pi `.pi/settings.json`'s text could pick a model or thinking level; one Pi can't + * parse counts. */ +export function piSettingsMayPickModel(text: string): boolean { + let settings: Record | null + try { + settings = record(JSON.parse(text.replace(/^/, ''))) + } catch { + return true + } + return settings === null || setsAny(settings, PI_MODEL_SETTINGS) +} diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts index 75bddb13249b..2ce9d8ba6195 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.test.ts @@ -131,7 +131,7 @@ describe('workspaceMayOverrideDefaultModel', () => { }) it('never vouches for an agent whose project config it does not know', async () => { - expect(await mayOverride('pi', join(root, 'anywhere'))).toBe(true) + expect(await mayOverride('unknown-agent', join(root, 'anywhere'))).toBe(true) }) it('ignores OpenCode project config that picks no model, effort or provider', async () => { @@ -205,4 +205,34 @@ describe('workspaceMayOverrideDefaultModel', () => { write(join(worktree, 'opencode.json'), '{"mcp":{}}') expect(await mayOverride('omp', worktree, join(root, 'omp-home'))).toBe(false) }) + + it.each([ + ['.pi/settings.json', '{"defaultProvider":"openai","defaultModel":"gpt-6"}'], + ['.pi/settings.json', '{"defaultThinkingLevel":"low"}'], + ['.pi/settings.json', '{"modelThinkingLevels":{"openai/gpt-6":"high"}}'], + ['.pi/settings.json', '{"enabledModels":["openai/*"]}'], + ['.pi/settings.json', '{"extensions":["./tools/pick-model.ts"]}'], + ['.pi/settings.json', '{"defaultModel":'], + ['.pi/extensions/pick-model.ts', 'export default () => {}\n'] + ])( + 'counts Pi project config in %s that may pick a model or thinking level', + async (file, text) => { + const worktree = join(root, 'pi-model') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + write(join(worktree, file), text) + expect(await mayOverride('pi', worktree, join(root, 'pi-home'))).toBe(true) + } + ) + + it('ignores Pi project settings that pick no model, and the account home’s own settings', async () => { + const worktree = join(root, 'pi-plain') + write(join(worktree, '.git'), 'gitdir: /elsewhere') + expect(await mayOverride('pi', worktree, join(root, 'pi-home'))).toBe(false) + write(join(worktree, '.pi', 'settings.json'), '\uFEFF{"theme":"dark","defaultTools":["+grep"]}') + // Another agent's model settings are not Pi's. + write(join(worktree, '.omp', 'settings.json'), '{"defaultModel":"gpt-6"}') + expect(await mayOverride('pi', worktree, join(root, 'pi-home'))).toBe(false) + write(join(worktree, '.pi', 'settings.json'), '{"defaultModel":"gpt-6"}') + expect(await mayOverride('pi', worktree, join(worktree, '.pi'))).toBe(false) + }) }) diff --git a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts index 92023c038519..6e243cdd3798 100644 --- a/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts +++ b/src/main/native-chat/agent-model-catalog/agent-project-model-override.ts @@ -6,7 +6,8 @@ import { codexConfigMayPickModel, ompSettingsMayPickModel, openCodeAgentFileMayPickModel, - openCodeConfigMayPickModel + openCodeConfigMayPickModel, + piSettingsMayPickModel } from './agent-project-model-config-keys' // A listing's default is the account's, but a chat runs in a workspace whose own @@ -61,6 +62,8 @@ type ProjectModelLayers = { files: readonly ProjectModelFile[] /** Folders of `.md` agent definitions whose frontmatter may pick the model. */ agentFolders?: readonly { path: string; recursive: boolean }[] + /** Folders of extension code, which may pick the model however it likes. */ + codeFolders?: readonly string[] /** The agent reads every ancestor of the workspace, not only up to its project root. */ wholeAncestry?: boolean } @@ -111,6 +114,11 @@ const PROJECT_MODEL_LAYERS: Readonly> = { ), ...files(['.codex/config.toml'], (text) => ompSettingsMayPickModel(text, 'toml')) ] + }, + // Pi reads `.pi/settings.json` in the chat's folder and loads the extensions beside it. + pi: { + files: files(['.pi/settings.json'], piSettingsMayPickModel), + codeFolders: ['.pi/extensions'] } } @@ -155,6 +163,15 @@ async function agentFolderMayPickModel(folder: string, recursive: boolean): Prom return found.some(Boolean) } +/** A folder holding anything is extension code that may pick the model. */ +async function codeFolderMayPickModel(folder: string): Promise { + try { + return (await readdir(folder)).length > 0 + } catch (error) { + return !isMissing(error) + } +} + /** A folder that is the agent's own home is account config, not a layer; any other file or agent * folder setting a model or effort is one. */ async function directoryMayOverride( @@ -169,7 +186,10 @@ async function directoryMayOverride( .map((file) => fileMayPickModel(join(dir, file.path), file.mayPickModel)), ...(layers.agentFolders ?? []).map((folder) => agentFolderMayPickModel(join(dir, folder.path), folder.recursive) - ) + ), + ...(layers.codeFolders ?? []) + .filter((folder) => dirname(join(dir, folder)) !== home) + .map((folder) => codeFolderMayPickModel(join(dir, folder))) ]) return found.some(Boolean) } diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts new file mode 100644 index 000000000000..4dd90990539f --- /dev/null +++ b/src/main/pi/rpc-configured-default.test.ts @@ -0,0 +1,365 @@ +// Pi's `--list-models` names no model it runs by default, so a new Pi chat with no saved pick showed +// "Model" and then whatever its session started on. Through the real Pi probe, adapter and catalog +// host, a chat started with no pick teaches the host the account's default for the next new chat. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { 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 { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' +import type { PiRpcConnection } from './rpc-session' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } +// What `pi --list-models` prints: the first row is not the model Pi starts on. +const LISTING = [ + 'provider model context max-out thinking images', + 'anthropic claude-sonnet-4 200K 64K yes yes', + 'openai gpt-6 400K 128K yes yes' +].join('\n') +const AVAILABLE = [ + { provider: 'anthropic', id: 'claude-sonnet-4', name: 'Claude Sonnet 4', reasoning: true }, + // Its thinking map adds xhigh, which `--list-models` never shows. + { + provider: 'openai', + id: 'gpt-6', + name: 'GPT-6', + reasoning: true, + thinkingLevelMap: { xhigh: 'xhigh' } + } +] +const RUNS = AVAILABLE[1]! + +let root: string +const adapters: PiRpcSessionAdapter[] = [] +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-pi-configured-default-')) +}) +afterEach(async () => { + for (const adapter of adapters.splice(0)) { + await adapter.closeAll() + } + rmSync(root, { recursive: true, force: true }) + await closeProviderTimelineRigs() +}) + +function workspace(name: string, files: Record = {}): string { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') + for (const [file, text] of Object.entries(files)) { + mkdirSync(join(dir, file, '..'), { recursive: true }) + writeFileSync(join(dir, file), text) + } + return dir +} + +type PiChild = { connection: PiRpcConnection; switchTo: (modelId: string) => void } + +/** A Pi child that starts on GPT-6 at `thinking`; a signed-out one lists no model. `switchTo` is + * a switch Orca didn't send, as an extension's `setModel` makes. */ +function piChild(available: readonly object[], thinking: string): PiChild { + let model: object | null = RUNS + const switchTo = (modelId: string): void => { + model = AVAILABLE.find((entry) => entry.id === modelId) ?? model + } + const connection: PiRpcConnection = { + pid: 4123, + closed: false, + rootVerdict: 'live', + processless: false, + lastCloseResult: null, + request: async (command: string, params?: Record) => { + if (command === 'get_state') { + return { + sessionFile: '/homes/pi/sessions/one.jsonl', + isStreaming: false, + isCompacting: false, + model, + thinkingLevel: thinking + } + } + if (command === 'get_available_models') { + return { models: available } + } + if (command === 'set_model') { + switchTo(String(params?.modelId)) + } + return command === 'get_commands' ? { commands: [] } : {} + }, + send: async () => {}, + close: async () => ({ root: 'exited', tree: 'exited' }), + pauseReading: () => {}, + resumeReading: () => {}, + onExit: () => {} + } + return { connection, switchTo } +} + +type ChatOptions = { + saved?: Record + resumed?: boolean + forked?: boolean + available?: object[] + thinking?: string + /** What happens between the chat's start and the pane's options read. */ + afterStart?: (chat: { + child: PiChild + adapter: PiRpcSessionAdapter + sessionId: string + }) => Promise +} + +/** A host with Pi's real probe and catalog service, and one Pi chat in `workspacePath`. */ +async function piHost() { + const records = new Map() + const clock = { now: 1_000 } + const store = new AgentModelCatalogStore({ now: () => clock.now }) + const catalog = createAgentModelCatalogService({ + store, + getRecord: (sessionId) => records.get(sessionId), + drivesRecord: () => true, + resolveAccountHome: async () => PI_HOME, + recordWorkspacePath: async (row) => row.launchDirectory ?? null, + agentReadsProjectModelConfig, + workspaceMayOverrideDefaultModel, + probes: { + pi: createPiModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + resolveCommand: () => '/opt/pi/bin/pi', + probeVersion: async () => true, + homePath: '/home/user', + runListing: async () => LISTING + }) + } + }) + + let chats = 0 + /** A Pi chat in `workspacePath` whose options the pane reads once, as the host's step does. */ + async function chat(workspacePath: string, opts: ChatOptions = {}) { + const sessionId = `pi-${++chats}` + let child: PiChild | undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. + records.set(sessionId, { + sessionId, + provider: 'pi', + accountHome: PI_HOME, + launchDirectory: workspacePath, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'ws-1', + workspaceKind: 'git-worktree' + } + } as AgentSessionRecord) + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch: async () => ({ + command: '/opt/pi/bin/pi', + cwd: workspacePath, + fullAccess: true, + previous: null, + ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}), + ...(opts.forked ? { forkFile: '/homes/pi/sessions/elsewhere.jsonl' } : {}) + }), + readProcessStartTime: async () => 1, + openConnection: () => { + child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high') + return child.connection + }, + onLifecycle: vi.fn(), + onSettled: vi.fn(), + onIdle: vi.fn(), + logger: { warn: vi.fn(), error: vi.fn() } + }) + adapters.push(adapter) + await adapter.acquire({ + identity: { + sessionId, + workspaceId: 'ws-1', + hostId: 'local', + agent: 'pi', + providerHandle: null + }, + fence: 1, + spawnToken: 'spawn-token', + options: opts.saved ?? {}, + events: rig.eventSink + }) + if (opts.afterStart && child) { + await opts.afterStart({ child, adapter, sessionId }) + } + const { catalogListing } = await adapter.readOptions({ sessionId, fence: 1 }) + if (!catalogListing) { + throw new Error('a Pi options read hands the host its listing') + } + catalog.recordLiveListing(sessionId, catalogListing) + return catalogListing + } + return { catalog, chat, store, clock } +} + +/** What a new Pi chat's composer paints first from a host answer. */ +function firstFrame(answer: AgentSessionModelCatalogResult) { + const seed = structuredAgentSessionSeedCatalog('pi') + const state = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('pi', seed), + seed, + answer, + { newLaunch: true } + ) + const snapshot = structuredAgentSessionOptionSnapshot(state) + const select = (id: string) => { + const entry = snapshot.find((descriptor) => descriptor.id === id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null + } + return { model: select('model'), effort: select('effort') } +} + +const NOTHING = { model: null, effort: null } + +describe('Pi’s default comes from what a chat with no pick runs', () => { + it('names no model from the listing, then the model and thinking level a no-pick chat ran', async () => { + const { catalog, chat } = await piHost() + const clean = workspace('clean') + const listed = await catalog.read({ agent: 'pi', workspacePath: clean, waitForListing: true }) + expect(listed).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) + expect(firstFrame(listed)).toEqual(NOTHING) + + await chat(clean) + // The next new chat, in this workspace or another whose config picks nothing. + for (const path of [clean, workspace('other')]) { + const answer = await catalog.read({ agent: 'pi', workspacePath: path }) + expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) + expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') + expect(firstFrame(answer)).toEqual({ model: 'openai/gpt-6', effort: 'high' }) + } + }) + + it('keeps the placeholder in a workspace whose Pi settings pick the model', async () => { + const { catalog, chat } = await piHost() + await chat(workspace('clean')) + const configured = workspace('configured', { + '.pi/settings.json': '{"defaultProvider":"anthropic","defaultModel":"claude-sonnet-4"}' + }) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: configured }))).toEqual( + NOTHING + ) + }) + + it('learns nothing from a chat whose workspace picks its model', async () => { + const { catalog, chat } = await piHost() + await catalog.read({ agent: 'pi', waitForListing: true }) + await chat(workspace('configured', { '.pi/extensions/pick.ts': 'export default () => {}\n' })) + expect( + firstFrame(await catalog.read({ agent: 'pi', workspacePath: workspace('clean') })) + ).toEqual(NOTHING) + }) + + it('learns nothing from a chat that restored a pick or resumed a conversation', async () => { + const { catalog, chat } = await piHost() + await catalog.read({ agent: 'pi', waitForListing: true }) + const clean = workspace('clean') + expect(await chat(clean, { saved: { model: 'openai/gpt-6' } })).not.toHaveProperty( + 'configuredDefault' + ) + expect(await chat(clean, { resumed: true })).not.toHaveProperty('configuredDefault') + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) + }) + + it('saves no default from a signed-out Pi, which lists no model', async () => { + const { catalog, chat } = await piHost() + const clean = workspace('clean') + await catalog.read({ agent: 'pi', waitForListing: true }) + expect(await chat(clean, { available: [] })).not.toHaveProperty('configuredDefault') + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) + + // Nor does it forget one a signed-in chat taught. + await chat(clean) + await chat(clean, { available: [] }) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ + model: 'openai/gpt-6', + effort: 'high' + }) + }) + + it('teaches the model the chat started on, not one an extension switched to later', async () => { + const { catalog, chat } = await piHost() + const clean = workspace('clean') + await catalog.read({ agent: 'pi', waitForListing: true }) + await chat(clean, { afterStart: async ({ child }) => child.switchTo('claude-sonnet-4') }) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ + model: 'openai/gpt-6', + effort: 'high' + }) + }) + + it('teaches the start model after a pick made in the chat, never the pick', async () => { + const { catalog, chat } = await piHost() + const clean = workspace('clean') + await catalog.read({ agent: 'pi', waitForListing: true }) + await chat(clean, { + afterStart: async ({ adapter, sessionId }) => { + await adapter.setOption({ + sessionId, + fence: 1, + key: 'model', + value: 'anthropic/claude-sonnet-4' + }) + } + }) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ + model: 'openai/gpt-6', + effort: 'high' + }) + }) + + it('learns nothing from a chat forked from a conversation in another folder', async () => { + const { catalog, chat } = await piHost() + await catalog.read({ agent: 'pi', waitForListing: true }) + const clean = workspace('clean') + expect(await chat(clean, { forked: true })).not.toHaveProperty('configuredDefault') + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) + }) + + it('keeps a learned thinking level only the chat’s menu offers when a later listing is newer', async () => { + const { catalog, chat, store, clock } = await piHost() + const clean = workspace('clean') + await chat(clean, { thinking: 'xhigh' }) + // A background re-list after the chat: `--list-models` offers no xhigh. + clock.now = 2_000 + store.recordSuccess( + agentModelCatalogFingerprint({ agent: 'pi', accountHome: PI_HOME, wslDistro: null }), + 'pi', + { + models: piModelCatalogFromListing(LISTING), + fastModeTierByModel: new Map(), + origin: 'probe' + }, + 'discovery' + ) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ + model: 'openai/gpt-6', + effort: 'xhigh' + }) + }) +}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index ee0a13c42c43..12425d684251 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -19,8 +19,7 @@ import { buildPiRpcLaunch } from './rpc-launch' import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' -import { applyPiRpcSessionOption, readPiRpcSessionOptions } from './rpc-options' -import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { applyPiRpcSessionOption } from './rpc-options' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' @@ -83,7 +82,8 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { ...launch, structuredSession: { id, spawnToken: input.spawnToken } }) - session = new PiRpcSession(input, randomUUID(), spec, this.deps) + const fresh = !launch.sessionFile && !launch.forkFile + session = new PiRpcSession(input, randomUUID(), spec, this.deps, fresh) this.sessions.set(id, session) this.starts.track(attempt, session.connection) const spawned = providerSpawnedProcessIdentity( @@ -227,9 +227,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) } readOptions: NonNullable = async (input) => - withLiveCatalogListing( - await readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) - ) + this.session(input.sessionId, input.fence).readOptions() readCommands = (id: string) => this.sessions.get(id)?.commands readOptionRestoreFailures(id: string): readonly string[] { return this.sessions.get(id)?.skipped ?? [] diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index ec6427a3f0c9..13f61f1fc979 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -19,6 +19,11 @@ import { PiRpcTurns } from './rpc-turns' import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' import { piRpcStateSchema } from './rpc-protocol' import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' +import { + unpickedSessionConfiguredChoice, + withLiveCatalogListing, + type AgentModelCatalogConfiguredChoice +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' export type PiRpcConnection = Pick< JsonlRpcAgentConnection, @@ -57,6 +62,9 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() + /** What this child started on, as its config's default; never re-read, so a later switch (an + * extension's or the user's) teaches nothing. */ + private startChoice?: AgentModelCatalogConfiguredChoice | null readonly skipped: string[] = [] commands?: AgentSessionSlashCommand[] options?: AgentSessionOptionsResult @@ -70,7 +78,9 @@ export class PiRpcSession { readonly input: StructuredAgentSessionAcquireInput, readonly generation: string, launch: ProviderProcessLaunch, - private readonly deps: PiRpcSessionDeps + private readonly deps: PiRpcSessionDeps, + /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ + readonly resolvesConfig = false ) { const sink = input.events && providerTimelineSink(input.events) if (!sink) { @@ -142,11 +152,14 @@ export class PiRpcSession { async start(): Promise { const state = piRpcStateSchema.parse(await this.connection.request('get_state')) this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + // Keys a restore sent, whether or not Pi took them. + const picked = new Set() for (const [key, value] of Object.entries(this.input.options ?? {})) { if (!['model', 'effort'].includes(key)) { this.skipped.push(key) continue } + picked.add(key) try { await applyPiRpcSessionOption(this.connection, this.selected, key, value) } catch (error) { @@ -160,6 +173,11 @@ export class PiRpcSession { } } this.options = await readPiRpcSessionOptions(this.connection) + this.startChoice = unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked, + ...this.options + }) try { this.commands = (await readPiRpcCommands(this.connection)).commands } catch (error) { @@ -172,6 +190,11 @@ export class PiRpcSession { return state.sessionFile } + /** What the child reports now, with what its start said of its config's default. */ + async readOptions() { + return withLiveCatalogListing(await readPiRpcSessionOptions(this.connection), this.startChoice) + } + async close(requested = true): ReturnType { this.requestedClose ||= requested this.dialogs.cancelAll() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx index c932962444ee..c2ef2e7d9c27 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx @@ -27,7 +27,7 @@ import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-cat import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' -// The user's report: with no saved pick, every new Grok, OpenCode or OMP chat painted "Model" (or +// The user's report: with no saved pick, every new Grok, OpenCode, OMP or Pi chat painted "Model" (or // the probe's model) before the model the chat runs. The first chat with no pick is the only one // that may: once it reports, the host names that model and the next chat starts from it. @@ -42,6 +42,11 @@ const CASES = { ids: ['github-copilot/gpt-6', 'github-copilot/claude-fable-5'], runs: 'github-copilot/claude-fable-5', holdsEverywhere: false + }, + pi: { + ids: ['anthropic/claude-sonnet-4', 'openai/gpt-6'], + runs: 'openai/gpt-6', + holdsEverywhere: false } } as const @@ -163,7 +168,7 @@ describe('a second new chat after one that named no model', () => { }) } - it.each(['grok', 'opencode'] as const)( + it.each(['grok', 'opencode', 'pi'] as const)( '%s: names the model the first chat ran on its first frame', async (agent) => { hostFor(agent)