mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(native-chat): a new structured chat's model list comes from the machine that runs it (#25143)
* fix(native-chat): a new structured chat's model list comes from the host that runs it agentSession.modelCatalog builds the structured-session host like agentSession.options, so a host with no saved chats since it started answers instead of refusing. When the host answers unknown because its first listing runs in the background, the picker re-reads on a bounded schedule until that listing lands. Local terminal-backed chat skips the structured catalog when a custom launch command is configured. * fix(native-chat): wait on the host's first model listing instead of re-reading on a timer A new structured chat's picker read the host catalog once; on an account the host had never listed, the answer was "unknown" while a background listing ran, and the client re-read on a 1-30 s schedule. Replace the schedule with the host's own completion signal: - The host answers a cold read with `listingInProgress: true` (new optional field) once it has started or joined that listing. A read that passes the new optional `waitForListing` param awaits the same joined listing and answers with it, or a plain "unknown" if it failed. The at-rest options read never waits. A host that predates the field never sends it, so the client never sends the param to a host that would refuse it. - The picker reads once per open and attach; after the host's report it sends one waiting read, with a 90 s client timeout above the slowest listing. While that read is out, the model pill keeps its label but cannot open or be set (typed /model included). An answer, failure, timeout, hide, attach or the provider's own list releases it. - `agentSession.modelCatalog` builds the structured-session host only for a read that names a session (a structured chat). Terminal-backed chat's session-less read keeps the non-building gate, so a desktop that never runs structured chat never opens the session journal. * fix(native-chat): one waiting model-list read per chat, and no late menu open The waiting catalog read was owned by one run of the picker's effect. Attach (a new fence), hide/show or a send re-ran the effect: the cleanup released the model picker onto the built-in list for a round trip, and the new run sent a second waiting read while the first, which cannot be withdrawn, kept a remote call slot until the listing ended. The waiting read now belongs to the chat (runtime target + agent + session): a small registry keeps one in flight per chat, every re-run or remount joins it, and the entry is deleted when the read settles. The picker hold is derived from that entry being in flight, so it lasts across attach and hide/show and ends when the read settles, the provider reports its own list, or the pane switches to another session. Answers still pass the stale and record checks. A bare /model typed while the list loads no longer opens the model menu by itself when the list lands: the menu stays keyed on the request, and only its initial open is suppressed while pending, so the request is spent shut and the end of the pending period never remounts it. * fix(native-chat): release the model picker in the same commit as the host list When the waiting catalog read settled in the chat that started it, the registry dropped its entry and told subscribers first, and the host list was applied a few microtasks later. React committed once with the picker enabled on the built-in list, then again with the host's list. Joiners now hand the registry their apply callback, and the registry runs every joiner (with the answer, or nothing when the read failed or timed out) before it deletes the entry and notifies. The release and the list land in one commit. An effect cleanup leaves the wait instead of flagging itself stale. * fix(runtime): queue model catalog reads in the long-wait lane A model catalog read that waits on a host's first listing replies only when that listing ends, yet it took one of the 8 foreground call slots for its server. Enough chats opened during one cold listing would stall that server's sends and interrupts until a wait settled. agentSession.modelCatalog now joins worktree.rm in the long-wait lane: same concurrency, counted apart from the foreground calls. The queue classifies by method only, and a warm catalog read answers at once, so the whole method moves.
This commit is contained in:
@@ -5,7 +5,11 @@ import {
|
||||
agentModelCatalogFingerprintForRecord
|
||||
} from './agent-model-catalog-fingerprint'
|
||||
import { createAgentModelCatalogService } from './agent-model-catalog-service'
|
||||
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
|
||||
import {
|
||||
AGENT_MODEL_CATALOG_FRESH_MS,
|
||||
AgentModelCatalogStore,
|
||||
type AgentModelCatalogSuccess
|
||||
} from './agent-model-catalog-store'
|
||||
|
||||
function record(accountHomePath: string): AgentSessionRecord {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here.
|
||||
@@ -55,7 +59,8 @@ describe('agent model catalog service', () => {
|
||||
probes: { codex: probe }
|
||||
})
|
||||
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
|
||||
origin: 'unknown'
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
// A second read while the probe is in flight must not start another, and a
|
||||
// record-scoped read probes the RECORD's pinned home, not the selection.
|
||||
@@ -81,7 +86,10 @@ describe('agent model catalog service', () => {
|
||||
probes: { codex: probe }
|
||||
})
|
||||
// The record-less read follows the CURRENT selection: unknown, never gpt-old.
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
expect(probe).toHaveBeenCalledWith('/homes/new')
|
||||
await vi.waitFor(async () => {
|
||||
const result = await service.read({ agent: 'codex' })
|
||||
@@ -139,7 +147,8 @@ describe('agent model catalog service', () => {
|
||||
probes: { codex: probe }
|
||||
})
|
||||
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
|
||||
origin: 'unknown'
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
|
||||
// Still a clean unknown — and the failure TTL suppresses a probe storm.
|
||||
@@ -164,6 +173,94 @@ describe('agent model catalog service', () => {
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('a read that waits for the first listing', () => {
|
||||
function deferredListing() {
|
||||
let resolve!: (success: AgentModelCatalogSuccess) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<AgentModelCatalogSuccess>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function coldService(probe: (home: string) => Promise<AgentModelCatalogSuccess>) {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getRecord: () => undefined,
|
||||
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
return { store, service }
|
||||
}
|
||||
|
||||
it('joins the listing the first read started and answers with it', async () => {
|
||||
const pending = deferredListing()
|
||||
const probe = vi.fn(() => pending.promise)
|
||||
const { service } = coldService(probe)
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({
|
||||
origin: 'unknown',
|
||||
listingInProgress: true
|
||||
})
|
||||
const waited = service.read({ agent: 'codex', waitForListing: true })
|
||||
pending.resolve(listing('gpt-listed'))
|
||||
const result = await waited
|
||||
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('answers a plain unknown when the listing fails', async () => {
|
||||
const pending = deferredListing()
|
||||
const { service } = coldService(() => pending.promise)
|
||||
const waited = service.read({ agent: 'codex', waitForListing: true })
|
||||
pending.reject(new Error('spawn failed'))
|
||||
expect(await waited).toEqual({ origin: 'unknown' })
|
||||
})
|
||||
|
||||
it('does not wait or report a listing while a failure is inside its TTL', async () => {
|
||||
const probe = vi.fn(async (): Promise<AgentModelCatalogSuccess> => {
|
||||
throw new Error('spawn failed')
|
||||
})
|
||||
const { store, service } = coldService(probe)
|
||||
store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed')
|
||||
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports no listing where the host has no lister for the account', async () => {
|
||||
const store = new AgentModelCatalogStore()
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getRecord: () => undefined,
|
||||
resolveAccountHome: async () => CODEX_HOME('/homes/selected')
|
||||
})
|
||||
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
|
||||
origin: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('serves an aged entry at once and refreshes it behind the answer', async () => {
|
||||
let now = 0
|
||||
const store = new AgentModelCatalogStore({ now: () => now })
|
||||
store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old'))
|
||||
now = AGENT_MODEL_CATALOG_FRESH_MS
|
||||
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
|
||||
const service = createAgentModelCatalogService({
|
||||
store,
|
||||
getRecord: () => undefined,
|
||||
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
const result = await service.read({ agent: 'codex', waitForListing: true })
|
||||
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old')
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a read for the workspace a new chat runs in', () => {
|
||||
function serviceWith(mayOverride: boolean) {
|
||||
const store = new AgentModelCatalogStore()
|
||||
|
||||
@@ -35,6 +35,8 @@ export type AgentModelCatalogService = {
|
||||
sessionId?: string
|
||||
/** Where a new chat would run; null when one was named but is not a local directory. */
|
||||
workspacePath?: string | null
|
||||
/** With no entry yet, answer from the listing this read starts or joins instead of `unknown`. */
|
||||
waitForListing?: boolean
|
||||
}) => Promise<AgentSessionModelCatalogResult>
|
||||
}
|
||||
|
||||
@@ -79,8 +81,9 @@ async function workspaceKeepsListedDefault(
|
||||
* launch); without one, the key is the account a launch would pin right now —
|
||||
* never "whichever account listed last". `unknown` tells the client to keep
|
||||
* its static seed, and a missing or aged entry kicks one joined background
|
||||
* probe so the next read is warm. Failures are the store's 30s TTL, never an
|
||||
* answer — a picker is a user surface and must not block.
|
||||
* probe so the next read is warm. With no entry, the answer says that listing
|
||||
* is running, and only a read that asks waits for it. Failures are the store's
|
||||
* 30s TTL, never an answer: inside it a read answers `unknown` at once.
|
||||
*/
|
||||
export function createAgentModelCatalogService(
|
||||
deps: AgentModelCatalogServiceDeps
|
||||
@@ -110,14 +113,27 @@ export function createAgentModelCatalogService(
|
||||
})
|
||||
accountHomePath = resolved.path
|
||||
}
|
||||
const entry = deps.store.get(fingerprint)
|
||||
let entry = deps.store.get(fingerprint)
|
||||
const probe = deps.probes?.[params.agent]
|
||||
if (probe && accountHomePath && deps.store.shouldRefresh(fingerprint)) {
|
||||
const home = accountHomePath
|
||||
void deps.store.refresh(fingerprint, params.agent, () => probe(home))
|
||||
}
|
||||
const home = accountHomePath
|
||||
// Without an entry, join a running listing too: that is the one a waiting read answers from.
|
||||
const listing =
|
||||
probe &&
|
||||
home &&
|
||||
(entry ? deps.store.shouldRefresh(fingerprint) : !deps.store.hasActiveFailure(fingerprint))
|
||||
? deps.store.refresh(fingerprint, params.agent, () => probe(home))
|
||||
: null
|
||||
if (!entry) {
|
||||
return { origin: 'unknown' }
|
||||
if (!listing) {
|
||||
return { origin: 'unknown' }
|
||||
}
|
||||
if (!params.waitForListing) {
|
||||
return { origin: 'unknown', listingInProgress: true }
|
||||
}
|
||||
entry = await listing
|
||||
if (!entry) {
|
||||
return { origin: 'unknown' }
|
||||
}
|
||||
}
|
||||
return resultFromEntry(
|
||||
entry,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// A chat's options at rest come from the host catalog without waiting on a listing.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service'
|
||||
import {
|
||||
AgentModelCatalogStore,
|
||||
type AgentModelCatalogSuccess
|
||||
} from '../agent-model-catalog/agent-model-catalog-store'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read'
|
||||
|
||||
const SESSION = 'session-1'
|
||||
|
||||
function restingRecord(): AgentSessionRecord {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields.
|
||||
return {
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/homes/a' },
|
||||
location: { wslDistro: null },
|
||||
options: {}
|
||||
} as unknown as 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,
|
||||
resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }),
|
||||
probes: { codex: probe }
|
||||
})
|
||||
const resting = { child: null, params: { provider: 'codex' } }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members.
|
||||
const context = {
|
||||
deps: { adapter: {}, 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([])
|
||||
})
|
||||
})
|
||||
@@ -56,4 +56,66 @@ describe('agentSession.modelCatalog', () => {
|
||||
await call('agentSession.modelCatalog', { agent: 'claude' }, STRUCTURED_CLIENT)
|
||||
expect(read).toHaveBeenCalledWith({ agent: 'claude' })
|
||||
})
|
||||
|
||||
it('passes a wait for the listing through to the catalog', async () => {
|
||||
await call(
|
||||
'agentSession.modelCatalog',
|
||||
{ agent: 'codex', sessionId: SESSION, waitForListing: true },
|
||||
STRUCTURED_CLIENT
|
||||
)
|
||||
expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, waitForListing: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentSession.modelCatalog before anything built the host', () => {
|
||||
const read = vi.fn(async () => ({
|
||||
origin: 'probe' as const,
|
||||
models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }],
|
||||
fetchedAt: 1
|
||||
}))
|
||||
const installHost = vi.fn(async () => {
|
||||
setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } }))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
read.mockClear()
|
||||
installHost.mockClear()
|
||||
clearStructuredHostStub()
|
||||
})
|
||||
|
||||
// A new chat's picker reads before its create lands; on a host with no saved chats nothing else
|
||||
// has built the host yet, and a refusal here left the picker on the client's built-in list.
|
||||
it('builds the host for a structured chat and answers from its catalog', async () => {
|
||||
const reply = await call(
|
||||
'agentSession.modelCatalog',
|
||||
{ agent: 'codex', sessionId: SESSION },
|
||||
STRUCTURED_CLIENT,
|
||||
{ ensureStructuredAgentSessionHost: installHost }
|
||||
)
|
||||
expect(installHost).toHaveBeenCalledTimes(1)
|
||||
expect(reply).toMatchObject({ ok: true, result: { origin: 'probe' } })
|
||||
expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION })
|
||||
})
|
||||
|
||||
// Terminal-backed chat reads with no session: a host that runs no structured chat keeps its
|
||||
// journal closed, and the read falls back to the CLI listing.
|
||||
it('does not build the host for a read that names no session', async () => {
|
||||
const reply = await call('agentSession.modelCatalog', { agent: 'codex' }, STRUCTURED_CLIENT, {
|
||||
ensureStructuredAgentSessionHost: installHost
|
||||
})
|
||||
expect(installHost).not.toHaveBeenCalled()
|
||||
expect(reply).toMatchObject({ ok: false })
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not build the host for a client that cannot read structured sessions', async () => {
|
||||
const reply = await call(
|
||||
'agentSession.modelCatalog',
|
||||
{ agent: 'codex', sessionId: SESSION },
|
||||
{ clientKind: 'runtime', clientCapabilities: [] },
|
||||
{ ensureStructuredAgentSessionHost: installHost }
|
||||
)
|
||||
expect(installHost).not.toHaveBeenCalled()
|
||||
expect(reply).toMatchObject({ ok: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { defineMethod } from '../core'
|
||||
import {
|
||||
requireInstalledStructuredHost,
|
||||
requireStructuredHost as requireHost
|
||||
requireStructuredHost
|
||||
} from './structured-agent-session-gate'
|
||||
import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas'
|
||||
|
||||
@@ -25,8 +25,14 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
|
||||
defineMethod({
|
||||
name: 'agentSession.modelCatalog',
|
||||
params: ModelCatalogParams,
|
||||
// A structured chat's read names its session and builds the host, since it may come first;
|
||||
// terminal-backed chat's session-less read must not open the journal where none runs.
|
||||
handler: async ({ worktree, ...params }, ctx) => {
|
||||
const catalog = requireHost(ctx).deps.modelCatalog
|
||||
const host =
|
||||
params.sessionId === undefined
|
||||
? requireStructuredHost(ctx)
|
||||
: await requireInstalledStructuredHost(ctx)
|
||||
const catalog = host.deps.modelCatalog
|
||||
if (!catalog) {
|
||||
return { origin: 'unknown' as const }
|
||||
}
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// Against the real menu: a `/model` request opens the menu once, and a period while the host still
|
||||
// lists models neither opens it later nor reopens one the user closed.
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
|
||||
values
|
||||
? Object.entries(values).reduce(
|
||||
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
|
||||
fallback
|
||||
)
|
||||
: fallback
|
||||
}))
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
|
||||
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
|
||||
|
||||
function model(pending: boolean): SessionOptionDescriptor {
|
||||
return {
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
category: 'model',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'opus',
|
||||
choices: [
|
||||
{ value: 'opus', label: 'Opus 4.8' },
|
||||
{ value: 'sonnet', label: 'Sonnet 5' }
|
||||
]
|
||||
},
|
||||
valueSource: 'applied',
|
||||
transport: 'agent-session',
|
||||
settable: !pending,
|
||||
...(pending ? { choicesPending: true as const } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const surface: SessionOptionsSurface = {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(async () => ({ snapshot: [] })),
|
||||
invokeAction: vi.fn(async () => ({ snapshot: [] })),
|
||||
subscribe: () => () => {}
|
||||
}
|
||||
|
||||
function view(pending: boolean, request: NativeChatOptionPickerRequest | null): React.JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<textarea data-testid="composer" />
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(pending)]}
|
||||
isWorking={false}
|
||||
pickerRequest={request}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const settle = (): Promise<void> =>
|
||||
act(async () => new Promise((resolve) => setTimeout(resolve, 30)))
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('model menu requests while the host lists models', () => {
|
||||
it('spends a request made while pending without opening the menu once the list lands', async () => {
|
||||
const request = { id: 'model', sequence: 1 }
|
||||
const { rerender } = render(view(true, request))
|
||||
const composer = screen.getByTestId('composer')
|
||||
composer.focus()
|
||||
await settle()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
rerender(view(false, request))
|
||||
await settle()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.activeElement).toBe(composer)
|
||||
|
||||
// A new request after the list landed still opens it.
|
||||
rerender(view(false, { id: 'model', sequence: 2 }))
|
||||
await settle()
|
||||
expect(screen.queryByRole('menu')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not reopen a menu the user closed when a pending period ends', async () => {
|
||||
const request = { id: 'model', sequence: 1 }
|
||||
const { rerender } = render(view(false, request))
|
||||
await settle()
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
await settle()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
rerender(view(true, request))
|
||||
await settle()
|
||||
rerender(view(false, request))
|
||||
await settle()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -341,6 +341,44 @@ describe('NativeChatSessionOptionPickers', () => {
|
||||
).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps the model pill shut, label intact, while its choices are still listed', async () => {
|
||||
const pending = model({ settable: false, choicesPending: true })
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[pending, effort]}
|
||||
isWorking={false}
|
||||
pickerRequest={{ id: 'model', sequence: 1 }}
|
||||
/>
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Model Opus 4.8' })
|
||||
expect(trigger.parentElement?.getAttribute('data-disabled')).toBe('true')
|
||||
expect(trigger.closest('[data-testid="dropdown-root"]')?.getAttribute('data-open')).toBe(
|
||||
'false'
|
||||
)
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: 'Effort High' })
|
||||
.parentElement?.getAttribute('data-disabled')
|
||||
).toBeNull()
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), effort]}
|
||||
isWorking={false}
|
||||
pickerRequest={{ id: 'model', sequence: 2 }}
|
||||
/>
|
||||
)
|
||||
const listed = screen.getByRole('button', { name: 'Model Opus 4.8' })
|
||||
expect(listed.parentElement?.getAttribute('data-disabled')).toBeNull()
|
||||
await waitFor(() =>
|
||||
expect(listed.closest('[data-testid="dropdown-root"]')?.getAttribute('data-open')).toBe(
|
||||
'true'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate titles for unknown values or misname generic controls', () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
|
||||
@@ -226,6 +226,8 @@ function NativeChatSessionOptionPickersInner({
|
||||
if (!surface || !model) {
|
||||
return null
|
||||
}
|
||||
// Still listed by the host: the pill shows its value and does not open, even on request.
|
||||
const modelChoicesPending = model.choicesPending === true
|
||||
const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null
|
||||
const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id)
|
||||
? (pickerRequest?.sequence ?? null)
|
||||
@@ -250,12 +252,13 @@ function NativeChatSessionOptionPickersInner({
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<DropdownMenu
|
||||
key={`model:${requestedModelSequence ?? 'idle'}`}
|
||||
defaultOpen={requestedModelSequence !== null}
|
||||
// Read only when a request remounts the menu: one made while pending is spent shut.
|
||||
defaultOpen={requestedModelSequence !== null && !modelChoicesPending}
|
||||
>
|
||||
<PickerTrigger
|
||||
label={nativeChatModelPillLabel(model)}
|
||||
tooltipLabel={modelTooltip}
|
||||
disabled={isWorking || pendingId !== null}
|
||||
disabled={isWorking || pendingId !== null || modelChoicesPending}
|
||||
disabledReason={modelReason}
|
||||
dispatched={sessionOptionDispatchUnconfirmed(model)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
|
||||
|
||||
// One waiting catalog read per chat, shared by every mount and effect run of that chat's picker:
|
||||
// a remote read cannot be withdrawn once sent, so a re-run joins the one in flight instead of
|
||||
// sending another. An entry lives exactly as long as its read.
|
||||
|
||||
/** Null when the read failed or timed out. */
|
||||
export type HostModelListingJoiner = (catalog: AgentSessionModelCatalogResult | null) => void
|
||||
|
||||
const waits = new Map<string, Set<HostModelListingJoiner>>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** Joins the chat's waiting read, started by `read` only when none is in flight. Returns `leave`. */
|
||||
export function joinHostModelListingWait(
|
||||
key: string,
|
||||
read: () => Promise<AgentSessionModelCatalogResult>,
|
||||
onSettled: HostModelListingJoiner
|
||||
): () => void {
|
||||
let joiners = waits.get(key)
|
||||
if (!joiners) {
|
||||
const started = new Set<HostModelListingJoiner>()
|
||||
joiners = started
|
||||
waits.set(key, started)
|
||||
const settle = (catalog: AgentSessionModelCatalogResult | null): void => {
|
||||
try {
|
||||
// Every joiner applies before the hold lifts, so the answer and the release commit together.
|
||||
for (const joiner of started) {
|
||||
joiner(catalog)
|
||||
}
|
||||
} finally {
|
||||
waits.delete(key)
|
||||
notify()
|
||||
}
|
||||
}
|
||||
new Promise<AgentSessionModelCatalogResult>((resolve) => resolve(read())).then(settle, () =>
|
||||
settle(null)
|
||||
)
|
||||
notify()
|
||||
}
|
||||
joiners.add(onSettled)
|
||||
const joined = joiners
|
||||
return () => {
|
||||
joined.delete(onSettled)
|
||||
}
|
||||
}
|
||||
|
||||
export function isHostModelListingWaitInFlight(key: string): boolean {
|
||||
return waits.has(key)
|
||||
}
|
||||
|
||||
export function subscribeHostModelListingWaits(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LOCAL_COMMIT_MESSAGE_HOST_KEY
|
||||
} from '../../../../shared/commit-message-host-key'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { hasExplicitTuiLaunchCommand } from '../../../../shared/tui-agent-launch-command-override'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import {
|
||||
getLocalProjectExecutionRuntimeContext,
|
||||
@@ -126,7 +127,12 @@ export async function discoverNativeChatCatalogModels(
|
||||
const hostCatalogAgent =
|
||||
agent === 'claude' ? ('claude' as const) : agent === 'codex' ? ('codex' as const) : null
|
||||
// Only `local` proves a native pane: a paired runtime's key also covers its SSH/WSL worktrees.
|
||||
if (hostCatalogAgent && hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY) {
|
||||
// A custom launch command runs a binary the structured catalog never lists; the CLI listing honors it.
|
||||
if (
|
||||
hostCatalogAgent &&
|
||||
hostKey === LOCAL_COMMIT_MESSAGE_HOST_KEY &&
|
||||
!hasExplicitTuiLaunchCommand(context.settings, hostCatalogAgent)
|
||||
) {
|
||||
const fromHost = await readLocalHostCatalogModels(hostCatalogAgent)
|
||||
if (fromHost) {
|
||||
return fromHost
|
||||
|
||||
@@ -59,6 +59,24 @@ describe('native chat session option enrichment', () => {
|
||||
expect(paired?.map(({ id }) => id)).toContain('gpt-cli')
|
||||
})
|
||||
|
||||
it('lists through the CLI when this machine runs a custom launch command', async () => {
|
||||
mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({
|
||||
success: true,
|
||||
catalogOrigin: 'probe',
|
||||
models: [{ id: 'gpt-custom', label: 'GPT Custom' }]
|
||||
})
|
||||
const context = {
|
||||
settings: { agentCmdOverrides: { codex: 'codex-nightly' } },
|
||||
worktreeId: 'repo::/worktree',
|
||||
worktreePath: '/worktree'
|
||||
}
|
||||
|
||||
// The structured catalog lists the built-in binary, which this terminal does not run.
|
||||
const models = await discoverNativeChatCatalogModels('codex', context, 'local')
|
||||
expect(mocks.callStructuredAgentSession).not.toHaveBeenCalled()
|
||||
expect(models?.map(({ id }) => id)).toContain('gpt-custom')
|
||||
})
|
||||
|
||||
it('bounds settled host enrichment entries', async () => {
|
||||
for (let index = 0; index < NATIVE_CHAT_MODEL_ENRICHMENT_MAX_ENTRIES + 4; index += 1) {
|
||||
ensureNativeChatModelEnrichment({
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, render, renderHook } from '@testing-library/react'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-session-option-settings-write', () => ({
|
||||
enqueueSessionOptionSettingsWrite: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/structured-agent-session-launch-options', () => ({
|
||||
holdStructuredAgentSessionLaunchOption: vi.fn(() => Promise.resolve({ kind: 'held' })),
|
||||
getStructuredAgentSessionLaunchSelection: () => null
|
||||
}))
|
||||
|
||||
import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options'
|
||||
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
|
||||
import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options'
|
||||
|
||||
const PAIRED_TARGET = { kind: 'environment', environmentId: 'server-1' } as const
|
||||
const UNKNOWN = { origin: 'unknown' }
|
||||
const LISTING = { origin: 'unknown', listingInProgress: true }
|
||||
const HOST_CATALOG = {
|
||||
origin: 'probe',
|
||||
models: [{ id: 'gpt-hosted', label: 'GPT Hosted', isDefault: true, efforts: [] }],
|
||||
fetchedAt: 1_000
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: no test here sends a pick over a fence, so mutate is never called.
|
||||
const mutate = vi.fn(async () => null) as unknown as StructuredAgentSessionMutate
|
||||
|
||||
type Props = { hidden?: boolean; attached?: boolean; sessionId?: string }
|
||||
|
||||
// A chat's waiting read outlives its mounts, so each test gets its own chat.
|
||||
let sessionId = ''
|
||||
let sessionCount = 0
|
||||
|
||||
function renderOptions(initial: Props = {}) {
|
||||
return renderHook(
|
||||
(props: Props) =>
|
||||
useStructuredAgentSessionOptions({
|
||||
agent: 'codex',
|
||||
sessionId: props.sessionId ?? sessionId,
|
||||
target: PAIRED_TARGET,
|
||||
transportEnabled: props.attached === true,
|
||||
isVisible: !props.hidden,
|
||||
providerVisible: props.attached === true && !props.hidden,
|
||||
fence: props.attached ? 1 : null,
|
||||
turnId: null,
|
||||
unloadedTurnRevisions: undefined,
|
||||
mutate,
|
||||
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
|
||||
}),
|
||||
{ initialProps: initial }
|
||||
)
|
||||
}
|
||||
|
||||
type Deferred = {
|
||||
promise: Promise<unknown>
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve!: (value: unknown) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<unknown>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Each catalog read takes the next answer; `options` reads never settle unless given. */
|
||||
function answerCatalog(answers: (() => Promise<unknown>)[]): void {
|
||||
let index = 0
|
||||
mocks.call.mockImplementation((_target: unknown, method: string) => {
|
||||
if (method === 'agentSession.modelCatalog') {
|
||||
const next = answers[index]
|
||||
index += 1
|
||||
return next ? next() : new Promise(() => {})
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
function catalogReads(): unknown[] {
|
||||
return mocks.call.mock.calls
|
||||
.filter(([, method]) => method === 'agentSession.modelCatalog')
|
||||
.map(([, , params]) => params)
|
||||
}
|
||||
|
||||
function model(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor {
|
||||
return snapshot.find((entry) => entry.id === 'model')!
|
||||
}
|
||||
|
||||
function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] {
|
||||
const descriptor = model(snapshot)
|
||||
return descriptor.kind.type === 'select' ? descriptor.kind.choices.map((c) => c.value) : []
|
||||
}
|
||||
|
||||
const flush = (): Promise<void> => act(async () => {})
|
||||
|
||||
describe('host model catalog read', () => {
|
||||
beforeEach(() => {
|
||||
mocks.call.mockReset()
|
||||
sessionCount += 1
|
||||
sessionId = `session-${sessionCount}`
|
||||
})
|
||||
|
||||
it('reads a warm catalog once and never holds the picker', async () => {
|
||||
const first = deferred()
|
||||
answerCatalog([() => first.promise])
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
// A read in flight is not a reason to hold the picker.
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
first.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
expect(catalogReads()).toEqual([{ agent: 'codex', sessionId }])
|
||||
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('waits once for a listing the host reports, holding the picker until it lands', async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(catalogReads()).toEqual([
|
||||
{ agent: 'codex', sessionId },
|
||||
{ agent: 'codex', sessionId, waitForListing: true }
|
||||
])
|
||||
const held = model(result.current.optionSnapshot)
|
||||
expect(held).toMatchObject({ choicesPending: true, settable: false })
|
||||
// The label stays: the pill still names the launch's model.
|
||||
expect(held.kind.type === 'select' ? held.kind.currentValue : null).toBe('gpt-5.5')
|
||||
await act(async () => {
|
||||
expect(await result.current.setStructuredOption('model', 'gpt-5.5')).toBe(false)
|
||||
})
|
||||
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
expect(catalogReads()).toHaveLength(2)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('releases the picker on the seed when the waiting read fails or times out', async () => {
|
||||
for (const failure of [
|
||||
() => Promise.resolve(UNKNOWN),
|
||||
() => Promise.reject(Object.assign(new Error('timed out'), { code: 'runtime_timeout' })),
|
||||
() => Promise.reject(Object.assign(new Error('gone'), { code: 'not_connected' }))
|
||||
]) {
|
||||
mocks.call.mockReset()
|
||||
answerCatalog([() => Promise.resolve(LISTING), failure])
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(catalogReads()).toHaveLength(2)
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
expect(model(result.current.optionSnapshot).settable).toBe(true)
|
||||
expect(modelChoices(result.current.optionSnapshot)).not.toContain('gpt-hosted')
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('asks a host that reports no listing nothing more', async () => {
|
||||
answerCatalog([() => Promise.resolve(UNKNOWN)])
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(catalogReads()).toHaveLength(1)
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('releases the picker when the running provider reports its own list first', async () => {
|
||||
const live = deferred()
|
||||
answerCatalog([() => Promise.resolve(LISTING), () => new Promise(() => {})])
|
||||
const catalogAnswers = mocks.call.getMockImplementation()!
|
||||
mocks.call.mockImplementation((target: unknown, method: string, params: unknown) =>
|
||||
method === 'agentSession.options' ? live.promise : catalogAnswers(target, method, params)
|
||||
)
|
||||
const { result, unmount } = renderOptions({ attached: true })
|
||||
await flush()
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
|
||||
live.resolve({
|
||||
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
|
||||
current: { model: 'gpt-live', confirmed: ['model'] }
|
||||
})
|
||||
await flush()
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-live')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('keeps the hold through attach and joins the wait already in flight', async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
|
||||
const { result, rerender, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
|
||||
rerender({ attached: true })
|
||||
// No frame on the stand-in list between the old fence and the new one.
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
|
||||
await flush()
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
|
||||
expect(catalogReads()).toHaveLength(2)
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('joins the wait already in flight when the pane hides and shows again', async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
|
||||
const { result, rerender, unmount } = renderOptions()
|
||||
await flush()
|
||||
rerender({ hidden: true })
|
||||
await flush()
|
||||
rerender({})
|
||||
await flush()
|
||||
expect(catalogReads()).toHaveLength(2)
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('joins the wait already in flight when the chat mounts again', async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
|
||||
const first = renderOptions()
|
||||
await flush()
|
||||
first.unmount()
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(catalogReads()).toHaveLength(2)
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBe(true)
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
expect(modelChoices(result.current.optionSnapshot)).toContain('gpt-hosted')
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('holds and answers only the chat whose host is listing', async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([
|
||||
() => Promise.resolve(LISTING),
|
||||
() => waited.promise,
|
||||
() => Promise.resolve(UNKNOWN)
|
||||
])
|
||||
const { result, rerender, unmount } = renderOptions()
|
||||
await flush()
|
||||
rerender({ sessionId: `${sessionId}-other` })
|
||||
await flush()
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
expect(modelChoices(result.current.optionSnapshot)).not.toContain('gpt-hosted')
|
||||
expect(catalogReads()).toHaveLength(3)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('applies nothing and keeps nothing when the answer lands after the chat closed', async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([
|
||||
() => Promise.resolve(LISTING),
|
||||
() => waited.promise,
|
||||
() => Promise.resolve(UNKNOWN)
|
||||
])
|
||||
const first = renderOptions()
|
||||
await flush()
|
||||
first.unmount()
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await flush()
|
||||
// A later open of the same chat asks afresh instead of joining a finished wait.
|
||||
const { result, unmount } = renderOptions()
|
||||
await flush()
|
||||
expect(catalogReads()).toHaveLength(3)
|
||||
expect(model(result.current.optionSnapshot).choicesPending).toBeUndefined()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
function CommitRecorder(props: { sessionId: string; attached: boolean; commits: string[] }) {
|
||||
const { optionSnapshot } = useStructuredAgentSessionOptions({
|
||||
agent: 'codex',
|
||||
sessionId: props.sessionId,
|
||||
target: PAIRED_TARGET,
|
||||
transportEnabled: props.attached,
|
||||
isVisible: true,
|
||||
providerVisible: false,
|
||||
fence: props.attached ? 1 : null,
|
||||
turnId: null,
|
||||
unloadedTurnRevisions: undefined,
|
||||
mutate,
|
||||
launch: { kind: 'new', seedOptions: { model: 'gpt-5.5' }, heldOptions: {} }
|
||||
})
|
||||
const descriptor = model(optionSnapshot)
|
||||
const state = `held=${descriptor.choicesPending === true} hosted=${modelChoices(optionSnapshot).includes('gpt-hosted')}`
|
||||
// No deps: one entry per commit, which act() would otherwise batch out of sight.
|
||||
useLayoutEffect(() => {
|
||||
props.commits.push(state)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
describe('the end of a host listing wait', () => {
|
||||
beforeEach(() => {
|
||||
mocks.call.mockReset()
|
||||
sessionCount += 1
|
||||
sessionId = `session-${sessionCount}`
|
||||
})
|
||||
|
||||
for (const attachedFirst of [false, true]) {
|
||||
it(`never commits the built-in list unheld before the host list (${attachedFirst ? 'joined after attach' : 'started here'})`, async () => {
|
||||
const waited = deferred()
|
||||
answerCatalog([() => Promise.resolve(LISTING), () => waited.promise])
|
||||
const commits: string[] = []
|
||||
const view = render(
|
||||
<CommitRecorder sessionId={sessionId} attached={false} commits={commits} />
|
||||
)
|
||||
await flush()
|
||||
if (attachedFirst) {
|
||||
view.rerender(<CommitRecorder sessionId={sessionId} attached commits={commits} />)
|
||||
await flush()
|
||||
}
|
||||
expect(commits.at(-1)).toBe('held=true hosted=false')
|
||||
const settledFrom = commits.length
|
||||
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', false)
|
||||
try {
|
||||
waited.resolve(HOST_CATALOG)
|
||||
await vi.waitFor(() => expect(commits.at(-1)).toBe('held=false hosted=true'))
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
} finally {
|
||||
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
|
||||
}
|
||||
expect(commits.slice(settledFrom)).not.toContain('held=false hosted=false')
|
||||
view.unmount()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type MutableRefObject } from 'react'
|
||||
import { useEffect, useSyncExternalStore, type MutableRefObject } from 'react'
|
||||
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog'
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from '../../../../shared/structured-agent-session-options'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability'
|
||||
import type { NativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state'
|
||||
import {
|
||||
isHostModelListingWaitInFlight,
|
||||
joinHostModelListingWait,
|
||||
subscribeHostModelListingWaits
|
||||
} from './host-model-listing-waits'
|
||||
|
||||
/**
|
||||
* Upgrades the static seed with the host's stored catalog without waiting on
|
||||
@@ -16,6 +22,10 @@ import type { NativeChatSessionOptionRecord } from '../../../../shared/native-ch
|
||||
* would pin, so the picker warms during create. An older host answers
|
||||
* `forbidden` or `method_not_found` — both mean "no such surface", so the seed
|
||||
* stands until the live read lands.
|
||||
*
|
||||
* When the host says its first listing for the account is running, one more
|
||||
* read waits for it — one per chat, joined by every later run and remount.
|
||||
* Returns true while that read is in flight.
|
||||
*/
|
||||
export function useHostModelCatalogUpgrade(args: {
|
||||
agent: AgentType
|
||||
@@ -33,7 +43,7 @@ export function useHostModelCatalogUpgrade(args: {
|
||||
updateOptionState: (
|
||||
update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState
|
||||
) => void
|
||||
}): void {
|
||||
}): boolean {
|
||||
const {
|
||||
activeOptionRecordRef,
|
||||
agent,
|
||||
@@ -46,30 +56,58 @@ export function useHostModelCatalogUpgrade(args: {
|
||||
updateOptionState,
|
||||
worktree
|
||||
} = args
|
||||
const waitKey = `${structuredAgentSessionHostKey(target)}\u0000${agent}\u0000${sessionId}`
|
||||
const awaitingListing = useSyncExternalStore(subscribeHostModelListingWaits, () =>
|
||||
isHostModelListingWaitInFlight(waitKey)
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!enabled || !optionCatalog || (agent !== 'claude' && agent !== 'codex')) {
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
void callStructuredAgentSession<AgentSessionModelCatalogResult>(
|
||||
target,
|
||||
'agentSession.modelCatalog',
|
||||
{ agent, sessionId, ...(namesDefault && worktree ? { worktree } : {}) }
|
||||
)
|
||||
.then((catalog) => {
|
||||
if (!stale) {
|
||||
updateOptionState((current) =>
|
||||
current.record === activeOptionRecordRef.current
|
||||
? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, {
|
||||
namesDefault
|
||||
})
|
||||
: current
|
||||
)
|
||||
const params = { agent, sessionId, ...(namesDefault && worktree ? { worktree } : {}) }
|
||||
const read = (waitForListing: boolean): Promise<AgentSessionModelCatalogResult> =>
|
||||
callStructuredAgentSession<AgentSessionModelCatalogResult>(
|
||||
target,
|
||||
'agentSession.modelCatalog',
|
||||
waitForListing ? { ...params, waitForListing } : params
|
||||
)
|
||||
const apply = (catalog: AgentSessionModelCatalogResult): void =>
|
||||
updateOptionState((current) =>
|
||||
current.record === activeOptionRecordRef.current
|
||||
? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, {
|
||||
namesDefault
|
||||
})
|
||||
: current
|
||||
)
|
||||
let leave: (() => void) | null = null
|
||||
const waitForListing = (): void => {
|
||||
leave = joinHostModelListingWait(waitKey, () => read(true), (catalog) => {
|
||||
if (catalog) {
|
||||
apply(catalog)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
if (isHostModelListingWaitInFlight(waitKey)) {
|
||||
waitForListing()
|
||||
} else {
|
||||
void read(false)
|
||||
.then((catalog) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
// Only a host that reports the listing knows the wait param; an older one refuses it.
|
||||
if (catalog.origin === 'unknown' && catalog.listingInProgress === true) {
|
||||
waitForListing()
|
||||
} else {
|
||||
apply(catalog)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
return () => {
|
||||
stale = true
|
||||
leave?.()
|
||||
}
|
||||
}, [
|
||||
activeOptionRecordRef,
|
||||
@@ -81,6 +119,8 @@ export function useHostModelCatalogUpgrade(args: {
|
||||
sessionId,
|
||||
target,
|
||||
updateOptionState,
|
||||
waitKey,
|
||||
worktree
|
||||
])
|
||||
return awaitingListing
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
canSetStructuredAgentSessionOption,
|
||||
commitStructuredAgentSessionOptionValues,
|
||||
lockedStructuredAgentSessionOptionSnapshot,
|
||||
pendingModelListStructuredAgentSessionOptionSnapshot,
|
||||
structuredAgentSessionOptionPicks,
|
||||
structuredAgentSessionOptionSnapshot,
|
||||
structuredAgentSessionOptionView,
|
||||
@@ -89,7 +90,7 @@ export function useStructuredAgentSessionOptions(args: {
|
||||
unloadedTurnRevisions: args.unloadedTurnRevisions
|
||||
})
|
||||
|
||||
useHostModelCatalogUpgrade({
|
||||
const awaitingHostModelList = useHostModelCatalogUpgrade({
|
||||
agent,
|
||||
sessionId,
|
||||
target,
|
||||
@@ -103,6 +104,8 @@ export function useStructuredAgentSessionOptions(args: {
|
||||
activeOptionRecordRef,
|
||||
updateOptionState
|
||||
})
|
||||
// The running provider's own list ends the wait for the host's.
|
||||
const modelListPending = awaitingHostModelList && optionState.catalogSource !== 'live'
|
||||
|
||||
// What a settled pick must remember so the next launch starts where the user left off.
|
||||
const rememberOptionPicks = useCallback(
|
||||
@@ -205,8 +208,9 @@ export function useStructuredAgentSessionOptions(args: {
|
||||
const snapshot = structuredAgentSessionOptionSnapshot(
|
||||
structuredAgentSessionOptionView(optionState, launchSeedOptions, held)
|
||||
)
|
||||
return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot)
|
||||
}, [acceptsPicks, held, launchSeedOptions, optionState])
|
||||
const shown = acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot)
|
||||
return modelListPending ? pendingModelListStructuredAgentSessionOptionSnapshot(shown) : shown
|
||||
}, [acceptsPicks, held, launchSeedOptions, modelListPending, optionState])
|
||||
const setStructuredOption = useCallback(
|
||||
async (id: string, value: string | boolean): Promise<boolean> => {
|
||||
const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held)
|
||||
@@ -214,6 +218,8 @@ export function useStructuredAgentSessionOptions(args: {
|
||||
if (
|
||||
!optionCatalog ||
|
||||
encoded === null ||
|
||||
// A typed `/model` reaches here without the picker.
|
||||
optionSnapshot.some((descriptor) => descriptor.id === id && descriptor.choicesPending) ||
|
||||
!canSetStructuredAgentSessionOption(view, id, value)
|
||||
) {
|
||||
return false
|
||||
@@ -234,6 +240,7 @@ export function useStructuredAgentSessionOptions(args: {
|
||||
held,
|
||||
launchSeedOptions,
|
||||
optionCatalog,
|
||||
optionSnapshot,
|
||||
optionStateRef,
|
||||
pendingOptionRef,
|
||||
sendStructuredOption,
|
||||
|
||||
@@ -123,6 +123,30 @@ describe('callStructuredAgentSession rewind capability', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('callStructuredAgentSession timeouts', () => {
|
||||
const target = { kind: 'environment', environmentId: 'env-1' } as const
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.call.mockResolvedValue({ origin: 'unknown' })
|
||||
})
|
||||
|
||||
// A waiting read lasts as long as the host's listing, which outlasts the default 15 s.
|
||||
it('gives a model catalog read longer than the slowest host listing', async () => {
|
||||
const params = { agent: 'claude', sessionId: 'session-1', waitForListing: true }
|
||||
await callStructuredAgentSession(target, 'agentSession.modelCatalog', params)
|
||||
const [, , , options] = mocks.call.mock.calls[0]!
|
||||
expect(options.timeoutMs).toBeGreaterThan(80_000)
|
||||
})
|
||||
|
||||
it('leaves other reads on the default timeout', async () => {
|
||||
await callStructuredAgentSession(target, 'agentSession.options', { sessionId: 'session-1' })
|
||||
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.options', {
|
||||
sessionId: 'session-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribeStructuredAgentSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -85,6 +85,13 @@ export async function readStructuredAgentSessionConversationOutline(
|
||||
)
|
||||
}
|
||||
|
||||
const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap<string, number> = new Map([
|
||||
['agentSession.conversationCommand', 195_000],
|
||||
// A waiting catalog read lasts as long as the host's listing: Claude's is 60 s, after up to 15 s
|
||||
// for an account switch to settle and 5 s of login-shell environment.
|
||||
['agentSession.modelCatalog', 90_000]
|
||||
])
|
||||
|
||||
export async function callStructuredAgentSession<TResult>(
|
||||
target: RuntimeClientTarget,
|
||||
method: string,
|
||||
@@ -100,9 +107,10 @@ export async function callStructuredAgentSession<TResult>(
|
||||
) {
|
||||
throw new Error('Rewinding requires a newer Orca server. Update the server and try again.')
|
||||
}
|
||||
return method === 'agentSession.conversationCommand'
|
||||
? callRuntimeRpc<TResult>(target, method, params, { timeoutMs: 195_000 })
|
||||
: callRuntimeRpc<TResult>(target, method, params)
|
||||
const timeoutMs = STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method)
|
||||
return timeoutMs === undefined
|
||||
? callRuntimeRpc<TResult>(target, method, params)
|
||||
: callRuntimeRpc<TResult>(target, method, params, { timeoutMs })
|
||||
}
|
||||
|
||||
async function subscribeStructuredAgentSessionMethod<TEvent>(
|
||||
|
||||
@@ -430,7 +430,12 @@ export type AgentSessionFastModeSupport = {
|
||||
* surface: an older host simply lacks the method.
|
||||
*/
|
||||
export type AgentSessionModelCatalogResult =
|
||||
| { origin: 'unknown' }
|
||||
| {
|
||||
origin: 'unknown'
|
||||
/** The host is running its first listing for this account; a `waitForListing` read answers
|
||||
* when it lands. Absent from a host that predates it. */
|
||||
listingInProgress?: true
|
||||
}
|
||||
| {
|
||||
/** What produced the listing; any age is served, `fetchedAt` carries it. */
|
||||
origin: 'live-session' | 'probe'
|
||||
|
||||
@@ -52,6 +52,8 @@ export type SessionOptionDescriptor = {
|
||||
transport: NativeChatLiveOptionTransport
|
||||
settable: boolean
|
||||
disabledReason?: SessionOptionDisabledReason
|
||||
/** The host is still listing this option's choices: the pill keeps its value but cannot open. */
|
||||
choicesPending?: true
|
||||
/** Why: picker-only and toggle-only PTY commands cannot be represented as
|
||||
* a truthful radio/checkbox state, so the producer exposes an action row. */
|
||||
action?: { type: 'agent-picker' | 'toggle-command' }
|
||||
|
||||
@@ -293,11 +293,14 @@ export const OptionsParams = z.object({ sessionId: SessionId }).strict()
|
||||
|
||||
/** `sessionId` scopes the catalog to that session's pinned account; without a
|
||||
* session record the host keys it by the account a new launch would pin.
|
||||
* `worktree` names where a new chat runs, whose own config may replace the default. */
|
||||
* `worktree` names where a new chat runs, whose own config may replace the default.
|
||||
* `waitForListing` holds the answer until the listing the host reported in progress lands; send
|
||||
* it only after that report, because a host that predates it refuses the unknown key. */
|
||||
export const ModelCatalogParams = z.strictObject({
|
||||
agent: z.enum(['claude', 'codex']),
|
||||
sessionId: SessionId.optional(),
|
||||
worktree: Identifier('Invalid worktree selector').optional()
|
||||
worktree: Identifier('Invalid worktree selector').optional(),
|
||||
waitForListing: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const ConversationCommandParams = z
|
||||
|
||||
@@ -69,6 +69,26 @@ describe('runtime RPC call queue', () => {
|
||||
await expect(Promise.all(deletes)).resolves.toEqual(['rm-1', 'rm-2', 'rm-3'])
|
||||
})
|
||||
|
||||
it('runs foreground calls while model catalog reads wait on a host listing', async () => {
|
||||
const queue = new RuntimeRpcCallQueuePool(8, 2)
|
||||
const release: (() => void)[] = []
|
||||
const waits = Array.from({ length: 8 }, () =>
|
||||
queue.enqueue('web-runtime', 'agentSession.modelCatalog', async () => {
|
||||
await new Promise<void>((resolve) => release.push(resolve))
|
||||
return 'listed'
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => expect(release).toHaveLength(8))
|
||||
|
||||
const send = queue.enqueue('web-runtime', 'agentSession.send', async () => 'sent')
|
||||
await expect(send).resolves.toBe('sent')
|
||||
|
||||
for (const resolve of release) {
|
||||
resolve()
|
||||
}
|
||||
await expect(Promise.all(waits)).resolves.toEqual(Array(8).fill('listed'))
|
||||
})
|
||||
|
||||
it('frees the queue slot when a runtime call throws synchronously', async () => {
|
||||
const queue = new RuntimeRpcCallQueuePool(1, 1)
|
||||
const first = queue.enqueue('web-runtime', 'status.get', () => {
|
||||
|
||||
@@ -51,10 +51,11 @@ export function isBackgroundRuntimeMethod(method: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Why its own lane: worktree.rm replies only when Git has deleted the checkout, so its calls would
|
||||
// hold the foreground slots listing refreshes need; the background lane's slots belong to status.
|
||||
// Why its own lane: these reply only when slow host work finishes (worktree.rm when Git has deleted
|
||||
// the checkout, a model catalog read that waits on the first listing), so they would hold the
|
||||
// foreground slots listing refreshes and sends need; the background lane's slots belong to status.
|
||||
function isLongWaitRuntimeMethod(method: string): boolean {
|
||||
return method === 'worktree.rm'
|
||||
return method === 'worktree.rm' || method === 'agentSession.modelCatalog'
|
||||
}
|
||||
|
||||
export class RuntimeRpcCallQueuePool {
|
||||
|
||||
@@ -222,6 +222,17 @@ export function lockedStructuredAgentSessionOptionSnapshot(
|
||||
}))
|
||||
}
|
||||
|
||||
/** While the host runs its first model listing, nothing is picked from the stand-in list. */
|
||||
export function pendingModelListStructuredAgentSessionOptionSnapshot(
|
||||
snapshot: readonly SessionOptionDescriptor[]
|
||||
): SessionOptionDescriptor[] {
|
||||
return snapshot.map((descriptor) =>
|
||||
descriptor.category === 'model'
|
||||
? { ...descriptor, settable: false, choicesPending: true }
|
||||
: descriptor
|
||||
)
|
||||
}
|
||||
|
||||
export function canSetStructuredAgentSessionOption(
|
||||
state: StructuredAgentSessionOptionState,
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user