From 66ffdfbf46d17ede468f7ba4ab07175299191c4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:49:48 -0700 Subject: [PATCH 1/5] refactor(native-chat): one rule for what a no-pick session says of its configured default ACP's rule (only a new session that restored no model pick and was sent none runs its config's default; an effort it picked says nothing) moves beside the catalog entry types so other agents' adapters report through the same step. An empty listing (a signed-out agent) now says nothing instead of "no listed model", so it can never retire a saved default. --- src/main/acp/acp-structured-options.ts | 25 +++++++--------- .../agent-model-catalog-entry.ts | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 15 deletions(-) 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..b799f5c33431 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< From df3ae27aff5e326bfe896628183ae058e4223be2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:50:07 -0700 Subject: [PATCH 2/5] feat(native-chat): check Pi's project config before naming its default model Pi reads `.pi/settings.json` in the chat's folder and loads project extensions from `.pi/extensions` (and the `extensions`/`packages` lists). A settings file that sets defaultProvider, defaultModel, defaultThinkingLevel, modelThinkingLevels or enabledModels, loads extension code, or can't be parsed counts as the project picking the model; extension code may register providers or set the model itself. Key names come from Pi 1.0.4's own settings manager and docs, read without running Pi. --- .../agent-project-model-config-keys.ts | 24 ++++++++++++++ .../agent-project-model-override.test.ts | 32 ++++++++++++++++++- .../agent-project-model-override.ts | 24 ++++++++++++-- 3 files changed, 77 insertions(+), 3 deletions(-) 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) } From 7ae4ca3ddae654db0dafe6694429bbfcb9c0081a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:50:10 -0700 Subject: [PATCH 3/5] feat(native-chat): a new Pi chat names the model and thinking level Pi starts on Pi's model listing marks no model as the one it runs, so a new Pi chat with no saved pick showed "Model" and then changed to the model its session reported. A new Pi session (not one resumed or forked from a saved conversation) that restored no model pick and was sent none now reports the model and, unless it picked one, the thinking level it started on, through the same host step as Grok, OpenCode and OMP. The host saves them as the account's default behind the project-config check, so the next new Pi chat names them on its first frame. A signed-out Pi, which lists no model, saves nothing. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/pi/rpc-configured-default.test.ts | 269 ++++++++++++++++++ src/main/pi/rpc-session-adapter.ts | 11 +- src/main/pi/rpc-session.ts | 25 +- ...t-session-options.learned-default.test.tsx | 9 +- 5 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 src/main/pi/rpc-configured-default.test.ts 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/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts new file mode 100644 index 000000000000..a97a1d53c7de --- /dev/null +++ b/src/main/pi/rpc-configured-default.test.ts @@ -0,0 +1,269 @@ +// 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 { createPiModelCatalogProbe } 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 }, + { provider: 'openai', id: 'gpt-6', name: 'GPT-6', reasoning: true } +] +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 +} + +/** A Pi child that starts on GPT-6 at high thinking; a signed-out one lists no model. */ +function piChild(available: readonly object[]): PiRpcConnection { + let model: object | null = RUNS + 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: 'high' + } + } + if (command === 'get_available_models') { + return { models: available } + } + if (command === 'set_model') { + model = AVAILABLE.find((entry) => entry.id === params?.modelId) ?? model + } + return command === 'get_commands' ? { commands: [] } : {} + }, + send: async () => {}, + close: async () => ({ root: 'exited', tree: 'exited' }), + pauseReading: () => {}, + resumeReading: () => {}, + onExit: () => {} + } + return connection +} + +/** A host with Pi's real probe and catalog service, and one Pi chat in `workspacePath`. */ +async function piHost() { + const records = new Map() + const catalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + 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: { saved?: Record; resumed?: boolean; available?: object[] } = {} + ) { + const sessionId = `pi-${++chats}` + // 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' } : {}) + }), + readProcessStartTime: async () => 1, + openConnection: () => piChild(opts.available ?? AVAILABLE), + 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 + }) + 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 } +} + +/** 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' + }) + }) +}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index ee0a13c42c43..c3ed5e912d18 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( @@ -224,12 +224,11 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { ) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => { const session = this.session(input.sessionId, input.fence) + session.picked.add(input.key) 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..4b4b98931cc3 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -19,6 +19,10 @@ 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 +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' export type PiRpcConnection = Pick< JsonlRpcAgentConnection, @@ -57,6 +61,8 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() + /** Option keys a restore or a pick sent this child, whether or not Pi took them. */ + readonly picked = new Set() readonly skipped: string[] = [] commands?: AgentSessionSlashCommand[] options?: AgentSessionOptionsResult @@ -70,7 +76,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) { @@ -147,6 +155,7 @@ export class PiRpcSession { this.skipped.push(key) continue } + this.picked.add(key) try { await applyPiRpcSessionOption(this.connection, this.selected, key, value) } catch (error) { @@ -172,6 +181,20 @@ export class PiRpcSession { return state.sessionFile } + /** What the child reports now, with what a new start no one picked a model for says of its + * config's default. */ + async readOptions() { + const options = await readPiRpcSessionOptions(this.connection) + return withLiveCatalogListing( + options, + unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked: this.picked, + ...options + }) + ) + } + 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) From 344dd21f0096fd5660bc5be9feeb47ddfe0477a9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:47:19 -0700 Subject: [PATCH 4/5] fix(native-chat): a newer, coarser effort menu keeps the effort a chat ran The saved list takes each model's effort menu from the newer listing. Pi's `--list-models` menu stops at high, while a chat's own menu adds xhigh or max for models whose thinking map allows them. After a background re-list, a learned xhigh was no longer in the menu, so the first frame dropped it. When the configured effort is only in the other listing's menu, that menu is kept. --- .../agent-model-catalog-entry.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 b799f5c33431..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 @@ -138,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) ) From b2dfc08871588c786218699298bcd9132f93f297 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:47:20 -0700 Subject: [PATCH 5/5] fix(native-chat): Pi teaches only the model and thinking level it started on A Pi chat re-derived its configured default from Pi's current state on every options read, so a model an extension switched to mid-chat (extensions can call setModel) was saved as the account default, and the next new chat named the wrong model. The choice is now computed once, after start-up restores the chat's saved picks, and every later read reports that snapshot. A pick made in the chat no longer matters to it. Tests cover an extension switch, a pick made in the chat, a forked chat, and a learned xhigh surviving a newer re-list. --- src/main/pi/rpc-configured-default.test.ts | 126 ++++++++++++++++++--- src/main/pi/rpc-session-adapter.ts | 1 - src/main/pi/rpc-session.ts | 30 ++--- 3 files changed, 126 insertions(+), 31 deletions(-) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index a97a1d53c7de..4dd90990539f 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -24,7 +24,8 @@ import { closeProviderTimelineRigs, openProviderTimelineRig } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' +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' @@ -37,7 +38,14 @@ const LISTING = [ ].join('\n') const AVAILABLE = [ { provider: 'anthropic', id: 'claude-sonnet-4', name: 'Claude Sonnet 4', reasoning: true }, - { provider: 'openai', id: 'gpt-6', name: 'GPT-6', 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]! @@ -65,9 +73,15 @@ function workspace(name: string, files: Record = {}): string { return dir } -/** A Pi child that starts on GPT-6 at high thinking; a signed-out one lists no model. */ -function piChild(available: readonly object[]): PiRpcConnection { +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, @@ -81,14 +95,14 @@ function piChild(available: readonly object[]): PiRpcConnection { isStreaming: false, isCompacting: false, model, - thinkingLevel: 'high' + thinkingLevel: thinking } } if (command === 'get_available_models') { return { models: available } } if (command === 'set_model') { - model = AVAILABLE.find((entry) => entry.id === params?.modelId) ?? model + switchTo(String(params?.modelId)) } return command === 'get_commands' ? { commands: [] } : {} }, @@ -98,14 +112,30 @@ function piChild(available: readonly object[]): PiRpcConnection { resumeReading: () => {}, onExit: () => {} } - return connection + 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: new AgentModelCatalogStore(), + store, getRecord: (sessionId) => records.get(sessionId), drivesRecord: () => true, resolveAccountHome: async () => PI_HOME, @@ -125,11 +155,9 @@ async function piHost() { 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: { saved?: Record; resumed?: boolean; available?: object[] } = {} - ) { + 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, @@ -150,10 +178,14 @@ async function piHost() { cwd: workspacePath, fullAccess: true, previous: null, - ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}) + ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}), + ...(opts.forked ? { forkFile: '/homes/pi/sessions/elsewhere.jsonl' } : {}) }), readProcessStartTime: async () => 1, - openConnection: () => piChild(opts.available ?? AVAILABLE), + openConnection: () => { + child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high') + return child.connection + }, onLifecycle: vi.fn(), onSettled: vi.fn(), onIdle: vi.fn(), @@ -173,6 +205,9 @@ async function piHost() { 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') @@ -180,7 +215,7 @@ async function piHost() { catalog.recordLiveListing(sessionId, catalogListing) return catalogListing } - return { catalog, chat } + return { catalog, chat, store, clock } } /** What a new Pi chat's composer paints first from a host answer. */ @@ -266,4 +301,65 @@ describe('Pi’s default comes from what a chat with no pick runs', () => { 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 c3ed5e912d18..12425d684251 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -224,7 +224,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { ) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => { const session = this.session(input.sessionId, input.fence) - session.picked.add(input.key) return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) } readOptions: NonNullable = async (input) => diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 4b4b98931cc3..13f61f1fc979 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -21,7 +21,8 @@ import { piRpcStateSchema } from './rpc-protocol' import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' import { unpickedSessionConfiguredChoice, - withLiveCatalogListing + withLiveCatalogListing, + type AgentModelCatalogConfiguredChoice } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' export type PiRpcConnection = Pick< @@ -61,8 +62,9 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() - /** Option keys a restore or a pick sent this child, whether or not Pi took them. */ - readonly picked = new Set() + /** 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 @@ -150,12 +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 } - this.picked.add(key) + picked.add(key) try { await applyPiRpcSessionOption(this.connection, this.selected, key, value) } catch (error) { @@ -169,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) { @@ -181,18 +190,9 @@ export class PiRpcSession { return state.sessionFile } - /** What the child reports now, with what a new start no one picked a model for says of its - * config's default. */ + /** What the child reports now, with what its start said of its config's default. */ async readOptions() { - const options = await readPiRpcSessionOptions(this.connection) - return withLiveCatalogListing( - options, - unpickedSessionConfiguredChoice({ - resolvesConfig: this.resolvesConfig, - picked: this.picked, - ...options - }) - ) + return withLiveCatalogListing(await readPiRpcSessionOptions(this.connection), this.startChoice) } async close(requested = true): ReturnType {