mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(acp): start chats for agents that report no model (#26587)
* fix(acp): start chats for agents that report no model * fix(acp): omit unknown models from resting session reads * test(acp): expect absent models for resting Claude chats * refactor(native-chat): move known agent discovery out of session host
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration'
|
||||
import { replaceAgentSessionRecordOptions } from '../runtime/agent-session-record-options'
|
||||
import { agentSessionStoreDraftRowWrites } from '../runtime/agent-session-store-draft'
|
||||
import type { AgentSessionStoreState } from '../runtime/agent-session-store-state'
|
||||
import { AcpStructuredOptions } from './acp-structured-options'
|
||||
import {
|
||||
NewSessionResponseSchema,
|
||||
type SessionConfigOption
|
||||
} from './generated/acp-protocol.generated'
|
||||
|
||||
// OMP 17.3.8 on Windows; only the captured conversation id is replaced.
|
||||
const response = NewSessionResponseSchema.parse(
|
||||
JSON.parse(
|
||||
readFileSync(new URL('./fixtures/omp-v17-windows-new-no-model.json', import.meta.url), 'utf8')
|
||||
)
|
||||
)
|
||||
const modelOption: SessionConfigOption = {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: 'reported-model',
|
||||
options: [{ value: 'reported-model', name: 'Reported Model' }]
|
||||
}
|
||||
|
||||
async function writtenOptions(provider: string, reader: AcpStructuredOptions) {
|
||||
const fixture = agentSessionRecordFixture()
|
||||
const record = {
|
||||
...fixture,
|
||||
provider,
|
||||
launchDirectory: 'C:/work/chat-folder',
|
||||
providerHandleChain: fixture.providerHandleChain.map((link) => ({
|
||||
...link,
|
||||
handle: { transport: 'acp', agent: provider, nativeId: response.sessionId }
|
||||
}))
|
||||
}
|
||||
const published: AgentSessionStoreState = {
|
||||
records: new Map([[record.sessionId, record]]),
|
||||
operations: new Map(),
|
||||
retiredClaimKeys: [],
|
||||
unreadableRecords: new Map(),
|
||||
sessionTabs: null
|
||||
}
|
||||
const options = await readNativeSessionOptions({
|
||||
adapter: { readOptions: async () => reader.read() },
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence
|
||||
})
|
||||
if (!options) {
|
||||
throw new Error('The live reader must report its options')
|
||||
}
|
||||
const changed = replaceAgentSessionRecordOptions(record, {
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
options,
|
||||
now: 3_000
|
||||
})
|
||||
const writes = agentSessionStoreDraftRowWrites(published, {
|
||||
...published,
|
||||
records: new Map([[record.sessionId, changed]])
|
||||
})
|
||||
expect(writes?.records.upsert).toHaveLength(1)
|
||||
const stored = JSON.parse(writes?.records.upsert[0]?.[1] ?? 'null')
|
||||
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
|
||||
expect(stored.options).toEqual(options)
|
||||
return options
|
||||
}
|
||||
|
||||
describe('ACP sessions without a reported model', () => {
|
||||
it('writes the captured Windows OMP startup through the live reader and shared normalizer', async () => {
|
||||
const reader = new AcpStructuredOptions()
|
||||
reader.adoptSession(response)
|
||||
expect(reader.read()).toEqual({
|
||||
models: [],
|
||||
current: { effort: 'off', confirmed: ['effort'] }
|
||||
})
|
||||
expect(reader.reported()).toEqual({ effort: 'off' })
|
||||
expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' })
|
||||
})
|
||||
|
||||
it.each(['grok', 'opencode'])('writes missing-model options for %s', async (provider) => {
|
||||
const reader = new AcpStructuredOptions()
|
||||
reader.adoptSession({})
|
||||
expect(reader.read().current).not.toHaveProperty('model')
|
||||
expect(await writtenOptions(provider, reader)).toEqual({})
|
||||
})
|
||||
|
||||
it.each(['config', 'legacy'] as const)('does not expose an empty %s model value', (source) => {
|
||||
const reader = new AcpStructuredOptions()
|
||||
reader.adoptSession(
|
||||
source === 'config'
|
||||
? { configOptions: [{ ...modelOption, currentValue: '', options: [] }] }
|
||||
: { models: { currentModelId: '', availableModels: [] } }
|
||||
)
|
||||
expect(reader.read().current).not.toHaveProperty('model')
|
||||
expect(reader.reported()).toEqual({})
|
||||
})
|
||||
|
||||
it('records a model reported after a startup with no model', async () => {
|
||||
const reader = new AcpStructuredOptions()
|
||||
reader.adoptSession(response)
|
||||
expect(await writtenOptions('omp', reader)).toEqual({ effort: 'off' })
|
||||
reader.adoptConfigOptions([...(response.configOptions ?? []), modelOption])
|
||||
expect(reader.read().current).toEqual({
|
||||
model: 'reported-model',
|
||||
effort: 'off',
|
||||
confirmed: ['model', 'effort']
|
||||
})
|
||||
expect(await writtenOptions('omp', reader)).toEqual({ model: 'reported-model', effort: 'off' })
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,7 @@ export class AcpStructuredOptions {
|
||||
...(choice.description ? { description: choice.description } : {})
|
||||
}))
|
||||
: []
|
||||
const currentModel = modelOption?.currentValue ?? this.models?.currentModelId ?? ''
|
||||
const currentModel = (modelOption?.currentValue ?? this.models?.currentModelId) || undefined
|
||||
const listed: { id: string; label: string; description?: string }[] = modelOption
|
||||
? selectChoices(modelOption).map((choice) => ({
|
||||
id: choice.value,
|
||||
@@ -134,7 +134,7 @@ export class AcpStructuredOptions {
|
||||
return {
|
||||
models,
|
||||
current: {
|
||||
model: currentModel,
|
||||
...(currentModel ? { model: currentModel } : {}),
|
||||
...(effortOption ? { effort: effortOption.currentValue } : {}),
|
||||
confirmed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"sessionId": "session-omp-windows",
|
||||
"configOptions": [
|
||||
{
|
||||
"id": "mode",
|
||||
"name": "Mode",
|
||||
"category": "mode",
|
||||
"type": "select",
|
||||
"currentValue": "default",
|
||||
"options": [
|
||||
{
|
||||
"value": "default",
|
||||
"name": "Default",
|
||||
"description": "Standard ACP headless mode"
|
||||
},
|
||||
{
|
||||
"value": "plan",
|
||||
"name": "Plan",
|
||||
"description": "Read-only planning mode that drafts a plan to a markdown file before any code changes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "thinking",
|
||||
"name": "Thinking",
|
||||
"category": "thought_level",
|
||||
"type": "select",
|
||||
"currentValue": "off",
|
||||
"options": [
|
||||
{
|
||||
"value": "off",
|
||||
"name": "Off"
|
||||
},
|
||||
{
|
||||
"value": "auto",
|
||||
"name": "Auto",
|
||||
"description": "Auto-detect per prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"modes": {
|
||||
"availableModes": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default",
|
||||
"description": "Standard ACP headless mode"
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"name": "Plan",
|
||||
"description": "Read-only planning mode that drafts a plan to a markdown file before any code changes"
|
||||
}
|
||||
],
|
||||
"currentModeId": "default"
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,7 @@ describe('Claude effort default at rest', () => {
|
||||
const resting = await readAtRest(new AgentModelCatalogStore(), restingRecord({}))
|
||||
|
||||
// The built-in list's default is a guess, not this account's: nothing to name.
|
||||
expect(resting.current.model).toBe('')
|
||||
expect(resting.current).not.toHaveProperty('model')
|
||||
const seed = getAgentSessionOptionCatalog('claude')!
|
||||
const state = [live, resting].reduce(
|
||||
(current, answer) => applyStructuredAgentSessionOptions(current, seed, answer),
|
||||
|
||||
@@ -82,7 +82,7 @@ function modelOption(value: unknown): ParsedCodexModelOption | null {
|
||||
}
|
||||
|
||||
export type CodexSessionOptionCatalog = {
|
||||
result: AgentSessionOptionsResult
|
||||
result: AgentSessionOptionsResult & { current: { model: string } }
|
||||
fastModeTierByModel: Map<string, string>
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../shared/protocol-version'
|
||||
import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../shared/agent-session-optional-model-capability'
|
||||
import {
|
||||
AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
||||
@@ -41,6 +42,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab
|
||||
AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY,
|
||||
// The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists.
|
||||
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
|
||||
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export function nativeSessionOptionsFromReport(input: {
|
||||
: encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode)
|
||||
const options: Record<string, string> = {
|
||||
...restored,
|
||||
model: reported.model,
|
||||
...(reported.model ? { model: reported.model } : {}),
|
||||
...(reported.effort ? { effort: reported.effort } : {}),
|
||||
...(fastMode !== undefined && fastMode !== null ? { fastMode } : {})
|
||||
}
|
||||
|
||||
+41
-32
@@ -25,38 +25,47 @@ function restingRecord(): AgentSessionRecord {
|
||||
}
|
||||
|
||||
describe('options at rest', () => {
|
||||
it('answers while the first catalog listing is still running', async () => {
|
||||
const record = restingRecord()
|
||||
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
|
||||
const modelCatalog = createAgentModelCatalogService({
|
||||
store: new AgentModelCatalogStore(),
|
||||
getRecord: () => record,
|
||||
drivesRecord: () => true,
|
||||
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: 'codex' },
|
||||
journal: { context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: {
|
||||
adapter: {},
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
store: { getRecord: () => record },
|
||||
modelCatalog
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => resting,
|
||||
conversation: async () => resting
|
||||
} as unknown as StructuredAgentSessionMutationContext
|
||||
it.each(['codex', 'omp', 'grok', 'opencode'] as const)(
|
||||
'omits an unknown model for %s while the catalog is unavailable',
|
||||
async (provider) => {
|
||||
const record = { ...restingRecord(), provider }
|
||||
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
|
||||
const modelCatalog = createAgentModelCatalogService({
|
||||
store: new AgentModelCatalogStore(),
|
||||
getRecord: () => record,
|
||||
drivesRecord: () => true,
|
||||
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
const resting = {
|
||||
child: null,
|
||||
params: { provider: 'codex' },
|
||||
journal: { context: { floor: () => null } }
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: {
|
||||
adapter: {},
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
store: { getRecord: () => record },
|
||||
modelCatalog
|
||||
},
|
||||
serialize: (_sessionId: string, task: () => Promise<unknown>) => task(),
|
||||
openConversation: async () => resting,
|
||||
conversation: async () => resting
|
||||
} as unknown as StructuredAgentSessionMutationContext
|
||||
|
||||
const result = await readStructuredAgentSessionOptions(context, SESSION)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(result.models).toEqual([])
|
||||
})
|
||||
const result = await readStructuredAgentSessionOptions(context, SESSION)
|
||||
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({
|
||||
model: 'later-model',
|
||||
effort: 'off'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('live Codex option reads', () => {
|
||||
@@ -165,7 +174,7 @@ describe('live Codex option reads', () => {
|
||||
live.child = { fence: 8 }
|
||||
})
|
||||
listing.resolve()
|
||||
await expect(reading).resolves.toMatchObject({ current: { model: '' } })
|
||||
expect((await reading).current).not.toHaveProperty('model')
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,8 +55,7 @@ async function readStructuredAgentSessionOptionsAtRest(
|
||||
// account's default; a built-in list's default is a guess, so with none the client keeps its own.
|
||||
const model =
|
||||
saved.model ??
|
||||
(catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ??
|
||||
''
|
||||
(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.
|
||||
const effort =
|
||||
saved.effort ??
|
||||
@@ -69,7 +68,7 @@ async function readStructuredAgentSessionOptionsAtRest(
|
||||
? { fastModeSupport: catalog.fastModeSupport }
|
||||
: {}),
|
||||
current: {
|
||||
model,
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(typeof fastMode === 'boolean' ? { fastMode } : {})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration'
|
||||
import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
|
||||
import {
|
||||
openTestAgentSessionRecordStore,
|
||||
seedTestAgentSessionRecordStore
|
||||
} from './agent-session-record-store-test-harness'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { AcpStructuredOptions } from '../acp/acp-structured-options'
|
||||
import { NewSessionResponseSchema } from '../acp/generated/acp-protocol.generated'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const SESSION = 'session-options'
|
||||
@@ -46,6 +53,75 @@ it('drops provider-rejected persisted options before the next owner proof', asyn
|
||||
).resolves.toEqual({ model: 'provider-model', other: 'keep' })
|
||||
})
|
||||
|
||||
it('omits empty model values from other option producers', async () => {
|
||||
await expect(
|
||||
readNativeSessionOptions({
|
||||
adapter: { readOptions: async () => ({ models: [], current: { model: '', effort: 'off' } }) },
|
||||
sessionId: SESSION,
|
||||
fence: 2,
|
||||
priorOptions: { model: 'old-model', other: 'keep' }
|
||||
})
|
||||
).resolves.toEqual({ effort: 'off', other: 'keep' })
|
||||
})
|
||||
|
||||
it('durably replaces model-less ACP options when a later report reaches the option writer', async () => {
|
||||
const response = NewSessionResponseSchema.parse(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
new URL('../acp/fixtures/omp-v17-windows-new-no-model.json', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
const reader = new AcpStructuredOptions()
|
||||
reader.adoptSession(response)
|
||||
const fixture = agentSessionRecordFixture()
|
||||
const record = {
|
||||
...fixture,
|
||||
provider: 'omp',
|
||||
options: reader.reported(),
|
||||
providerHandleChain: fixture.providerHandleChain.map((link) => ({
|
||||
...link,
|
||||
handle: { transport: 'acp', agent: 'omp', nativeId: response.sessionId }
|
||||
}))
|
||||
}
|
||||
await seedTestAgentSessionRecordStore(directory, { records: [record] })
|
||||
const store = await openTestAgentSessionRecordStore(directory)
|
||||
expect(store.getRecord(record.sessionId)?.options).toEqual({ effort: 'off' })
|
||||
|
||||
reader.adoptConfigOptions([
|
||||
...(response.configOptions ?? []),
|
||||
{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: 'reported-model',
|
||||
options: [{ value: 'reported-model', name: 'Reported Model' }]
|
||||
}
|
||||
])
|
||||
const options = await readNativeSessionOptions({
|
||||
adapter: { readOptions: async () => reader.read() },
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
priorOptions: store.getRecord(record.sessionId)?.options
|
||||
})
|
||||
if (!options) {
|
||||
throw new Error('The later ACP report must provide options')
|
||||
}
|
||||
await store.replaceSessionOptions({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
options,
|
||||
now: NOW
|
||||
})
|
||||
const reopened = await openTestAgentSessionRecordStore(directory)
|
||||
expect(reopened.getRecord(record.sessionId)?.options).toEqual({
|
||||
model: 'reported-model',
|
||||
effort: 'off'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists resumed provider options atomically with owner proof', async () => {
|
||||
const store = await openTestAgentSessionRecordStore(directory)
|
||||
const reserved = await store.reserveOwner({
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AcpStructuredOptions } from '../../../acp/acp-structured-options'
|
||||
import { NewSessionResponseSchema } from '../../../acp/generated/acp-protocol.generated'
|
||||
import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities'
|
||||
import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../../shared/agent-session-optional-model-capability'
|
||||
import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities'
|
||||
import { parseRuntimeClientCapabilities } from '../runtime-client-capabilities'
|
||||
import {
|
||||
call,
|
||||
clearStructuredHostStub,
|
||||
hostCalls,
|
||||
installStructuredHostStub,
|
||||
SESSION,
|
||||
STRUCTURED_CLIENT
|
||||
} from './structured-agent-session-rpc.test-fixture'
|
||||
|
||||
const CURRENT_CLIENT = {
|
||||
...STRUCTURED_CLIENT,
|
||||
clientCapabilities: [
|
||||
...STRUCTURED_CLIENT.clientCapabilities,
|
||||
AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY
|
||||
]
|
||||
}
|
||||
const DESKTOP_CLIENTS = [
|
||||
['local desktop', [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES]],
|
||||
['paired desktop', remoteRuntimeClientCapabilities(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES)]
|
||||
] as const
|
||||
|
||||
function missingModelReport() {
|
||||
const reader = new AcpStructuredOptions()
|
||||
reader.adoptSession(
|
||||
NewSessionResponseSchema.parse(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
new URL('../../../acp/fixtures/omp-v17-windows-new-no-model.json', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return reader.read()
|
||||
}
|
||||
|
||||
beforeEach(installStructuredHostStub)
|
||||
afterEach(clearStructuredHostStub)
|
||||
|
||||
describe('options without a selected model across client versions', () => {
|
||||
it.each(['runtime', 'mobile'] as const)(
|
||||
'refuses the captured model-less report for an older %s client',
|
||||
async (clientKind) => {
|
||||
hostCalls.readOptions.mockResolvedValue(missingModelReport())
|
||||
const reply = await call(
|
||||
'agentSession.options',
|
||||
{ sessionId: SESSION },
|
||||
{
|
||||
...STRUCTURED_CLIENT,
|
||||
clientKind
|
||||
}
|
||||
)
|
||||
expect(reply).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: 'structured_agent_session_unsupported' }
|
||||
})
|
||||
expect(reply).not.toHaveProperty('result')
|
||||
expect(hostCalls.readOptions).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(DESKTOP_CLIENTS)(
|
||||
'returns the captured absence to the %s',
|
||||
async (_name, capabilities) => {
|
||||
const report = missingModelReport()
|
||||
hostCalls.readOptions.mockResolvedValue(report)
|
||||
const reply = await call(
|
||||
'agentSession.options',
|
||||
{ sessionId: SESSION },
|
||||
{
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: [...capabilities]
|
||||
}
|
||||
)
|
||||
expect(reply).toMatchObject({ id: 'request-1', ok: true, result: report })
|
||||
if (!reply.ok) {
|
||||
throw new Error('Updated desktops must receive the report')
|
||||
}
|
||||
expect(reply.result).toEqual(report)
|
||||
expect(reply).not.toHaveProperty('result.current.model')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps same-build in-process reads available', async () => {
|
||||
const report = missingModelReport()
|
||||
hostCalls.readOptions.mockResolvedValue(report)
|
||||
expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({
|
||||
ok: true,
|
||||
result: report
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['reported-model', ''])(
|
||||
'preserves an existing string-valued report (%j)',
|
||||
async (model) => {
|
||||
const report = { models: [], current: { model, effort: 'off' } }
|
||||
hostCalls.readOptions.mockResolvedValue(report)
|
||||
for (const client of [STRUCTURED_CLIENT, CURRENT_CLIENT]) {
|
||||
expect(await call('agentSession.options', { sessionId: SESSION }, client)).toMatchObject({
|
||||
ok: true,
|
||||
result: report
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('resumes legacy discovery after the provider reports a model', async () => {
|
||||
hostCalls.readOptions.mockResolvedValueOnce(missingModelReport())
|
||||
expect(
|
||||
await call('agentSession.options', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
||||
).toMatchObject({
|
||||
ok: false
|
||||
})
|
||||
const report = { models: [], current: { model: 'later-model', effort: 'off' } }
|
||||
hostCalls.readOptions.mockResolvedValueOnce(report)
|
||||
expect(
|
||||
await call('agentSession.options', { sessionId: SESSION }, STRUCTURED_CLIENT)
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
result: report
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps authenticated capability input compatible with older parsers', () => {
|
||||
for (const [, capabilities] of DESKTOP_CLIENTS) {
|
||||
expect(parseRuntimeClientCapabilities(capabilities)).toEqual(capabilities)
|
||||
expect(capabilities).toContain(AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY)
|
||||
}
|
||||
expect(remoteRuntimeClientCapabilities()).not.toContain(
|
||||
AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
OptionsParams
|
||||
} from './structured-agent-session-schemas'
|
||||
import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory'
|
||||
import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
|
||||
import { clientReadsOptionsWithoutModel } from './structured-agent-session-policy'
|
||||
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
|
||||
@@ -41,8 +43,17 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
|
||||
name: 'agentSession.options',
|
||||
permission: 'workspace',
|
||||
params: OptionsParams,
|
||||
handler: async (params, ctx) =>
|
||||
(await requireInstalledStructuredHost(ctx, params.sessionId)).readOptions(params.sessionId)
|
||||
handler: async (params, ctx) => {
|
||||
const result = await (
|
||||
await requireInstalledStructuredHost(ctx, params.sessionId)
|
||||
).readOptions(params.sessionId)
|
||||
if (result.current.model === undefined && !clientReadsOptionsWithoutModel(ctx)) {
|
||||
throw agentSessionRefusalError('structured_agent_session_unsupported', {
|
||||
reason: 'clientCapabilityMissing'
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.modelCatalog',
|
||||
|
||||
@@ -6,10 +6,20 @@ import {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../../shared/agent-session-optional-model-capability'
|
||||
import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
export function clientReadsOptionsWithoutModel(
|
||||
context: Pick<RpcContext, 'clientCapabilities' | 'clientKind'>
|
||||
): boolean {
|
||||
return (
|
||||
context.clientKind === undefined ||
|
||||
context.clientCapabilities?.includes(AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY) === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One rule for every caller: can this client read structured sessions? The host's own
|
||||
* The host's Chat UI setting is not consulted. It is the host user's launch preference,
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import type * as ReactModule from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OMP_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-omp'
|
||||
import {
|
||||
applyStructuredAgentSessionOptions,
|
||||
createStructuredAgentSessionOptionState,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from '../../../../shared/structured-agent-session-options'
|
||||
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
|
||||
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
|
||||
|
||||
@@ -421,6 +427,23 @@ describe('NativeChatSessionOptionPickers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a session with no reported model without an empty model control', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('omp'),
|
||||
OMP_SESSION_OPTION_CATALOG,
|
||||
{ models: [], current: { effort: 'off', confirmed: ['effort'] } }
|
||||
)
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={structuredAgentSessionOptionSnapshot(state)}
|
||||
isWorking={false}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /^Model/ })).toBeNull()
|
||||
expect(screen.queryAllByRole('button', { name: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('does not duplicate titles for unknown values or misname generic controls', () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Older pickers require a model in every successful options report.
|
||||
export const AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY =
|
||||
'agent-session.options.optional-model.v1' as const
|
||||
@@ -483,7 +483,7 @@ export type AgentSessionOptionsResult = {
|
||||
/** Session/account/transport support. Absent means unknown, never unsupported. */
|
||||
fastModeSupport?: AgentSessionFastModeSupport
|
||||
current: {
|
||||
model: string
|
||||
model?: string
|
||||
effort?: string
|
||||
/** Canonical preference for the next turn. Explicit false is meaningful. */
|
||||
fastMode?: boolean
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY
|
||||
} from './protocol-version'
|
||||
import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from './agent-session-optional-model-capability'
|
||||
|
||||
// Electron clients can decode client-hosted page placement; becoming a page
|
||||
// host still requires the separate authenticated browser-client lease.
|
||||
@@ -22,6 +23,7 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
|
||||
// The renderer that reads a paired host's structured chats is the one that reads its own.
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY,
|
||||
// Same renderer: it reads each host's `agentSession.agents` and renders any agent listed there.
|
||||
STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY,
|
||||
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp'
|
||||
import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex'
|
||||
import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot'
|
||||
import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state'
|
||||
@@ -17,6 +18,29 @@ function viewModel(...args: Parameters<typeof structuredAgentSessionOptionView>)
|
||||
}
|
||||
|
||||
describe('structured agent session options', () => {
|
||||
it('keeps a missing model out of the picker and adopts a later report', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('omp'),
|
||||
OMP_SESSION_OPTION_CATALOG,
|
||||
{ models: [], current: { effort: 'off', confirmed: ['effort'] } }
|
||||
)
|
||||
expect(state.catalog?.models).toEqual([])
|
||||
expect(structuredAgentSessionOptionSnapshot(state)).toEqual([])
|
||||
expect(state.record.model).toBeUndefined()
|
||||
|
||||
const reported = applyStructuredAgentSessionOptions(state, OMP_SESSION_OPTION_CATALOG, {
|
||||
models: [{ id: 'reported-model', label: 'Reported Model', isDefault: true, efforts: [] }],
|
||||
current: { model: 'reported-model', confirmed: ['model'] }
|
||||
})
|
||||
expect(structuredAgentSessionOptionSnapshot(reported)[0]).toMatchObject({
|
||||
valueSource: 'reported',
|
||||
kind: {
|
||||
currentValue: 'reported-model',
|
||||
choices: [{ value: 'reported-model', label: 'Reported Model' }]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('projects native Codex selects while bridge Codex keeps its agent picker', () => {
|
||||
const state = applyStructuredAgentSessionOptions(
|
||||
createStructuredAgentSessionOptionState('codex'),
|
||||
|
||||
@@ -79,7 +79,7 @@ export function structuredAgentSessionOptionCatalog(
|
||||
const models: CatalogModel[] = result.models.map((model) =>
|
||||
discoveredModel(model, result.fastModeSupport?.supported === true)
|
||||
)
|
||||
if (!models.some((model) => model.id === result.current.model)) {
|
||||
if (result.current.model && !models.some((model) => model.id === result.current.model)) {
|
||||
models.push({
|
||||
id: result.current.model,
|
||||
label: result.current.model,
|
||||
@@ -176,12 +176,12 @@ export function applyStructuredAgentSessionOptions(
|
||||
result: AgentSessionOptionsResult
|
||||
): StructuredAgentSessionOptionState {
|
||||
if (result.current.fastMode === undefined) {
|
||||
clearTrackedSessionOption(state.record, result.current.model, 'fastMode')
|
||||
clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode')
|
||||
}
|
||||
applyNativeChatReportedSessionOptions(
|
||||
state.record,
|
||||
{
|
||||
model: result.current.model,
|
||||
...(result.current.model ? { model: result.current.model } : {}),
|
||||
...(result.current.effort ? { effort: result.current.effort } : {}),
|
||||
...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-optional-model-capability'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../src/shared/electron-remote-runtime-client-capabilities'
|
||||
import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities'
|
||||
import { resolveBaselineReleaseRef } from './release-checkout'
|
||||
import { callBuild } from './structured-agent-session-surface-execution'
|
||||
import { installableHost, structuredHostStub } from './structured-agent-session-host-fixture'
|
||||
import {
|
||||
loadAgentSessionWireBuild,
|
||||
WORKING_TREE,
|
||||
type AgentSessionWireBuild
|
||||
} from './versioned-agent-session-wire'
|
||||
|
||||
let current: AgentSessionWireBuild
|
||||
let baseline: AgentSessionWireBuild
|
||||
const SESSION = 'model-options-session'
|
||||
|
||||
beforeAll(async () => {
|
||||
current = await loadAgentSessionWireBuild(WORKING_TREE)
|
||||
baseline = await loadAgentSessionWireBuild(resolveBaselineReleaseRef())
|
||||
}, 180_000)
|
||||
|
||||
afterEach(async () => {
|
||||
await current.installStructuredHost(null)
|
||||
if (baseline.methodNames.includes('agentSession.options')) {
|
||||
await baseline.installStructuredHost(null)
|
||||
}
|
||||
})
|
||||
|
||||
describe('selected model reports across released client and host versions', () => {
|
||||
it('refuses an absent model only for an older client against the new host', async () => {
|
||||
const host = structuredHostStub(SESSION, 'folder-options')
|
||||
const report = { models: [], current: { effort: 'off' } }
|
||||
host.readOptions.mockResolvedValue(report)
|
||||
await current.installStructuredHost(installableHost(host))
|
||||
const legacy = [
|
||||
...baseline.capabilities.filter(
|
||||
(value) => value !== AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY
|
||||
),
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
]
|
||||
expect(
|
||||
await callBuild(
|
||||
current,
|
||||
'agentSession.options',
|
||||
{ sessionId: SESSION },
|
||||
{
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: legacy
|
||||
}
|
||||
)
|
||||
).toMatchObject([{ ok: false, error: { message: 'structured_agent_session_unsupported' } }])
|
||||
expect(
|
||||
await callBuild(
|
||||
current,
|
||||
'agentSession.options',
|
||||
{ sessionId: SESSION },
|
||||
{
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: remoteRuntimeClientCapabilities(
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
|
||||
)
|
||||
}
|
||||
)
|
||||
).toMatchObject([{ ok: true, result: report }])
|
||||
host.readOptions.mockResolvedValue({ models: [], current: { model: 'reported-model' } })
|
||||
expect(
|
||||
await callBuild(
|
||||
current,
|
||||
'agentSession.options',
|
||||
{ sessionId: SESSION },
|
||||
{
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: legacy
|
||||
}
|
||||
)
|
||||
).toMatchObject([{ ok: true, result: { current: { model: 'reported-model' } } }])
|
||||
})
|
||||
|
||||
it('lets a new desktop client read existing model reports from the released host', async () => {
|
||||
const client = {
|
||||
clientKind: 'runtime' as const,
|
||||
clientCapabilities: remoteRuntimeClientCapabilities(
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
|
||||
)
|
||||
}
|
||||
if (!baseline.methodNames.includes('agentSession.options')) {
|
||||
expect(
|
||||
await callBuild(baseline, 'agentSession.options', { sessionId: SESSION }, client)
|
||||
).toMatchObject([{ ok: false, error: { code: 'method_not_found' } }])
|
||||
return
|
||||
}
|
||||
const host = structuredHostStub(SESSION, 'folder-options')
|
||||
const report = { models: [], current: { model: 'released-model', effort: 'off' } }
|
||||
host.readOptions.mockResolvedValue(report)
|
||||
await baseline.installStructuredHost(installableHost(host))
|
||||
expect(
|
||||
await callBuild(baseline, 'agentSession.options', { sessionId: SESSION }, client)
|
||||
).toMatchObject([{ ok: true, result: report }])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user