Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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',
Expand Down
25 changes: 10 additions & 15 deletions src/main/acp/acp-structured-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
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<
Expand Down Expand Up @@ -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)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null
try {
settings = record(JSON.parse(text.replace(/^/, '')))
} catch {
return true
}
return settings === null || setsAny(settings, PI_MODEL_SETTINGS)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -111,6 +114,11 @@ const PROJECT_MODEL_LAYERS: Readonly<Record<string, ProjectModelLayers>> = {
),
...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']
}
}

Expand Down Expand Up @@ -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<boolean> {
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(
Expand All @@ -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)
}
Expand Down
Loading
Loading