Prevent resting chats from delaying native command menus (#35069)

* Scope native chat command discovery to menu demand

* Fix command discovery CI imports and test timing

* fix: recover demanded command menus after context retirement

* test: load command host fixture before mocked consumers

* test: retain eager cache reads across command expiry

* test: cover command recovery after pending deadline

* test: count demanded command renders and query isolation

* test: load mobile command host fixture during collection

* test: type command batch fields from subscribe events

* test: check command event arrays without assertion

* Fix command discovery fixture project boundaries

* Name command fixture mocks with public Vitest types

* Assert opening metadata on resumed session snapshots

* test: assert passive command population boundaries

* test: cover mobile command capability handoff

* test: correct capability proof scope adapters
This commit is contained in:
Brennan Benson
2026-10-29 13:16:35 -07:00
committed by GitHub
parent e69baa3499
commit e08cb10cc6
67 changed files with 3639 additions and 347 deletions
@@ -77,7 +77,7 @@ describe('real phone inline composer from More through picker pruning and host b
agent = 'codex',
sessionId = SESSION_ID,
catalog: {
reported: { name: string; kind: 'skill' | 'command'; path?: string }[]
reported: { name: string; kind: 'skill' | 'command'; path?: string }[] | undefined
discovery?: NativeChatSkillDiscoverySnapshot
} = {
reported: []
@@ -101,7 +101,7 @@ describe('real phone inline composer from More through picker pruning and host b
},
skillDiscovery: catalog.discovery ?? {
status: 'ready',
skills: catalog.reported
skills: (catalog.reported ?? [])
.filter((entry) => entry.kind === 'skill' && entry.path)
.map((entry) => ({
id: entry.name,
@@ -281,6 +281,44 @@ describe('real phone inline composer from More through picker pruning and host b
host.leases.dispose()
}
)
it.each([undefined, [], [{ name: 'fast', kind: 'command' as const }]])(
'saves provider text under inventory %j and keeps refused edits',
async (reported) => {
for (const text of ['/fast', '/permissions']) {
const message = queuedDraft({
messageId: 'provider-edit',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'seed' }] }
})
const host = queuedEditHost(SESSION_ID, message)
try {
const { button, field } = await rows(message, 'codex', SESSION_ID, { reported })
sendRequest.mockImplementation(async (method, params) =>
method === 'agentSession.queuedMessageUpdate'
? { id: 'refused', ok: false, error: { code: 'UNAVAILABLE', message: 'Try again' } }
: readOrHoldReply(method, params)
)
act(() => field().props.onChangeText(text))
await act(async () => button('Save edited message').props.onPress())
expect(state.hook!.queued.editor?.text).toBe(text)
sendRequest.mockImplementation(async (method, params) =>
method === 'agentSession.queuedMessageUpdate'
? mutationOk(host.update(QueuedMessageUpdateParams.parse(params)))
: readOrHoldReply(method, params)
)
await act(async () => button('Save edited message').props.onPress())
expect(host.row!.body.blocks).toEqual([{ type: 'text', text }])
expect(
sendRequest.mock.calls.some(
([method]) =>
method === 'agentSession.send' || method === 'agentSession.conversationCommand'
)
).toBe(false)
} finally {
host.leases.dispose()
}
}
}
)
it('keeps both saved files on clean Save and projects the phone picked list after an argument edit', async () => {
const skills = [
{ name: 'review', path: '/A/SKILL.md' },
@@ -62,6 +62,7 @@ export type MobileStructuredAgentSessionArgs = {
hostSupport: StructuredAgentSessionHostSupport | null
agent: string | null
/** What the session reported for its `/` menu; undefined until read or on an older host. */
onCommandsChanged?: () => void
sessionCommands?: readonly AgentSessionSlashCommand[]
composerScopeKey?: string | null
appendComposerText?: (text: string) => boolean
@@ -1,4 +1,7 @@
import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities'
import {
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY,
AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY
} from '../../../src/shared/structured-agent-session-surface-capabilities'
import {
AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY,
@@ -27,6 +30,7 @@ import {
import { SKILL_DISCOVER_WORKSPACE_CAPABILITY } from '../../../src/shared/skill-install-capability'
export type StructuredAgentSessionHostSupport = {
commandsOnRequest?: boolean
coherentModelCatalog?: boolean
promptCancel: boolean
questionAnswers: boolean
@@ -61,6 +65,7 @@ export function structuredAgentSessionHostSupport(
capabilities: readonly string[]
): StructuredAgentSessionHostSupport {
return {
commandsOnRequest: capabilities.includes(AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY),
coherentModelCatalog: capabilities.includes(AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY),
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY),
@@ -125,11 +125,8 @@ describe('mobile structured conversation commands', () => {
const { input, sendRequest } = setup()
input.text = '/fast'
input.controller.agent = 'omp'
await dispatchMobileStructuredCommand(input)
expect(input.onError).toHaveBeenCalledWith(
expect.stringContaining('/fast is not available'),
undefined
)
expect(await dispatchMobileStructuredCommand(input)).toBeNull()
expect(input.onError).not.toHaveBeenCalled()
input.controller.sessionCommands = [{ name: 'fast', kind: 'command' }]
expect(await dispatchMobileStructuredCommand(input)).toBeNull()
expect(sendRequest).not.toHaveBeenCalled()
@@ -347,7 +347,8 @@ describe('MobileNativeChatComposer on a structured chat', () => {
it('puts a picked skill at the start of the draft, highlighted, and deletes it as one unit', async () => {
const onChangeText = vi.fn()
const catalog = structured()
const onSlashMenuDemand = vi.fn()
const catalog = structured({ onSlashMenuDemand })
await act(async () => {
renderer = create(draw({ value: '/re', onChangeText, structured: catalog }))
})
@@ -355,6 +356,7 @@ describe('MobileNativeChatComposer on a structured chat', () => {
field().props.onSelectionChange({ nativeEvent: { selection: { end: 3 } } })
)
expect(catalog.onSlashMenuOpen).toHaveBeenCalled()
expect(onSlashMenuDemand).toHaveBeenLastCalledWith(true)
await act(async () => labelled('/review-pr')[0]!.props.onPress())
expect(onChangeText).toHaveBeenLastCalledWith('/review-pr ')
expect(field().props.selection).toEqual({ start: 11, end: 11 })
@@ -362,6 +364,7 @@ describe('MobileNativeChatComposer on a structured chat', () => {
await act(async () =>
renderer!.update(draw({ value: '/review-pr focus', onChangeText, structured: catalog }))
)
expect(onSlashMenuDemand).toHaveBeenLastCalledWith(false)
const token = field().find((node) => node.props.children === '/review-pr')
expect(token.props.style).toMatchObject({ backgroundColor: expect.any(String) })
await act(async () => field().props.onChangeText('/review-pr focus on auth'))
@@ -44,6 +44,7 @@ const NO_FILES: PendingNativeChatFile[] = []
export type MobileNativeChatStructuredComposer = {
catalog: MobileComposerStructuredCatalog
onSlashMenuOpen: () => void
onSlashMenuDemand?: (open: boolean) => void
onAttachFiles?: () => void
// An uploading attachment must hold Send.
files: readonly PendingNativeChatFile[]
@@ -164,6 +165,7 @@ export function MobileNativeChatComposer({
skillDiscovery,
onNeedSkills,
onSlashMenuOpen: structured?.onSlashMenuOpen,
onSlashMenuDemand: structured?.onSlashMenuDemand,
focusField: () => fieldRef.current?.focus()
})
const dictation = useMobileNativeChatComposerDictation({
@@ -36,6 +36,7 @@ export function useMobileNativeChatComposerMenu(input: {
onNeedSkills?: () => void
/** The `/` menu opened; the session's list may have changed since it was read. */
onSlashMenuOpen?: () => void
onSlashMenuDemand?: (open: boolean) => void
/** A picked skill; a send carries its file while its token stays, and treats a leading one as chat. */
onPickSkill?: (skill: { token: string; path?: string }) => void
editing?: boolean
@@ -124,6 +125,7 @@ export function useMobileNativeChatComposerMenu(input: {
onNeedFiles?.(mentionQuery)
}
}, [onNeedFiles, mentionQuery])
const { onSlashMenuDemand } = input
const slashOpen = autocomplete.mode === 'slash'
useEffect(() => {
if (slashOpen) {
@@ -131,6 +133,11 @@ export function useMobileNativeChatComposerMenu(input: {
}
}, [onSlashMenuOpen, slashOpen])
useEffect(() => {
onSlashMenuDemand?.(slashOpen)
return () => onSlashMenuDemand?.(false)
}, [onSlashMenuDemand, slashOpen])
const placeCaret = useCallback((at: number): void => {
setCursor(at)
setPendingSelection({ start: at, end: at })
@@ -16,7 +16,7 @@ export function useMobileNativeChatStructuredComposer(args: {
}): MobileNativeChatStructuredComposer | null {
const { structured, acceptsFiles, files } = args
const conversationCommands = args.conversationCommands ?? NO_COMMANDS
const { commands, refresh } = args.sessionCommands
const { commands, refresh, setMenuOpen } = args.sessionCommands
const { files: pendingFiles, attachFiles, removeFiles } = files
// Memoize commands separately so attachment changes cannot rebuild the menu catalog.
const catalog = useMemo(
@@ -29,11 +29,21 @@ export function useMobileNativeChatStructuredComposer(args: {
? {
catalog,
onSlashMenuOpen: refresh,
onSlashMenuDemand: setMenuOpen,
...(acceptsFiles ? { onAttachFiles: () => void attachFiles() } : {}),
files: pendingFiles,
onRemoveFile: (id: string) => removeFiles([id])
}
: null,
[acceptsFiles, attachFiles, catalog, pendingFiles, refresh, removeFiles, structured]
[
acceptsFiles,
attachFiles,
catalog,
pendingFiles,
refresh,
removeFiles,
setMenuOpen,
structured
]
)
}
@@ -0,0 +1,490 @@
import { createElement, useState } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { Animated } from 'react-native'
import { expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcResponse } from '../transport/types'
import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from '../transport/mobile-runtime-client-capabilities'
import type { MobileCommandMenuHostFixtureModule } from '../../../src/shared/mobile-queued-edit-host.test-fixture-contract'
import { useMobileSessionFeedbackCapabilities } from './use-mobile-session-feedback-capabilities'
import { useMobileSessionTabReconciliation } from './use-mobile-session-tab-reconciliation'
import { useMobileNativeChatSessionLane } from './use-mobile-native-chat-session-lane'
import { resolveMobileNativeChat } from './native-chat/mobile-native-chat-eligibility'
import { useMobileNativeChatStructuredComposer } from './native-chat/composer/use-mobile-native-chat-structured-composer'
import { useMobileNativeChatComposerMenu } from './native-chat/composer/use-mobile-native-chat-composer-menu'
// Transcript/terminal services are outside this capability-to-command ownership contract.
vi.mock('./use-mobile-native-chat-session', () => ({ useMobileNativeChatSession: () => ({}) }))
vi.mock('./use-mobile-structured-agent-session', () => ({
useMobileStructuredAgentSession: () => ({ session: {} })
}))
const terminal = vi.hoisted(() => ({ recover: vi.fn() }))
vi.mock('./use-mobile-session-tabs-reconciliation', () => ({
useMobileSessionTabsReconciliation: () => ({ requestTerminalInventoryRecovery: terminal.recover })
}))
vi.mock('react-native', () => ({ Animated: { Value: class {} } }))
const { createMobileCommandMenuHostFixture } =
await vi.importActual<MobileCommandMenuHostFixtureModule>(
'../../../src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture'
)
type Host = Awaited<ReturnType<typeof createMobileCommandMenuHostFixture>>
const ok = (result: unknown): RpcResponse => ({ id: 'reply', ok: true, result })
const supported = () => ok({ capabilities: MOBILE_RUNTIME_CLIENT_CAPABILITIES })
function peer(host: Host) {
const statuses: ReturnType<typeof Promise.withResolvers<RpcResponse>>[] = []
let statusFailure: 'refusal' | 'timeout' | undefined,
rejectCommands = false
let responseGate: Promise<void> | undefined
const send = vi.fn<RpcClient['sendRequest']>(async (method, params) => {
if (method === 'status.get') {
if (statusFailure === 'timeout') {
throw new Error('status timed out')
}
if (statusFailure === 'refusal') {
return { id: 'refused', ok: false, error: { code: 'FORBIDDEN', message: 'Unavailable' } }
}
const status = Promise.withResolvers<RpcResponse>()
statuses.push(status)
return status.promise
}
if (method !== 'agentSession.commands') {
throw new Error(`unexpected ${method}`)
}
if (rejectCommands) {
return { id: 'refused', ok: false, error: { code: 'UNAVAILABLE', message: 'Unavailable' } }
}
if (typeof params !== 'object' || params === null || !('sessionId' in params)) {
throw new Error('missing session')
}
const result = await host.request(String(params.sessionId), MOBILE_RUNTIME_CLIENT_CAPABILITIES)
await responseGate
return ok(result)
})
const client: RpcClient = {
sendRequest: send,
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
return {
client,
statuses,
calls: () => send.mock.calls.filter(([m]) => m === 'agentSession.commands').length,
statusCalls: () => send.mock.calls.filter(([m]) => m === 'status.get').length,
failStatus: (failure: 'refusal' | 'timeout' = 'timeout') => {
statusFailure = failure
},
allowStatus: () => {
statusFailure = undefined
},
failCommands: (fail: boolean) => {
rejectCommands = fail
},
holdResponse: (gate?: Promise<void>) => {
responseGate = gate
}
}
}
async function mounted(initial: ReturnType<typeof peer>, sessionId: string, connected = true) {
let props: {
peer: ReturnType<typeof peer>
sessionId: string
hostId: string
connState: ConnectionState
} = {
peer: initial,
sessionId,
hostId: 'host',
connState: connected ? 'connected' : 'reconnecting'
}
const base = {
initialCreateWarning: '',
sessionTabs: [],
sessionTabsRef: { current: [] },
activeSessionTabId: null,
activeSessionTabIdRef: { current: null },
activeSessionTab: null,
markdownDocs: new Map(),
markdownDocsRef: { current: new Map() },
createWarningState: { source: '', visible: '' },
setCreateWarningState: vi.fn(),
setToastMessage: vi.fn(),
toastOpacityRef: { current: new Animated.Value(0) },
toastHideTimerRef: { current: null },
toastSeqRef: { current: 0 },
clientRef: { current: null },
connStateRef: { current: 'connected' },
activeSessionTabTypeRef: { current: null },
delayedActionTimersRef: { current: new Set<ReturnType<typeof setTimeout>>() }
} satisfies Partial<Parameters<typeof useMobileSessionFeedbackCapabilities>[0]>
let current:
| {
support: ReturnType<typeof useMobileSessionFeedbackCapabilities>['agentSessionHostSupport']
menu: ReturnType<typeof useMobileNativeChatComposerMenu>
commands: ReturnType<typeof useMobileNativeChatSessionLane>['sessionCommands']['commands']
}
| undefined
function Probe() {
const input = { ...base, client: props.peer.client, connState: props.connState }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The checked subset supplies every feedback read; the unknown bridge omits unused composed controller fields, as in the toast-owner fixture.
const feedbackScope = input as unknown as Parameters<
typeof useMobileSessionFeedbackCapabilities
>[0]
const feedback = useMobileSessionFeedbackCapabilities(feedbackScope)
const reconciliation = {
...input,
...feedback,
worktreeId: 'menu-folder',
setShowQuickCommands: vi.fn(),
terminalGestureInputQueuesRef: { current: new Map() },
terminalGestureInputInFlightRef: { current: new Set<string>() },
registerTerminalInventoryRecoveryAction: () => () => {}
} satisfies Partial<Parameters<typeof useMobileSessionTabReconciliation>[0]>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The checked subset supplies capability/reset effects; the unknown bridge omits callbacks used only by stubbed terminal reconciliation.
const reconciliationScope = reconciliation as unknown as Parameters<
typeof useMobileSessionTabReconciliation
>[0]
useMobileSessionTabReconciliation(reconciliationScope)
const lane = useMobileNativeChatSessionLane({
client: props.peer.client,
hostId: props.hostId,
structured: true,
resolution: resolveMobileNativeChat({
type: 'agent-session',
agent: 'claude',
sessionId: props.sessionId
}),
sourceIdentity: props.hostId,
hostSupport: feedback.agentSessionHostSupport,
appendComposerTextRef: { current: () => false },
enabled: true,
connState: props.connState,
onSendError: () => {}
})
const structured = useMobileNativeChatStructuredComposer({
structured: true,
sessionCommands: lane.sessionCommands,
acceptsFiles: false,
conversationCommands: [],
files: { files: [], attachFiles: async () => {}, removeFiles: () => {} }
})
const [value, setValue] = useState('')
const menu = useMobileNativeChatComposerMenu({
value,
onChangeText: setValue,
agent: 'claude',
structuredCatalog: structured?.catalog,
onSlashMenuOpen: structured?.onSlashMenuOpen,
onSlashMenuDemand: structured?.onSlashMenuDemand,
filePaths: [],
focusField: () => {}
})
current = {
support: feedback.agentSessionHostSupport,
menu,
commands: lane.sessionCommands.commands
}
return null
}
let tree: ReactTestRenderer
await act(async () => {
tree = create(createElement(Probe))
})
const read = () => {
if (!current) {
throw new Error('unmounted probe')
}
return current
}
return {
read,
update: async (next: Partial<typeof props> = {}) => {
props = { ...props, ...next }
await act(async () => tree.update(createElement(Probe)))
},
draft: async (text: string) => {
await act(async () => {
read().menu.onFieldChange(text)
read().menu.onSelectionChange({ start: text.length, end: text.length })
})
},
dispose: async () => {
await act(async () => tree.unmount())
}
}
}
const fast = (view: Awaited<ReturnType<typeof mounted>>) => {
expect(view.read().commands?.some((row) => row.name === 'fast')).toBe(true)
expect(view.read().menu.rows.some((row) => row.kind !== 'file' && row.token === '/fast')).toBe(
true
)
}
const tick = async (ms = 0) => {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms)
})
}
it.each([false, true])(
'keeps the held-status eager boundary across retained reconnect=%s, then hands closed/open demand to support',
async (reconnect) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const host = await createMobileCommandMenuHostFixture('claude', true)
const source = peer(host),
view = await mounted(source, host.sessionId, !reconnect)
try {
if (reconnect) {
await view.update({ connState: 'connected' })
}
expect(source.calls()).toBe(1)
expect(host.pending(host.sessionId)).toBe(true)
await tick()
expect(host.preparations()).toBe(1)
expect(host.starts()).toBe(0)
await act(async () => {
source.statuses[0]!.resolve(supported())
})
expect(view.read().support?.commandsOnRequest).toBe(true)
host.releasePreparation()
await act(async () => {
await vi.waitFor(() => expect(host.pending(host.sessionId)).toBe(false))
})
expect(host.starts()).toBe(1)
expect(view.read().commands).toBeUndefined()
for (let i = 0; i < 3; i++) {
await view.update()
}
expect(source.calls()).toBe(1)
await view.draft('/')
await tick()
fast(view)
expect(source.calls()).toBe(2)
expect(host.starts()).toBe(1)
await view.draft('closed')
await view.update({ connState: 'reconnecting' })
expect(view.read().support).toBeNull()
await view.update({ connState: 'connected' })
await tick()
expect(source.calls()).toBe(3)
expect(host.starts()).toBe(1)
await act(async () => {
source.statuses.at(-1)!.resolve(supported())
})
await tick(60_000)
expect(source.calls()).toBe(3)
} finally {
await view.dispose()
await host.dispose()
vi.useRealTimers()
}
}
)
it.each(['false', 'malformed', 'refusal', 'timeout'] as const)(
'opens and settles a queued real menu refresh independently of status %s',
async (outcome) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const host = await createMobileCommandMenuHostFixture('claude', true)
const source = peer(host),
view = await mounted(source, host.sessionId)
try {
const calls = source.calls()
expect(calls).toBe(1)
if (outcome === 'timeout' || outcome === 'refusal') {
source.failStatus(outcome)
}
await act(async () => {
const first = source.statuses[0]!
if (outcome === 'false') {
first.resolve(ok({ capabilities: [] }))
} else if (outcome === 'malformed') {
first.resolve(ok({ capabilities: 42 }))
} else if (outcome === 'refusal') {
first.resolve({
id: 'refused',
ok: false,
error: { code: 'FORBIDDEN', message: 'Unavailable' }
})
} else {
first.reject(new Error('status timed out'))
}
})
if (outcome === 'false' || outcome === 'malformed') {
expect(view.read().support?.commandsOnRequest).toBe(false)
} else {
expect(view.read().support).toBeNull()
}
await tick(1_000)
await tick(2_000)
expect(source.calls()).toBe(calls)
if (outcome === 'timeout' || outcome === 'refusal') {
expect(source.statusCalls()).toBe(3)
}
await view.draft('/')
expect(source.calls()).toBe(calls)
host.releasePreparation()
await act(async () => {
await vi.waitFor(() => expect(host.pending(host.sessionId)).toBe(false))
})
expect(host.starts()).toBe(1)
expect(source.calls()).toBe(calls + 1)
fast(view)
source.failCommands(true)
await view.draft('closed')
await view.draft('/')
expect(source.calls()).toBe(calls + 2)
fast(view)
const settled = source.calls()
await tick(60_000)
expect(source.calls()).toBe(settled)
} finally {
await view.dispose()
await host.dispose()
vi.useRealTimers()
}
}
)
it.each(['open-true', 'open-false', 'close-before-true'] as const)(
'drains compatibility work and excludes late peer/session results: %s',
async (schedule) => {
const support = schedule !== 'open-false'
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const host = await createMobileCommandMenuHostFixture('claude', true, 2)
const oldPeer = peer(host),
view = await mounted(oldPeer, host.sessionId)
try {
await view.draft('/')
if (schedule === 'close-before-true') {
await view.draft('closed')
}
await act(async () => {
oldPeer.statuses[0]!.resolve(support ? supported() : ok({ capabilities: [] }))
})
const handoff = oldPeer.calls()
expect(handoff).toBe(schedule === 'open-true' ? 2 : 1)
await view.draft('closed')
const replacement = peer(host)
const replacementReply = Promise.withResolvers<void>()
replacement.holdResponse(replacementReply.promise)
await view.update({ peer: replacement, hostId: 'other', sessionId: host.sessionIds[1]! })
expect(view.read().support).toBeNull()
expect(replacement.calls()).toBe(1)
await view.draft('/')
const retiredStatus = replacement.statuses[0]!
await view.update({ connState: 'reconnecting' })
await view.update({ connState: 'connected' })
await act(async () => {
retiredStatus.resolve(supported())
})
expect(view.read().support).toBeNull()
await act(async () => {
replacement.statuses.at(-1)!.resolve(supported())
})
await view.draft('closed')
host.releasePreparation()
await act(async () => {
await vi.waitFor(() => {
for (const id of host.sessionIds) {
expect(host.pending(id)).toBe(false)
}
})
})
expect(host.starts()).toBe(2)
expect(oldPeer.calls()).toBe(handoff + 1)
expect(view.read().commands).toBeUndefined()
await act(async () => {
replacementReply.resolve()
})
expect(view.read().commands).toBeUndefined()
const drained = replacement.calls()
await tick(60_000)
expect(replacement.calls()).toBe(drained)
await view.draft('/')
await tick()
fast(view)
await view.update({ sessionId: host.sessionId })
await tick()
fast(view)
} finally {
await view.dispose()
await host.dispose()
vi.useRealTimers()
}
}
)
it.each([3, 30])(
'counts the permitted held-status wave at %s distinct contexts without status amplification',
async (count) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const host = await createMobileCommandMenuHostFixture('claude', true, count)
const sources = host.sessionIds.map(() => peer(host))
const views: Awaited<ReturnType<typeof mounted>>[] = []
try {
for (const [i, source] of sources.entries()) {
views.push(await mounted(source, host.sessionIds[i]!))
}
expect(sources.reduce((n, source) => n + source.calls(), 0)).toBe(count)
for (const id of host.sessionIds) {
expect(host.pending(id)).toBe(true)
}
await tick()
expect(host.preparations()).toBe(count)
expect(host.starts()).toBe(0)
for (const source of sources) {
source.failStatus()
await act(async () => {
source.statuses[0]!.reject(new Error('status timed out'))
})
}
await tick(1_000)
await tick(2_000)
for (const source of sources) {
expect(source.statusCalls()).toBe(3)
expect(source.calls()).toBe(1)
}
// A real successful retry publishes support; it does not originate another closed-menu read.
for (const source of sources) {
source.allowStatus()
}
await tick(4_000)
for (const source of sources) {
await act(async () => {
source.statuses.at(-1)!.resolve(supported())
})
}
for (const view of views) {
expect(view.read().support?.commandsOnRequest).toBe(true)
}
host.releasePreparation()
await act(async () => {
await vi.waitFor(() => {
for (const id of host.sessionIds) {
expect(host.pending(id)).toBe(false)
}
})
})
expect(host.starts()).toBe(count)
for (const view of views) {
await view.update()
}
for (const source of sources) {
expect(source.calls()).toBe(1)
}
} finally {
for (const view of views) {
await view.dispose()
}
await host.dispose()
vi.useRealTimers()
}
}
)
@@ -54,7 +54,8 @@ export function useMobileNativeChatSessionLane({
client,
hostId,
sessionId: structured ? sessionId : null,
enabled: connState === 'connected'
commandsOnRequest: hostSupport?.commandsOnRequest === true,
enabled: enabled && connState === 'connected'
})
const bridgeSession = useMobileNativeChatSession({
client,
@@ -76,6 +77,7 @@ export function useMobileNativeChatSessionLane({
connected: connState === 'connected',
agent: structured ? agent : null,
sessionCommands: sessionCommands.commands,
onCommandsChanged: sessionCommands.invalidate,
onSendError,
onQueuedEditNotice,
...(onActionResolved ? { onActionResolved } : {})
@@ -228,8 +228,7 @@ describe('mobile structured send actions', () => {
})
expect(calls()).toHaveLength(1)
expect(onSendError).toHaveBeenCalledWith('Use /compact without arguments.', undefined)
expect(kinds).toBeGreaterThanOrEqual(count)
expect(names).toBeGreaterThanOrEqual(count)
expect({ kinds, names }).toEqual({ kinds: 0, names: 0 })
}
)
@@ -60,7 +60,8 @@ export function useMobileStructuredAgentSession(
enabled,
onActionResolved,
onSendError,
hostSupport
hostSupport,
onCommandsChanged
} = args
// Gate queueing by capability while accepting published cards from every host.
const queueCapable = hostSupport?.queuedMessages === true
@@ -73,7 +74,7 @@ export function useMobileStructuredAgentSession(
const commandPendingRef = useRef(false)
// Legacy hosts must join repeated Stop while the first turn cancel remains pending.
const inFlightStopsRef = useRef(new Map<string, Promise<boolean>>())
const stateArgs = { client, sessionId, sessionKey, enabled, connected }
const stateArgs = { client, sessionId, sessionKey, enabled, connected, onCommandsChanged }
const { state, stateRef, queuedMessages, queuePause, olderPages, loadEarlier } =
useMobileStructuredAgentState(stateArgs)
@@ -29,6 +29,7 @@ import { useMobileOlderPageLoader, type OlderPagePhase } from './use-mobile-olde
const MAX_RETAINED_SESSION_STATES = 32
export function useMobileStructuredAgentState(args: {
onCommandsChanged?: () => void
client: RpcClient | null
sessionId: string | null
sessionKey: string | null
@@ -45,6 +46,10 @@ export function useMobileStructuredAgentState(args: {
loadEarlier: () => void
} {
const { client, connected, enabled, sessionId, sessionKey } = args
const commandsChanged = useRef(args.onCommandsChanged)
useLayoutEffect(() => {
commandsChanged.current = args.onCommandsChanged
}, [args.onCommandsChanged])
// Bound synchronous per-session retention so offline tab switches cannot grow memory indefinitely.
const [sessionStates, setSessionStates] = useState<Map<string, StructuredAgentSessionState>>(
() => new Map()
@@ -104,6 +109,9 @@ export function useMobileStructuredAgentState(args: {
applyRequests: apply,
applyEvent: (event, options) => {
apply({ type: 'event', event, ...options })
if (event.type !== 'end' && event.commandsChanged) {
commandsChanged.current?.()
}
},
applyError: (message, refusal) => {
if (stateRef.current.epoch !== null && refusal === undefined) {
@@ -3,7 +3,6 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredAgentId } from '../../../src/shared/agent-session-provider-handle'
import type { MobileNativeChatSendOutcome } from './native-chat/mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './native-chat/mobile-native-chat-pending-echo'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import {
@@ -23,7 +22,7 @@ const ORIGIN: MobileNativeChatSendOrigin = {
describe('useMobileStructuredNativeChatSendBridge', () => {
let renderer: ReactTestRenderer | null = null
let sendWithOutcome: (text: string) => Promise<MobileNativeChatSendOutcome>
let sendWithOutcome: ReturnType<typeof useMobileStructuredNativeChatSendBridge>['sendWithOutcome']
const captureSendOrigin = vi.fn(() => ORIGIN)
const clearDraftForSend = vi.fn()
const onSendError = vi.fn()
@@ -86,13 +85,13 @@ describe('useMobileStructuredNativeChatSendBridge', () => {
}
)
it('keeps host-command reconciliation for Codex', async () => {
it('keeps an unknown provider command with the Codex session', async () => {
sendStructured.mockResolvedValue('unknown')
mount('codex')
await expect(sendWithOutcome('/review')).resolves.toBe('unknown')
expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, '/review')
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it("reads another agent's sends by that agent's commands, never Codex's", async () => {
@@ -112,4 +111,27 @@ describe('useMobileStructuredNativeChatSendBridge', () => {
expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, 'hello')
})
it.each(['accepted', 'unknown', 'rejected'] as const)(
'carries typed provider commands and attachments through %s',
async (outcome) => {
sendStructured.mockResolvedValue(outcome)
mount('codex')
for (const text of ['/fast', '/permissions']) {
restoreRejectedDraft.mockClear()
await expect(sendWithOutcome(text, ['/shot.png'])).resolves.toBe(outcome)
expect(sendStructured).toHaveBeenLastCalledWith(
text,
['/shot.png'],
undefined,
undefined,
[]
)
if (outcome === 'rejected') {
expect(restoreRejectedDraft).toHaveBeenLastCalledWith(ORIGIN, text)
} else {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
}
}
}
)
})
@@ -1,9 +1,21 @@
import type { MobileCommandMenuHostFixtureModule } from '../../../src/shared/mobile-queued-edit-host.test-fixture-contract'
import type { AgentSessionCommandsResult } from '../../../src/shared/agent-session-wire'
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
import {
mobileComposerCatalog,
mobileComposerSkillTokens
} from './native-chat/composer/mobile-native-chat-composer-menu'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileStructuredSessionCommands } from './use-mobile-structured-session-commands'
const { createMobileCommandMenuHostFixture } =
await vi.importActual<MobileCommandMenuHostFixtureModule>(
'../../../src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture'
)
type Hook = ReturnType<typeof useMobileStructuredSessionCommands>
describe('useMobileStructuredSessionCommands', () => {
@@ -12,10 +24,29 @@ describe('useMobileStructuredSessionCommands', () => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
function Probe(props: { client: RpcClient; sessionId: string }) {
hook = useMobileStructuredSessionCommands({ ...props, hostId: 'host-1', enabled: true })
function Probe(props: {
client: RpcClient
sessionId: string
commandsOnRequest?: boolean
observe?: boolean
enabled?: boolean
}) {
hook = useMobileStructuredSessionCommands({
...props,
hostId: 'host-1',
enabled: props.enabled ?? true
})
useMobileStructuredAgentState({
client: props.client,
sessionId: props.sessionId,
sessionKey: props.sessionId,
enabled: props.observe ?? false,
connected: true,
onCommandsChanged: hook.invalidate
})
return null
}
@@ -106,4 +137,344 @@ describe('useMobileStructuredSessionCommands', () => {
await act(async () => answers.shift()!())
expect(hook!.commands?.map((entry) => entry.name)).toEqual(['review-pr', 'research'])
})
it.each(['pending', 'lost-hint', 'lost-reply'])(
'recovers a %s only while the same menu stays open',
async (mode) => {
vi.useFakeTimers()
let first = true
let finishFirstRead: () => void = () => {}
const firstRead = new Promise<void>((resolve) => {
finishFirstRead = resolve
})
const sendRequest = vi.fn(async () => {
if (first) {
first = false
await firstRead
if (mode === 'lost-reply') {
throw new Error('transport timeout')
}
return {
id: 'pending',
ok: true as const,
result: { commandsPending: true },
_meta: { runtimeId: 'runtime-1' }
}
}
return {
id: 'ready',
ok: true as const,
result: { commands: [{ name: 'fast', kind: 'command' }], commandsPending: false },
_meta: { runtimeId: 'runtime-1' }
}
})
const client: RpcClient = {
sendRequest,
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
try {
await act(async () => {
renderer = create(
createElement(Probe, { client, sessionId: 'a', commandsOnRequest: true })
)
})
expect(sendRequest).not.toHaveBeenCalled()
await act(async () => hook!.setMenuOpen(true))
if (mode !== 'lost-reply') {
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
finishFirstRead()
})
expect(sendRequest).toHaveBeenCalledTimes(1)
// Probe completion at 12s wakes this demanded target, without a second probe.
await act(async () => {
vi.advanceTimersByTime(2_000)
if (mode === 'pending') {
hook!.invalidate()
}
await vi.advanceTimersByTimeAsync(mode === 'pending' ? 0 : 8_000)
})
} else {
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
finishFirstRead()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
}
expect(hook!.commands?.map((row) => row.name)).toEqual(['fast'])
expect(sendRequest).toHaveBeenCalledTimes(2)
await act(async () => hook!.setMenuOpen(false))
await act(async () => {
hook!.invalidate()
await vi.advanceTimersByTimeAsync(330_000)
})
expect(sendRequest).toHaveBeenCalledTimes(2)
} finally {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
}
}
)
it('keeps unknown, pending, defined and empty distinct through the real phone picker and live callback', async () => {
vi.useFakeTimers()
let response: AgentSessionCommandsResult = { commandsPending: true }
let reject = false,
held: Promise<void> | undefined
let frame: (value: unknown) => void = () => {}
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (method) => {
if (method === 'agentSession.commands') {
const result = response
if (held) {
await held
}
if (reject) {
throw new Error('transport unavailable')
}
return { id: 'commands', ok: true, result }
}
return { id: 'hold', ok: true, result: {} }
})
const client: RpcClient = {
sendRequest,
subscribe: (_method, _params, onData) => {
frame = onData
return () => {}
},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
const tokens = () =>
mobileComposerSkillTokens(
mobileComposerCatalog({
agent: 'claude',
structured: { reported: hook!.commands, conversationCommands: [] },
discovery: {
status: 'ready',
skills: [
{
id: 'review-pr',
name: 'review-pr',
description: null,
providers: ['claude'],
sourceKind: 'repo',
sourceLabel: 'Repo',
rootPath: '/skills',
directoryPath: '/skills/review-pr',
skillFilePath: '/skills/review-pr/SKILL.md',
installed: true,
updatedAt: null
}
]
}
})
)
const event = (hint = false) =>
frame({
type: 'batch',
sessionId: 'a',
commands: null,
...(hint ? { commandsChanged: true } : {}),
batch: {
cursor: { epoch: 'e', sequence: 0 },
items: [],
removedItemIds: [],
submissions: []
}
})
await act(async () => {
renderer = create(
createElement(Probe, { client, sessionId: 'a', commandsOnRequest: true, observe: true })
)
})
await act(async () => {
event()
await vi.advanceTimersByTimeAsync(50)
})
await act(async () => hook!.setMenuOpen(true))
expect(hook!.commands).toBeUndefined()
expect(tokens().has('/review-pr')).toBe(true)
response = { commands: [{ name: 'review-pr', kind: 'skill' }], commandsPending: true }
for (const hintAt of [0, 1, 2]) {
await act(async () => {
for (let index = 0; index < 3; index++) {
event(index === hintAt)
}
await vi.advanceTimersByTimeAsync(50)
})
expect(hook!.commands).toEqual(response.commands)
}
response = { commands: [], commandsPending: true }
await act(async () => {
event(true)
await vi.advanceTimersByTimeAsync(50)
})
expect(tokens().has('/review-pr')).toBe(false)
response = { commands: [{ name: 'fast', kind: 'command' }], commandsPending: false }
await act(async () => {
event(true)
await vi.advanceTimersByTimeAsync(50)
})
expect(hook!.commands).toEqual(response.commands)
response = { commandsPending: false }
await act(async () => {
event(true)
await vi.advanceTimersByTimeAsync(50)
})
expect(hook!.commands).toBeUndefined()
expect(tokens().has('/review-pr')).toBe(true)
response = { commands: [], commandsPending: false }
await act(async () => {
event(true)
await vi.advanceTimersByTimeAsync(50)
})
expect(tokens().has('/review-pr')).toBe(false)
reject = true
await act(async () => {
event(true)
await vi.advanceTimersByTimeAsync(50)
})
expect(hook!.commands).toEqual([])
reject = false
const late = Promise.withResolvers<void>()
held = late.promise
response = { commands: [{ name: 'obsolete', kind: 'skill' }], commandsPending: false }
await act(async () => {
event(true)
await vi.advanceTimersByTimeAsync(50)
})
await act(async () => {
hook!.setMenuOpen(false)
renderer!.update(
createElement(Probe, {
client,
sessionId: 'b',
commandsOnRequest: true,
observe: true,
enabled: false
})
)
})
await act(async () => {
late.resolve()
await vi.advanceTimersByTimeAsync(600_000)
})
expect(hook!.commands).toBeUndefined()
expect(tokens().has('/review-pr')).toBe(true)
})
it.each([
['claude', false],
['omp', false],
['claude', true]
] as const)(
'recovers the parked %s phone menu (pending preparation=%s) through actual host retirement and dispatch',
async (provider, pending) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const host = await createMobileCommandMenuHostFixture(provider, pending)
let online = pending,
stop = () => {}
const sendRequest = vi.fn<RpcClient['sendRequest']>(async (method) => {
if (method !== 'agentSession.commands') {
return { id: 'hold', ok: true, result: {} }
}
if (!online) {
throw new Error('offline')
}
return { id: 'commands', ok: true, result: await host.read() }
})
const client: RpcClient = {
sendRequest,
subscribe: (_method, _params, onData) => {
void host.subscribe(onData).then((value) => {
stop = value
})
return () => stop()
},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
try {
await act(async () => {
renderer = create(
createElement(Probe, {
client,
sessionId: host.sessionId,
commandsOnRequest: true,
observe: true
})
)
})
await act(async () => hook!.setMenuOpen(true))
await act(async () => {
await vi.advanceTimersByTimeAsync(330_000)
})
const parked = sendRequest.mock.calls.filter(
([method]) => method === 'agentSession.commands'
).length
if (pending) {
expect(parked).toBeGreaterThan(3)
} else {
expect(parked).toBe(3)
}
await act(async () => {
await vi.advanceTimersByTimeAsync(100_000)
})
expect(
sendRequest.mock.calls.filter(([method]) => method === 'agentSession.commands')
).toHaveLength(parked)
expect(host.starts()).toBe(0)
online = true
for (const scoped of [false, true]) {
const starts = host.starts()
await act(async () => {
await host.retire(scoped)
await vi.advanceTimersByTimeAsync(50)
})
await vi.waitFor(
async () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(50)
})
expect(hook!.commands?.some((row) => row.name === 'fast')).toBe(true)
expect(host.starts()).toBe(starts + 1)
},
{ interval: 0 }
)
}
expect(host.starts()).toBe(2)
await act(async () => hook!.setMenuOpen(false))
const before = sendRequest.mock.calls.length
await act(async () => {
await host.retire(false)
await vi.advanceTimersByTimeAsync(600_000)
})
expect(sendRequest).toHaveBeenCalledTimes(before)
} finally {
act(() => renderer?.unmount())
renderer = null
stop()
await host.dispose()
vi.useRealTimers()
}
}
)
})
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from 'react'
import { startAgentSessionCommandMenuQuery } from '../../../src/shared/agent-session-command-menu-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { z } from 'zod'
import type { AgentSessionSlashCommand } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
type Reported = { sessionId: string; commands: readonly AgentSessionSlashCommand[] }
type Reported = {
client: RpcClient
hostId: string
sessionId: string
commands: readonly AgentSessionSlashCommand[]
}
const sessionCommandsSchema = z.looseObject({
commandsPending: z.boolean().optional(),
commands: z
.array(
z.looseObject({
@@ -39,13 +46,21 @@ export function useMobileStructuredSessionCommands(args: {
hostId: string | null
sessionId: string | null
enabled: boolean
}): { commands: readonly AgentSessionSlashCommand[] | undefined; refresh: () => void } {
const { client, hostId, sessionId, enabled } = args
commandsOnRequest?: boolean
}): {
commands: readonly AgentSessionSlashCommand[] | undefined
refresh: () => void
setMenuOpen: (open: boolean) => void
invalidate: () => void
} {
const { client, hostId, sessionId, enabled, commandsOnRequest = false } = args
const [menuOpen, setMenuOpen] = useState(false)
const query = useRef<ReturnType<typeof startAgentSessionCommandMenuQuery> | null>(null)
const [reported, setReported] = useState<Reported | null>(null)
const [readCount, setReadCount] = useState(0)
useEffect(() => {
if (!client || !hostId || !sessionId || !enabled) {
if (!client || !hostId || !sessionId || !enabled || commandsOnRequest) {
return
}
let stale = false
@@ -54,19 +69,70 @@ export function useMobileStructuredSessionCommands(args: {
.then((reply) => {
const read = sessionCommandsRead.interpret(reply)
if (!stale && read.accepted && read.value.commands) {
setReported({ sessionId, commands: read.value.commands })
setReported({ client, hostId, sessionId, commands: read.value.commands })
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [client, enabled, hostId, readCount, sessionId])
}, [client, commandsOnRequest, enabled, hostId, readCount, sessionId])
const refresh = useCallback(() => setReadCount((count) => count + 1), [])
useEffect(() => {
if (!commandsOnRequest || !menuOpen || !enabled || !client || !hostId || !sessionId) {
return
}
const owned = startAgentSessionCommandMenuQuery(
async () => {
const reply = await sessionCommandsRead.request(
client,
{ sessionId },
{
timeoutMs: 30_000,
failWhenDisconnected: true,
budgetSpansConnect: true
}
)
const result = sessionCommandsRead.interpret(reply)
if (!result.accepted) {
throw new Error('Commands unavailable')
}
return result.value
},
(result) => {
if (result.commands !== undefined) {
setReported({ client, hostId, sessionId, commands: result.commands })
} else if (!result.commandsPending) {
setReported((previous) =>
previous?.client === client &&
previous.hostId === hostId &&
previous.sessionId === sessionId
? null
: previous
)
}
}
)
query.current = owned
return () => {
query.current = null
owned.dispose()
}
}, [client, commandsOnRequest, enabled, hostId, menuOpen, sessionId])
const invalidate = useCallback(() => query.current?.invalidate(), [])
const refresh = useCallback(() => {
if (!commandsOnRequest) {
setReadCount((count) => count + 1)
}
}, [commandsOnRequest])
return {
commands: reported?.sessionId === sessionId ? reported.commands : undefined,
refresh
commands:
reported?.client === client && reported.hostId === hostId && reported.sessionId === sessionId
? reported.commands
: undefined,
refresh,
setMenuOpen,
invalidate
}
}
@@ -1,8 +1,14 @@
import { AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities'
import { describe, expect, it } from 'vitest'
import { parseRuntimeClientCapabilities } from '../../../src/main/runtime/rpc/runtime-client-capabilities'
import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from './mobile-runtime-client-capabilities'
describe('mobile runtime client capabilities', () => {
it('promises menu-scoped command requests', () => {
expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toContain(
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY
)
})
it('stays inside the bounds the host parses, which fail closed to no capabilities at all', () => {
expect(parseRuntimeClientCapabilities(MOBILE_RUNTIME_CLIENT_CAPABILITIES)).toEqual(
MOBILE_RUNTIME_CLIENT_CAPABILITIES
@@ -1,5 +1,8 @@
import {
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY,
AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY
} from '../../../src/shared/structured-agent-session-surface-capabilities'
import { AGENT_SESSION_QUEUED_IMAGE_CARDS_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-runtime-capabilities'
import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from '../../../src/shared/structured-agent-session-surface-capabilities'
import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-current-requests-capability'
import {
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
@@ -22,6 +25,7 @@ import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runt
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities'
export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY,
AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY,
+30
View File
@@ -662,3 +662,33 @@ it.each([10, 100])(
}
}
)
it('takes an OMP context out of the legacy queue for a capable menu while joining admitted work', async () => {
const gate = deferred<void>()
const { catalog, children } = await rig((child) => {
const initialize = child.initialize.bind(child)
vi.spyOn(child, 'initialize').mockImplementation(async () => {
await gate.promise
return initialize()
})
})
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
try {
catalog.read(ompRecord('legacy-one'))
catalog.read(ompRecord('legacy-two'))
await waitFor(() => expect(children).toHaveLength(2))
catalog.read(ompRecord('queued'))
const explicit = catalog.settled(ompRecord('queued'), 1_000, true)
const joined = catalog.settled(ompRecord('legacy-one'), 1_000, true)
await waitFor(() => expect(children).toHaveLength(3))
expect(children.filter((child) => !child.exited)).toHaveLength(3)
gate.resolve()
expect(await explicit).toBeDefined()
expect(await joined).toBeDefined()
await waitFor(() => expect(children.every((child) => child.exited)).toBe(true))
expect(children).toHaveLength(3)
} finally {
gate.resolve()
catalog.dispose()
}
})
+17 -6
View File
@@ -66,7 +66,7 @@ export class AcpAtRestCommandCatalog extends AtRestCommandCatalog {
record.location,
deps.isWindowsProcessStartTimeAvailable
),
scan: (record, cwd, isCurrent, _generation, contextSignal) => {
scan: (record, cwd, isCurrent, _generation, contextSignal, _admitted, demand) => {
const signal = AbortSignal.any([lifetime.signal, contextSignal])
let answer: (value: AtRestCommandScan) => void = () => {}
let fail: (reason: unknown) => void = () => {}
@@ -82,20 +82,31 @@ export class AcpAtRestCommandCatalog extends AtRestCommandCatalog {
}
const run = async () => {
check()
if (admitted >= 34) {
if (!demand.independent && admitted >= 34) {
throw new Error('ACP command discovery busy')
}
admitted++
let countedLegacy = !demand.independent
if (countedLegacy) {
admitted++
}
let release: () => void
try {
release = await admission.acquire(0, signal)
release = await demand.acquire(admission, signal)
if (countedLegacy && demand.independent) {
admitted--
countedLegacy = false
}
} catch (error) {
admitted--
if (countedLegacy) {
admitted--
}
throw error
}
const releasePermit = release
release = () => {
admitted--
if (countedLegacy) {
admitted--
}
releasePermit()
}
let folder: Awaited<ReturnType<typeof acpRunFolder>> | undefined
+7 -5
View File
@@ -1,3 +1,4 @@
import type { AtRestCommandDemand } from '../native-chat/agent-session-wire/at-rest-command-demand'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { PrioritySemaphore } from '../../shared/priority-semaphore'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
@@ -20,7 +21,7 @@ import {
import { join } from 'node:path'
export const CLAUDE_AT_REST_COMMANDS_TTL_MS = AT_REST_COMMANDS_TTL_MS
// Each probe starts Claude; limit concurrent workspace discovery processes.
// Older clients eagerly discover commands; retain their bounded admission.
const MAX_PROBES_RUNNING = 2
export type ClaudeAtRestCommandsDeps = {
@@ -44,8 +45,8 @@ export class ClaudeAtRestCommandCatalog extends AtRestCommandCatalog {
isLegacyAgentSessionAccountHome(record.accountHome),
resolveWorkspacePath: deps.resolveWorkspacePath,
...(deps.resolveAccountHome ? { resolveAccountHome: deps.resolveAccountHome } : {}),
scan: (record, cwd, isCurrent, _generation, signal, admitted) =>
scanClaudeAtRest(deps, probes, record, cwd, isCurrent, signal, admitted),
scan: (record, cwd, isCurrent, _generation, signal, admitted, demand) =>
scanClaudeAtRest(deps, probes, record, cwd, isCurrent, signal, admitted, demand),
...(deps.now ? { now: deps.now } : {})
})
}
@@ -58,11 +59,12 @@ async function scanClaudeAtRest(
cwd: string,
isCurrent: () => boolean,
signal: AbortSignal,
admitted: () => void
admitted: () => void,
demand: AtRestCommandDemand
): Promise<AtRestCommandScan> {
const account = requireLegacyAgentSessionAccountHome(record.accountHome).path
if (deps.probe) {
const release = await probes.acquire(0, signal)
const release = await demand.acquire(probes, signal)
try {
if (!isCurrent()) {
return { commands: undefined, fromAgent: false }
@@ -1,3 +1,4 @@
import { PrioritySemaphore } from '../../shared/priority-semaphore'
import { describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
@@ -169,3 +170,57 @@ it("lists a resting chat's `/` menu from the account it would start on, followin
release()
}
})
it('withdraws a queued legacy acquisition for modern demand without duplicating its attempt', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const acquiring = vi.spyOn(PrioritySemaphore.prototype, 'acquire')
const starts: string[] = []
const held = Promise.withResolvers<void>()
let active = 0,
peak = 0
const catalog = new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => process.cwd(),
probe: async ({ cwd, signal }) => {
starts.push(cwd)
peak = Math.max(peak, ++active)
try {
await new Promise<void>((resolve) => {
held.promise.then(resolve)
signal?.addEventListener('abort', () => held.promise.then(resolve), { once: true })
})
return [{ name: 'fast', kind: 'command' }]
} finally {
active--
}
}
})
const record = agentSessionRecordFixture()
const chat = (workspaceId: string) => ({
...record,
location: { ...record.location, workspaceId }
})
try {
for (const id of ['one', 'two']) {
catalog.read(chat(id))
}
await vi.waitFor(() => expect(starts).toHaveLength(2))
catalog.read(chat('queued'))
await vi.waitFor(() => expect(acquiring).toHaveBeenCalledTimes(3))
const menu = catalog.settled(chat('queued'), 10_000, true)
const joined = catalog.settled(chat('queued'), 10_000, true)
await vi.waitFor(() => expect(starts).toHaveLength(3))
expect(peak).toBe(3)
catalog.dispose()
// Logical retirement does not make held physical work disappear.
expect(active).toBe(3)
held.resolve()
await Promise.all([menu, joined])
await vi.waitFor(() => expect(active).toBe(0))
expect(starts).toHaveLength(3)
} finally {
held.resolve()
catalog.dispose()
acquiring.mockRestore()
vi.useRealTimers()
}
})
@@ -3,6 +3,7 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-w
import type { SubscriberFieldState } from './agent-session-subscriber-frame-fields'
export type AgentSessionSubscribeInput = {
commandsOnRequest?: boolean
id: string
sessionId: string
emit: (event: AgentSessionSubscribeEvent) => void
@@ -2,6 +2,7 @@ import type { AgentSessionPublishedWork } from './agent-session-published-work'
import type { Subscriber } from './structured-agent-session-subscribers'
// Deduplicate against last sent values only; withheld fields must not suppress final replacements.
// Deduplicate against last sent values only; withheld fields must not suppress final replacements.
import type {
AgentSessionBackgroundTaskState,
AgentSessionSlashCommand,
@@ -11,6 +12,7 @@ import type { QueuePublication } from './structured-agent-session-queued-publica
import { boundedFrames } from './agent-session-bounded-frames'
export type SubscriberFieldState = {
commandsOnRequest?: boolean
sessionId: string
boundedFrames: boolean
projectQueuePublication?: (publication: QueuePublication) => QueuePublication
@@ -21,7 +23,10 @@ export type SubscriberFieldState = {
}
export type SubscriberFieldHooks = {
readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
readCommands?: (
sessionId: string,
commandsOnRequest?: boolean
) => AgentSessionSlashCommand[] | undefined
readQueuePublication?: (sessionId: string) => QueuePublication | undefined
/** Read expensive child-derived rosters only when a frame owes them. */
readBackgroundTasks?: (sessionId: string) => AgentSessionBackgroundTaskState | null
@@ -30,7 +35,7 @@ export type SubscriberFieldHooks = {
export type SubscriberFrame = {
/** Bounded subscribers receive side lists after history. */
frames: AgentSessionSubscribeEvent[]
/** Phones read commands through agentSession.options instead. */
/** Last observed inventory also deduplicates bounded command-change hints. */
commands: AgentSessionSlashCommand[] | null | undefined
attachedQueued: boolean
queued: QueuePublication | undefined
@@ -48,10 +53,12 @@ export function buildSubscriberFrame(
event: AgentSessionSubscribeEvent,
withholdQueued: boolean
): SubscriberFrame {
const commands = subscriber.boundedFrames
? undefined
: (hooks.readCommands?.(subscriber.sessionId) ?? null)
const commands =
subscriber.boundedFrames && !subscriber.commandsOnRequest
? undefined
: (hooks.readCommands?.(subscriber.sessionId, subscriber.commandsOnRequest) ?? null)
const includeCommands =
!subscriber.boundedFrames &&
hooks.readCommands !== undefined &&
event.type !== 'end' &&
(event.type !== 'batch' || commands !== subscriber.commands)
@@ -66,7 +73,14 @@ export function buildSubscriberFrame(
: undefined
return {
frames: subscriber.boundedFrames
? boundedFrames(event, { queued: sideQueued, backgroundTasks })
? boundedFrames(event, { queued: sideQueued, backgroundTasks }).map((frame, index) =>
index === 0 &&
frame.type !== 'end' &&
subscriber.commandsOnRequest &&
(event.type !== 'batch' || event.commandsChanged || commands !== subscriber.commands)
? { ...frame, commandsChanged: true }
: frame
)
: [
{
...event,
@@ -106,15 +120,16 @@ function subscriberBackgroundTasks(
: undefined
}
/** Phones read commands through agentSession.options, so never owe this field. */
/** Capable phones receive a small invalidation hint instead of the inventory. */
export function subscriberCommandsChanged(
hooks: SubscriberFieldHooks,
subscriber: SubscriberFieldState
): boolean {
return (
!subscriber.boundedFrames &&
(!subscriber.boundedFrames || subscriber.commandsOnRequest === true) &&
hooks.readCommands !== undefined &&
(hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands
(hooks.readCommands(subscriber.sessionId, subscriber.commandsOnRequest) ?? null) !==
subscriber.commands
)
}
@@ -1,3 +1,4 @@
import { AtRestCommandDemand } from './at-rest-command-demand'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionSlashCommand } from '../../../shared/agent-session-wire'
import {
@@ -5,7 +6,10 @@ import {
type AgentSessionAccountHome
} from '../../../shared/agent-session-account-home'
import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory'
import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands'
import type {
AtRestCommandChange,
StructuredAgentSessionAtRestCommands
} from './structured-agent-session-at-rest-commands'
import { atRestCommandContextReady } from './at-rest-command-context'
import { stat } from 'node:fs/promises'
import { isDefinitiveAbsence } from '../../../shared/definitive-filesystem-absence'
@@ -36,7 +40,8 @@ export type AtRestCommandSource = {
isCurrent: () => boolean,
generation: number,
signal: AbortSignal,
admitted: () => void
admitted: () => void,
demand: AtRestCommandDemand
) => Promise<AtRestCommandScan>
scanTimeoutMs?: number
maxFailedScans?: number | null
@@ -60,6 +65,7 @@ type Entry = {
scannedAt: number
failures: number
scanning: boolean
demand?: AtRestCommandDemand
attempt?: AbortController
lastReadAt: number
settled: Set<() => void>
@@ -68,7 +74,7 @@ type Entry = {
/** Cache commands from each resting chat’s launch folder; publish changed scan results. */
export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommands {
private readonly entries = new Map<string, Entry>()
private readonly listeners = new Set<() => void>()
private readonly listeners = new Set<(change?: AtRestCommandChange) => void>()
private generation = 0
private nextEviction = Number.NEGATIVE_INFINITY
private disposed = false
@@ -87,7 +93,13 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
}
queueMicrotask(() => {
if (!this.disposed) {
this.listeners.forEach((listener) => listener())
this.listeners.forEach((listener) =>
listener({
retired: (record) =>
this.source.accepts(record) &&
(workspaceId === undefined || record.location.workspaceId === workspaceId)
})
)
}
})
}
@@ -102,10 +114,24 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
read = (record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined =>
this.readEntry(record)?.commands
settled = async (record: AgentSessionRecord, budgetMs: number) => {
snapshot = (record: AgentSessionRecord) => {
const entry = this.source.accepts(record) ? this.entries.get(this.key(record)) : undefined
return {
commands: entry?.commands,
commandsPending: entry?.scanning ?? false,
retryableFailure:
entry !== undefined &&
!entry.scanning &&
entry.commands === undefined &&
entry.failures > 0 &&
this.canRetry(entry)
}
}
settled = async (record: AgentSessionRecord, budgetMs: number, independent = false) => {
const deadline = Date.now() + budgetMs
for (;;) {
const entry = this.readEntry(record)
const entry = this.readEntry(record, independent)
if (!entry) {
return undefined
}
@@ -130,18 +156,14 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
}
}
onChange = (listener: () => void): (() => void) => {
onChange = (listener: (change?: AtRestCommandChange) => void): (() => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
// Folder pins and accounts can expose different skills.
private readEntry(record: AgentSessionRecord): Entry | undefined {
if (this.disposed || !this.source.accepts(record)) {
return undefined
}
const now = this.now()
const key = JSON.stringify([
private key(record: AgentSessionRecord): string {
return JSON.stringify([
record.provider,
// A selected account changes through retireContext, not per chat.
this.source.resolveAccountHome
@@ -150,6 +172,14 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
record.location.workspaceId,
agentSessionPinnedLaunchDirectory(record)
])
}
private readEntry(record: AgentSessionRecord, independent = false): Entry | undefined {
if (this.disposed || !this.source.accepts(record)) {
return undefined
}
const now = this.now()
const key = this.key(record)
let entry = this.entries.get(key)
if (!entry) {
entry = {
@@ -167,7 +197,10 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
entry.lastReadAt = now
this.evictUnread(now)
if (!entry.scanning && now - entry.scannedAt >= AT_REST_COMMANDS_TTL_MS) {
this.scanInto(entry, record)
this.scanInto(entry, record, independent)
}
if (independent) {
entry.demand?.request()
}
return entry
}
@@ -185,7 +218,9 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
}
}
private scanInto(entry: Entry, record: AgentSessionRecord): void {
private scanInto(entry: Entry, record: AgentSessionRecord, independent: boolean): void {
const demand = new AtRestCommandDemand(independent)
entry.demand = demand
const attempt = new AbortController()
entry.attempt = attempt
const owns = (): boolean => !this.disposed && this.entries.get(entry.key) === entry
@@ -223,7 +258,7 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
return { commands: undefined, fromAgent: false }
}
const answer = await wait(
this.source.scan(chat, cwd, isCurrent, entry.generation, attempt.signal, admitted)
this.source.scan(chat, cwd, isCurrent, entry.generation, attempt.signal, admitted, demand)
)
attempt.signal.throwIfAborted()
return answer
@@ -253,11 +288,9 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
return
}
entry.failures += 1
const limit = this.source.maxFailedScans
entry.scannedAt =
limit === null || entry.failures < (limit ?? MAX_FAILED_SCANS)
? this.now() - AT_REST_COMMANDS_TTL_MS + FAILED_SCAN_RETRY_MS
: this.now()
entry.scannedAt = this.canRetry(entry)
? this.now() - AT_REST_COMMANDS_TTL_MS + FAILED_SCAN_RETRY_MS
: this.now()
console.warn(`[${this.source.label}] reading the at-rest \`/\` commands failed:`, error)
}
)
@@ -272,4 +305,9 @@ export class AtRestCommandCatalog implements StructuredAgentSessionAtRestCommand
private now(): number {
return this.source.now?.() ?? Date.now()
}
private canRetry(entry: Entry): boolean {
const limit = this.source.maxFailedScans
return limit === null || entry.failures < (limit ?? MAX_FAILED_SCANS)
}
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { PrioritySemaphore } from '../../../shared/priority-semaphore'
import { AtRestCommandDemand } from './at-rest-command-demand'
describe('legacy discovery admission takeover', () => {
it.each(['before-grant', 'after-grant'] as const)(
'withdraws the same acquisition %s and returns the permit once',
async (race) => {
const pool = new PrioritySemaphore(1)
const held = await pool.acquire(0)
const demand = new AtRestCommandDemand(false)
const signal = new AbortController().signal
const queued = demand.acquire(pool, signal)
if (race === 'after-grant') {
held()
}
demand.request()
const independentRelease = await queued
if (race === 'before-grant') {
held()
}
const next = await pool.acquire(0)
independentRelease()
let duplicatePermit = false
const blocked = pool.acquire(0).then((release) => {
duplicatePermit = true
return release
})
await Promise.resolve()
expect(duplicatePermit).toBe(false)
next()
;(await blocked)()
}
)
it('joins an admitted attempt and retains its legacy permit through cleanup', async () => {
const pool = new PrioritySemaphore(1)
const demand = new AtRestCommandDemand(false)
const release = await demand.acquire(pool, new AbortController().signal)
demand.request()
let admitted = false
const waiting = pool.acquire(0).then((done) => {
admitted = true
return done
})
await Promise.resolve()
expect(admitted).toBe(false)
release()
;(await waiting)()
})
})
@@ -0,0 +1,37 @@
import type { PrioritySemaphore } from '../../../shared/priority-semaphore'
/** A modern request can take over queued eager work, but joins work already admitted. */
export class AtRestCommandDemand {
private readonly listeners = new Set<() => void>()
constructor(public independent: boolean) {}
request(): void {
this.independent = true
this.listeners.forEach((listener) => listener())
}
async acquire(pool: PrioritySemaphore, signal: AbortSignal): Promise<() => void> {
if (this.independent) {
return () => {}
}
const queued = new AbortController()
const promote = () => queued.abort()
this.listeners.add(promote)
try {
const release = await pool.acquire(0, AbortSignal.any([signal, queued.signal]))
if (this.independent) {
release()
return () => {}
}
return release
} catch (error) {
if (this.independent && !signal.aborted) {
return () => {}
}
throw error
} finally {
this.listeners.delete(promote)
}
}
}
@@ -1,4 +1,7 @@
import type { MobileQueuedEditHostFixtureModule } from '../../../shared/mobile-queued-edit-host.test-fixture-contract'
import type {
MobileQueuedEditHostFixtureModule,
MobileCommandMenuHostFixtureModule
} from '../../../shared/mobile-queued-edit-host.test-fixture-contract'
import {
QueuedMessageEditHoldParams,
QueuedMessageUpdateParams
@@ -7,7 +10,11 @@ import {
createQueuedMessageTestRig,
QUEUED_RIG_CALLER
} from './structured-agent-session-queued-message-rig.test-fixture'
import { HOST_TEST_NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data'
import {
HOST_TEST_NOW,
HOST_TEST_SESSION,
hostTestOperationId
} from './structured-agent-session-host-test-data'
export { HOST_TEST_NOW as now }
@@ -54,3 +61,169 @@ export const createMobileQueuedEditHostFixture: MobileQueuedEditHostFixtureModul
dispose: rig.dispose
}
}
export const createMobileCommandMenuHostFixture: MobileCommandMenuHostFixtureModule['createMobileCommandMenuHostFixture'] =
async (provider, holdPreparation = false, population = 1) => {
const { mkdtemp, mkdir, rm } = await import('node:fs/promises')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const { ClaudeAtRestCommandCatalog } = await import('../../claude/claude-at-rest-commands')
const { commandDiscoveryRig, OMP, DISCOVERY_SESSION } =
await import('../../acp/acp-command-discovery.test-fixture')
const { acpStructuredAgentDefinition } =
await import('../../acp/acp-structured-agent-definitions')
const { CLAUDE_STRUCTURED_AGENT } =
await import('../../claude/claude-structured-agent-definition')
const { structuredAgentsDeclaringNothing } =
await import('./structured-agent-session-adapter-router-test-support')
const { StructuredAgentRegistry } = await import('./structured-agent-registry')
const { StructuredAgentSessionAdapterRouter } =
await import('./structured-agent-session-adapter-router')
const { StructuredAgentSessionHost } = await import('./structured-agent-session-host')
const { openTestAgentSessionRecordStore } =
await import('../../runtime/agent-session-record-store-test-harness')
const { openTestJournalHostDatabase, closeTestJournalHostDatabase } =
await import('../agent-session-journal/journal-host-database-test-support')
const { bindAtRestCommandContexts, retireAtRestCommandSettings, retireAtRestCommandContext } =
await import('./at-rest-command-context')
const { recordingStructuredAgentSessionLogger } =
await import('./structured-agent-session-logger-test-support')
const root = await mkdtemp(join(tmpdir(), 'orca-35069-phone-'))
const cwd = join(root, 'workspace')
await mkdir(cwd)
const { clientRequestsCommands } =
await import('../../runtime/rpc/methods/structured-agent-session-policy')
let starts = 0,
preparations = 0
const preparation = Promise.withResolvers<void>()
const resolveWorkspacePath = async () => {
preparations++
if (holdPreparation) {
await preparation.promise
}
return cwd
}
const omp =
provider === 'omp'
? await commandDiscoveryRig(
(child) => {
starts++
child.agent.on('session/new', (frame) => {
child.agent.notify('session/update', {
sessionId: DISCOVERY_SESSION,
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'fast', description: 'Fast', input: { hint: '' } }]
}
})
child.agent.reply(frame, { sessionId: DISCOVERY_SESSION })
})
},
{ resolveWorkspacePath }
)
: undefined
const catalog =
omp?.catalog ??
new ClaudeAtRestCommandCatalog({
resolveWorkspacePath,
probe: async () => {
starts++
return [{ name: 'fast', kind: 'command' }]
}
})
const agents = new StructuredAgentRegistry(
structuredAgentsDeclaringNothing([
provider === 'omp' ? acpStructuredAgentDefinition(OMP) : CLAUDE_STRUCTURED_AGENT
])
.registrations()
.map((registration) => ({
...registration,
adapter: { ...registration.adapter, atRestCommands: catalog }
}))
)
const store = await openTestAgentSessionRecordStore(root)
const sessionIds = Array.from({ length: population }, (_, index) =>
index === 0 ? 'phone-command-menu' : `phone-command-menu-${index}`
)
const sessionId = sessionIds[0]!
for (const [index, sessionId] of sessionIds.entries()) {
await store.reserveOwner({
sessionId,
provider,
accountHome: {
variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'PI_CODING_AGENT_DIR',
path: root
},
location: {
executionHostId: 'local',
workspaceId: index === 0 ? 'menu-folder' : `menu-folder-${index}`,
workspaceKind: 'folder',
wslDistro: null
},
expectedFence: null,
spawnToken: 'spawn',
claimKeyId: 'key',
handoffOperationId: null,
probe: { outcome: 'reservation-unused' },
operation: {
callerKey: 'phone',
operationId: hostTestOperationId(),
fingerprint: sessionId
},
now: HOST_TEST_NOW
})
}
const host = new StructuredAgentSessionHost({
agents,
store,
adapter: new StructuredAgentSessionAdapterRouter(agents, async () => {}),
journalDatabase: openTestJournalHostDatabase(root),
claimKeyId: 'key',
logger: recordingStructuredAgentSessionLogger().logger,
probeOwner: async () => ({ outcome: 'pid-absent' })
})
const unbind = bindAtRestCommandContexts([catalog])
return {
sessionId,
sessionIds,
preparations: () => preparations,
pending: (id) => catalog.snapshot(store.getRecord(id)!).commandsPending,
releasePreparation: () => {
holdPreparation = false
preparation.resolve()
},
request: (id, clientCapabilities) =>
host.readCommands(
id,
clientRequestsCommands({ clientCapabilities: [...clientCapabilities] })
),
starts: () => starts,
read: () => host.readCommands(sessionId, true),
subscribe: (emit) =>
host.subscribe({
id: 'phone',
sessionId,
commandsOnRequest: true,
boundedFrames: true,
emit
}),
retire: async (scoped) => {
holdPreparation = false
if (scoped) {
retireAtRestCommandContext('menu-folder')
} else {
retireAtRestCommandSettings(['agentCmdOverrides'])
}
await Promise.resolve()
},
dispose: async () => {
unbind()
catalog.dispose()
preparation.resolve()
await host.flushAllStreamedEvents()
await omp?.dispose()
closeTestJournalHostDatabase(root)
await rm(root, { recursive: true, force: true })
}
}
}
@@ -149,9 +149,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
atRestCommands: StructuredAgentSessionAtRestCommands = {
read: (record) =>
this.agents.registration(record.provider)?.adapter.atRestCommands?.read(record),
settled: async (record, budgetMs) => {
snapshot: (record) =>
this.agents.registration(record.provider)?.adapter.atRestCommands?.snapshot?.(record) ?? {
commandsPending: false
},
settled: async (record, budgetMs, independent) => {
const source = this.agents.registration(record.provider)?.adapter.atRestCommands
return source?.settled ? source.settled(record, budgetMs) : source?.read(record)
return source?.settled ? source.settled(record, budgetMs, independent) : source?.read(record)
},
onChange: (listener) => {
const stops = this.adapters().flatMap((adapter) =>
@@ -0,0 +1,423 @@
// @vitest-environment happy-dom
import {
directory,
workspace,
account,
clock,
store,
host,
openHost,
attachContext,
advanceCatalogClock,
setCatalogAccount
} from './structured-agent-session-at-rest-command-rig.test-fixture'
import { createMobileCommandMenuHostFixture } from './mobile-queued-edit-host.test-fixture'
import { AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY } from '../../../shared/structured-agent-session-surface-capabilities'
import { PrioritySemaphore } from '../../../shared/priority-semaphore'
import {
commandDiscoveryRig,
type CommandDiscoveryChild
} from '../../acp/acp-command-discovery.test-fixture'
import {
bindAtRestCommandContexts,
retireAtRestCommandSettings,
refreshAtRestCommandContext
} from './at-rest-command-context'
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
import {
CLAUDE_AT_REST_COMMANDS_TTL_MS,
ClaudeAtRestCommandCatalog
} from '../../claude/claude-at-rest-commands'
it.each(['claude', 'omp'] as const)(
'bounds mixed real-host %s admission, retained reads and replacement roots at N/10N',
async (provider) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const warned = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
for (const count of [3, 30]) {
for (const legacy of [0, 1, 2, count]) {
let hold = true,
physical = 0,
peak = 0,
starts = 0,
preparations = 0,
permits = 0,
peakPermits = 0
const originalAcquire = PrioritySemaphore.prototype.acquire
const acquire = vi
.spyOn(PrioritySemaphore.prototype, 'acquire')
.mockImplementation(async function (this: PrioritySemaphore, priority, signal) {
const release = await originalAcquire.call(this, priority, signal)
peakPermits = Math.max(peakPermits, ++permits)
let released = false
return () => {
expect(released).toBe(false)
released = true
permits--
release()
}
})
let cleanup: Promise<void> | undefined
let releaseCleanup = () => {}
let selected = account,
cwd = workspace
const roots: CommandDiscoveryChild[] = []
let rootPeak = 0,
signals = 0
const gates: ReturnType<typeof Promise.withResolvers<void>>[] = []
const launches: { cwd: string; account: string }[] = []
const enter = async (launch: { cwd: string; account: string }) => {
starts++
peak = Math.max(peak, ++physical)
launches.push(launch)
const gate = Promise.withResolvers<void>()
gates.push(gate)
if (hold) {
await gate.promise
}
physical--
}
const omp =
provider === 'omp'
? await commandDiscoveryRig(
(child) => {
roots.push(child)
rootPeak = Math.max(rootPeak, roots.filter((root) => !root.exited).length)
const prove = child.proveClose
child.proveClose = async () => {
await cleanup
return prove()
}
if (child.launch.cwd === undefined) {
throw new Error('command discovery must launch in its workspace')
}
const entering = enter({
cwd: child.launch.cwd,
account: child.launch.env!.PI_CODING_AGENT_DIR!
})
const initialize = child.initialize.bind(child)
vi.spyOn(child, 'initialize').mockImplementation(async () => {
await entering
return initialize()
})
},
{
resolveWorkspacePath: async () => {
preparations++
return cwd
}
}
)
: undefined
const catalog =
omp?.catalog ??
new ClaudeAtRestCommandCatalog({
now: () => clock,
resolveWorkspacePath: async () => {
preparations++
return cwd
},
resolveAccountHome: async () => ({ variable: 'CLAUDE_CONFIG_DIR', path: selected }),
probe: async (input) => {
input.signal?.addEventListener('abort', () => signals++, { once: true })
await enter({ cwd: input.cwd, account: input.accountHomePath })
return [{ name: 'fast', kind: 'command' }]
}
})
const unbind = bindAtRestCommandContexts([catalog])
const stops: (() => void)[] = [],
legacyStops: (() => void)[] = []
let frames = 0,
hints = 0
const snapshot = vi.spyOn(catalog, 'snapshot')
const expectIdle = (sessionIds: readonly string[]) => {
for (const sessionId of sessionIds) {
expect(catalog.snapshot(store.getRecord(sessionId)!).commandsPending).toBe(false)
}
}
const emit = (event: AgentSessionSubscribeEvent) => {
frames++
if (event.type !== 'end' && event.commandsChanged) {
hints++
}
}
try {
await openHost(catalog)
const ids = await Promise.all(
Array.from({ length: count + 1 }, (_, index) => attachContext(provider, index))
)
for (const [index, sessionId] of ids.entries()) {
stops.push(
await host.subscribe({
id: `modern-${index}`,
sessionId,
commandsOnRequest: true,
emit
})
)
expectIdle([sessionId])
if (index < legacy) {
legacyStops.push(await host.subscribe({ id: `legacy-${index}`, sessionId, emit }))
}
}
stops.push(
await host.subscribe({
id: 'duplicate',
sessionId: ids[0]!,
commandsOnRequest: true,
emit
})
)
expectIdle(ids.slice(legacy))
await vi.waitFor(() => expect(starts).toBe(Math.min(2, legacy)), { interval: 0 })
const distinct = host.readCommands(ids[count]!, true)
const overlap = host.readCommands(ids[legacy > 2 ? 2 : 0]!, true)
const duplicate = host.readCommands(ids[legacy > 2 ? 2 : 0]!, true)
const admitted = Math.min(2, legacy) + 1 + (legacy === 0 || legacy > 2 ? 1 : 0)
await vi.waitFor(() => expect(starts).toBe(admitted), { interval: 0 })
expect(physical).toBe(admitted)
expect(peak).toBe(admitted)
expect(permits).toBe(Math.min(2, legacy))
expect(peakPermits).toBe(Math.min(2, legacy))
if (omp) {
expect(omp.children.filter((child) => !child.exited)).toHaveLength(admitted)
expect(rootPeak).toBe(admitted)
}
expect(preparations).toBe(
new Set([...ids.slice(0, legacy), ids[count], ids[legacy > 2 ? 2 : 0]]).size
)
hold = false
gates.forEach((gate) => gate.resolve())
await Promise.all([distinct, overlap, duplicate])
for (const sessionId of ids) {
await host.readCommands(sessionId, true)
}
await vi.waitFor(() => expect(physical).toBe(0), { interval: 0 })
if (omp) {
await vi.waitFor(
() => expect(omp.children.every((child) => child.exited)).toBe(true),
{ interval: 0 }
)
}
await vi.waitFor(() => expect(permits).toBe(0), { interval: 0 })
const cached = (await host.readCommands(ids[0]!, true)).commands
let seeded = starts
expect(seeded).toBe(count + 1)
expectIdle(ids)
frames = 0
hints = 0
snapshot.mockClear()
advanceCatalogClock(CLAUDE_AT_REST_COMMANDS_TTL_MS)
vi.setSystemTime(Date.now() + CLAUDE_AT_REST_COMMANDS_TTL_MS)
const passive = await host.subscribe({
id: 'expired-frame',
sessionId: ids[0]!,
commandsOnRequest: true,
emit
})
stops.push(passive)
expect(starts).toBe(seeded)
expect(snapshot).toHaveBeenCalled()
expect(frames).toBe(2)
expectIdle(ids)
if (legacy > 0) {
hold = true
let retained: AgentSessionSubscribeEvent | undefined
legacyStops.push(
await host.subscribe({
id: 'expired-legacy-frame',
sessionId: ids[0]!,
emit: (event) => {
if ('commands' in event) {
retained = event
}
}
})
)
await vi.waitFor(() => expect(starts).toBe(seeded + 1), { interval: 0 })
expect(retained).toMatchObject({ commands: cached })
expect(permits).toBe(1)
}
legacyStops.splice(0).forEach((stop) => stop())
hold = false
gates.forEach((gate) => gate.resolve())
await vi.waitFor(() => expect(physical).toBe(0), { interval: 0 })
await vi.waitFor(() => expect(permits).toBe(0), { interval: 0 })
if (omp) {
await vi.waitFor(
() => expect(omp.children.every((child) => child.exited)).toBe(true),
{ interval: 0 }
)
}
seeded += legacy > 0 ? 1 : 0
expect(starts).toBe(seeded)
expectIdle(ids)
frames = 0
hints = 0
selected = join(directory, 'replacement-account')
retireAtRestCommandSettings(['activeClaudeManagedAccountId'])
await vi.advanceTimersByTimeAsync(0)
expect(starts).toBe(seeded)
expect(hints).toBe(count + 3)
expect(frames).toBe(count + 3)
expectIdle(ids)
snapshot.mockClear()
frames = 0
expect(await host.readCommands(ids[0]!, true)).toMatchObject({ commandsPending: false })
expect(frames).toBe(3)
expect(snapshot).toHaveBeenCalledTimes(count + 7)
expect(launches.at(-1)?.account).toBe(provider === 'claude' ? selected : account)
frames = 0
hints = 0
cwd = join(directory, 'replacement-folder')
await mkdir(cwd, { recursive: true })
await refreshAtRestCommandContext(async () => {})
await vi.advanceTimersByTimeAsync(0)
expect(hints).toBe(count + 3)
expect(starts).toBe(seeded + 1)
expectIdle(ids)
await host.readCommands(ids[0]!, true)
expect(launches.at(-1)?.cwd).toBe(cwd)
// Retirement cancels logical ownership while old physical work is still held.
const beforeOld = starts
catalog.retireContext()
await vi.advanceTimersByTimeAsync(0)
expectIdle(ids)
expect(starts).toBe(beforeOld)
hold = true
const old = host.readCommands(ids[0]!, true)
const oldSettled = omp
? expect(old).rejects.toThrow('Command discovery unavailable')
: expect(old).resolves.toMatchObject({ commandsPending: true })
await vi.waitFor(() => expect(physical).toBe(1), { interval: 0 })
const cleanupGate = Promise.withResolvers<void>()
cleanup = cleanupGate.promise
releaseCleanup = () => cleanupGate.resolve()
// Finish logical demand while retaining physical work across provider/read deadlines.
await vi.advanceTimersByTimeAsync(10_000)
await oldSettled
const beforeReplacement = starts
catalog.retireContext()
await vi.advanceTimersByTimeAsync(0)
expectIdle(ids)
expect(starts).toBe(beforeReplacement)
const replacement = host.readCommands(ids[0]!, true)
await vi.waitFor(() => expect(physical).toBe(2), { interval: 0 })
expect(peak).toBe(Math.max(admitted, 2))
if (!omp) {
expect(signals).toBe(1)
}
if (omp) {
expect(omp.children.filter((child) => !child.exited)).toHaveLength(2)
expect(rootPeak).toBeGreaterThanOrEqual(2)
}
hold = false
gates.forEach((gate) => gate.resolve())
cleanupGate.resolve()
cleanup = undefined
await replacement
await vi.waitFor(() => expect(physical).toBe(0), { interval: 0 })
if (omp) {
const saved = account
setCatalogAccount(selected)
const pinned = await attachContext(provider, count + 2)
setCatalogAccount(saved)
await host.readCommands(pinned, true)
expect(launches.at(-1)?.account).toBe(selected)
await omp.catalog.closeAll()
expect(omp.children.every((child) => child.exited)).toBe(true)
}
} finally {
releaseCleanup()
hold = false
gates.forEach((gate) => gate.resolve())
legacyStops.forEach((stop) => stop())
stops.forEach((stop) => stop())
unbind()
await omp?.dispose()
acquire.mockRestore()
}
}
}
} finally {
warned.mockRestore()
vi.useRealTimers()
}
},
30_000
)
it.each([
['claude', false],
['omp', false],
['claude', true]
] as const)(
'uses the phone CI host fixture with actual %s dispatch, pending preparation=%s and retirement',
async (provider, pending) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const fixture = await createMobileCommandMenuHostFixture(provider, pending)
const frames: AgentSessionSubscribeEvent[] = []
const stop = await fixture.subscribe((event) => frames.push(event))
try {
expect(fixture.starts()).toBe(0)
if (pending) {
const read = fixture.read()
await vi.advanceTimersByTimeAsync(10_000)
expect(await read).toMatchObject({ commandsPending: true })
await vi.advanceTimersByTimeAsync(330_000)
expect(fixture.starts()).toBe(0)
} else {
expect((await fixture.read()).commands?.some((row) => row.name === 'fast')).toBe(true)
}
await fixture.retire(true)
expect(frames.some((event) => event.type !== 'end' && event.commandsChanged)).toBe(true)
expect(fixture.starts()).toBe(pending ? 0 : 1)
expect((await fixture.read()).commands?.some((row) => row.name === 'fast')).toBe(true)
expect(fixture.starts()).toBe(pending ? 1 : 2)
} finally {
stop()
await fixture.dispose()
vi.useRealTimers()
}
}
)
it.each(['claude', 'omp'] as const)(
'keeps marked pre-report phone requests independent, sharing each %s context at N/10N',
async (provider) => {
for (const count of [3, 30]) {
const host = await createMobileCommandMenuHostFixture(provider, true, count)
try {
const reads = host.sessionIds.map((id) =>
host.request(id, [AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY])
)
const joined = host.request(host.sessionId, [AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY])
for (const id of host.sessionIds) {
expect(host.pending(id)).toBe(true)
}
await vi.waitFor(() => expect(host.preparations()).toBe(count))
expect(host.starts()).toBe(0)
host.releasePreparation()
const replies = await Promise.all([...reads, joined])
expect(replies.every((reply) => reply.commands?.some((row) => row.name === 'fast'))).toBe(
true
)
expect(host.starts()).toBe(count)
expect(host.preparations()).toBe(count)
for (const id of host.sessionIds) {
expect(host.pending(id)).toBe(false)
}
} finally {
await host.dispose()
}
}
}
)
@@ -0,0 +1,233 @@
// @vitest-environment happy-dom
import { cleanup } from '@testing-library/react'
import type { CommandMenuRendererFixtureModule } from '../../../shared/agent-session-command-menu.test-fixture-contract'
import { OMP } from '../../acp/acp-command-discovery.test-fixture'
import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions'
import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition'
import { StructuredAgentRegistry } from './structured-agent-registry'
import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router'
import { structuredAgentsDeclaringNothing } from './structured-agent-session-adapter-router-test-support'
import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, vi, type Mock } from 'vitest'
import type {
AgentSessionSlashCommand,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import {
ClaudeAtRestCommandCatalog,
type ClaudeAtRestCommandsDeps
} from '../../claude/claude-at-rest-commands'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
import {
HOST_TEST_NOW,
HOST_TEST_SESSION as SESSION,
hostTestAttachParams,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
const runtimeMocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: runtimeMocks.call,
subscribeStructuredAgentSession: runtimeMocks.subscribe
}))
export const runtime: { call: Mock; subscribe: Mock } = runtimeMocks
export const renderer = await vi.importActual<CommandMenuRendererFixtureModule>(
'../../../renderer/src/components/native-chat/agent-session-command-menu.test-fixture'
)
export const caller = { callerKey: 'desktop' }
export const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a'
export let directory: string
export let workspace: string
export let account: string
export let clock: number
export let store: AgentSessionRecordStore
export let host: StructuredAgentSessionHost
export let deliveryAdapter: StructuredAgentSessionAdapterRouter
export function adapter(
catalog: StructuredAgentSessionAtRestCommands
): StructuredAgentSessionAdapter {
return {
supportsLocation: () => true,
supportsCreate: () => true,
open: async () => ({ reportedOptions: {} }),
acquire: vi.fn(async (input) => ({
acquisitionGeneration: `generation-${input.fence}`,
process: {
hostId: 'local',
pid: 4001,
processStartTimeMs: HOST_TEST_NOW,
spawnToken: input.spawnToken
},
link: {
linkId: `link-${input.fence}`,
mintedAtFence: input.fence,
observedAt: HOST_TEST_NOW,
origin: 'created' as const,
handle: claudeProviderHandle(CLAUDE_SESSION, null)
}
})),
atRestCommands: catalog,
dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })),
cancelTurn: async () => ({ cancelled: true }),
answerPrompt: async () => {},
setOption: async () => {},
releaseAcquisition: async () => true,
closeSession: async () => true
}
}
// Isolate discovery to test folders; a large real home otherwise outlasts the menu wait.
export const discoverWithoutUserHome: ClaudeAtRestCommandsDeps['discover'] = async (args) => {
const { discoverSkills } = await import('../../skills/discovery')
return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true })
}
export function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog {
return new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => workspacePath,
now: () => clock,
discover: discoverWithoutUserHome
})
}
export async function openHost(
catalog: StructuredAgentSessionAtRestCommands | null = catalogFor(workspace)
): Promise<void> {
const implementation = adapter(catalog ?? { read: () => undefined, onChange: () => () => {} })
if (catalog === null) {
implementation.atRestCommands = undefined
}
const agents = new StructuredAgentRegistry(
structuredAgentsDeclaringNothing([CLAUDE_STRUCTURED_AGENT, acpStructuredAgentDefinition(OMP)])
.definitions()
.map((definition) => ({ definition, adapter: implementation }))
)
store = await openTestAgentSessionRecordStore(directory)
deliveryAdapter = new StructuredAgentSessionAdapterRouter(agents, async () => {})
host = new StructuredAgentSessionHost({
agents,
store,
adapter: deliveryAdapter,
journalDatabase: openTestJournalHostDatabase(directory),
claimKeyId: 'key',
logger: recordingStructuredAgentSessionLogger().logger,
now: () => clock,
probeOwner: async () => ({ outcome: 'pid-absent' })
})
}
export async function writeSkill(root: string, name: string): Promise<void> {
await mkdir(join(root, name), { recursive: true })
await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: A skill\n---\n`)
}
export async function writeCommand(root: string, file: string): Promise<void> {
await mkdir(join(root, file, '..'), { recursive: true })
await writeFile(join(root, file), '---\ndescription: A command\n---\nDo it.\n')
}
export async function menuOf(sessionId: string): Promise<AgentSessionSlashCommand[] | null> {
let commands: AgentSessionSlashCommand[] | null | undefined
const emit = (event: AgentSessionSubscribeEvent) => {
if ('commands' in event) {
commands = event.commands
}
}
const unsubscribe = await host.subscribe({ id: `pane-${sessionId}`, sessionId, emit })
await vi.waitFor(() => expect(commands).toBeTruthy())
unsubscribe()
return commands ?? null
}
export const namesOf = (menu: AgentSessionSlashCommand[] | null, kind: 'skill' | 'command') =>
(menu ?? []).filter((entry) => entry.kind === kind).map((entry) => entry.name)
beforeEach(async () => {
resetHostTestOperationIds()
clock = HOST_TEST_NOW
directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-commands-'))
workspace = join(directory, 'workspace')
account = join(directory, 'claude-account')
await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr')
await writeCommand(join(account, 'commands'), 'deploy.md')
await openHost()
const attached = await host.attach(
caller,
hostTestAttachParams(null, {
provider: 'claude',
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: account },
providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null }
})
)
expect(attached).toMatchObject({ ok: true })
await host.setSessionTabVisibility(SESSION, true)
})
afterEach(async () => {
cleanup()
renderer.resetReadOwners()
await host.flushAllStreamedEvents()
await rm(directory, { recursive: true, force: true })
})
export async function attachContext(provider: 'claude' | 'omp', index: number): Promise<string> {
const sessionId = crypto.randomUUID()
if (provider === 'omp') {
await store.reserveOwner({
sessionId,
provider,
location: { ...store.getRecord(SESSION)!.location, workspaceId: `workspace-${index}` },
accountHome: { variable: 'PI_CODING_AGENT_DIR', path: account },
expectedFence: null,
spawnToken: crypto.randomUUID(),
claimKeyId: 'key',
handoffOperationId: null,
probe: { outcome: 'reservation-unused' },
operation: { callerKey: 'test', operationId: hostTestOperationId(), fingerprint: sessionId },
now: clock
})
return sessionId
}
const attached = await host.attach(
caller,
hostTestAttachParams(null, {
envelope: { ...hostTestAttachParams(null).envelope, sessionId },
provider,
agent: provider,
accountHome:
provider === 'claude'
? { variable: 'CLAUDE_CONFIG_DIR', path: account }
: { variable: 'PI_CODING_AGENT_DIR', path: account },
location: { ...store.getRecord(SESSION)!.location, workspaceId: `workspace-${index}` },
providerHandle: { kind: 'claude', sessionId: crypto.randomUUID(), leafUuid: null }
})
)
if (!attached.ok) {
throw new Error(JSON.stringify(attached))
}
return sessionId
}
export const advanceCatalogClock = (milliseconds: number): void => {
clock += milliseconds
}
export const setCatalogAccount = (path: string): void => {
account = path
}
@@ -1,160 +1,56 @@
// @vitest-environment happy-dom
import {
directory,
workspace,
account,
clock,
store,
host,
deliveryAdapter,
openHost,
discoverWithoutUserHome,
menuOf,
namesOf,
attachContext,
runtime,
renderer,
advanceCatalogClock
} from './structured-agent-session-at-rest-command-rig.test-fixture'
import { act } from '@testing-library/react'
import {
commandDiscoveryRig,
DISCOVERY_SESSION
} from '../../acp/acp-command-discovery.test-fixture'
import { AtRestCommandCatalog } from './at-rest-command-catalog'
import { startAgentSessionCommandMenuQuery } from '../../../shared/agent-session-command-menu-query'
import { createCodexAtRestSkillCatalog } from '../../codex/codex-at-rest-skills-probe'
import { refreshAtRestCommandContext } from './at-rest-command-context'
import {
bindAtRestCommandContexts,
retireAtRestCommandSettings,
refreshAtRestCommandContext,
retireAtRestCommandContext
} from './at-rest-command-context'
import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type {
AgentSessionSlashCommand,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import {
CLAUDE_AT_REST_COMMANDS_TTL_MS,
ClaudeAtRestCommandCatalog,
type ClaudeAtRestCommandsDeps
ClaudeAtRestCommandCatalog
} from '../../claude/claude-at-rest-commands'
import { createClaudeCommandCatalogProbe } from '../../claude/claude-command-catalog-probe'
import type { runAgentModelCatalogListing } from '../agent-model-catalog/agent-model-catalog-probe-runner'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
import {
HOST_TEST_NOW,
HOST_TEST_SESSION as SESSION,
hostTestAttachParams,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data'
type ListingCall = Parameters<typeof runAgentModelCatalogListing>
const caller = { callerKey: 'desktop' }
const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a'
let directory: string
let workspace: string
let account: string
let clock: number
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAdapter {
return {
supportsLocation: () => true,
supportsCreate: () => true,
open: async () => ({ reportedOptions: {} }),
acquire: vi.fn(async (input) => ({
acquisitionGeneration: `generation-${input.fence}`,
process: {
hostId: 'local',
pid: 4001,
processStartTimeMs: HOST_TEST_NOW,
spawnToken: input.spawnToken
},
link: {
linkId: `link-${input.fence}`,
mintedAtFence: input.fence,
observedAt: HOST_TEST_NOW,
origin: 'created' as const,
handle: claudeProviderHandle(CLAUDE_SESSION, null)
}
})),
atRestCommands: catalog,
dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })),
cancelTurn: async () => ({ cancelled: true }),
answerPrompt: async () => {},
setOption: async () => {},
releaseAcquisition: async () => true,
closeSession: async () => true
}
}
// Isolate discovery to test folders; a large real home otherwise outlasts the menu wait.
const discoverWithoutUserHome: ClaudeAtRestCommandsDeps['discover'] = async (args) => {
const { discoverSkills } = await import('../../skills/discovery')
return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true })
}
function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog {
return new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => workspacePath,
now: () => clock,
discover: discoverWithoutUserHome
})
}
async function openHost(catalog = catalogFor(workspace)): Promise<void> {
store = await openTestAgentSessionRecordStore(directory)
host = new StructuredAgentSessionHost({
agents: NO_STRUCTURED_AGENTS,
store,
adapter: adapter(catalog),
journalDatabase: openTestJournalHostDatabase(directory),
claimKeyId: 'key',
logger: recordingStructuredAgentSessionLogger().logger,
now: () => clock,
probeOwner: async () => ({ outcome: 'pid-absent' })
})
}
async function writeSkill(root: string, name: string): Promise<void> {
await mkdir(join(root, name), { recursive: true })
await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: A skill\n---\n`)
}
async function writeCommand(root: string, file: string): Promise<void> {
await mkdir(join(root, file, '..'), { recursive: true })
await writeFile(join(root, file), '---\ndescription: A command\n---\nDo it.\n')
}
async function menuOf(sessionId: string): Promise<AgentSessionSlashCommand[] | null> {
let commands: AgentSessionSlashCommand[] | null | undefined
const emit = (event: AgentSessionSubscribeEvent) => {
if ('commands' in event) {
commands = event.commands
}
}
const unsubscribe = await host.subscribe({ id: `pane-${sessionId}`, sessionId, emit })
await vi.waitFor(() => expect(commands).toBeTruthy())
unsubscribe()
return commands ?? null
}
const namesOf = (menu: AgentSessionSlashCommand[] | null, kind: 'skill' | 'command') =>
(menu ?? []).filter((entry) => entry.kind === kind).map((entry) => entry.name)
beforeEach(async () => {
resetHostTestOperationIds()
clock = HOST_TEST_NOW
directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-commands-'))
workspace = join(directory, 'workspace')
account = join(directory, 'claude-account')
await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr')
await writeCommand(join(account, 'commands'), 'deploy.md')
await openHost()
const attached = await host.attach(
caller,
hostTestAttachParams(null, {
provider: 'claude',
agent: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: account },
providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null }
})
)
expect(attached).toMatchObject({ ok: true })
await host.setSessionTabVisibility(SESSION, true)
})
afterEach(async () => {
await host.flushAllStreamedEvents()
await rm(directory, { recursive: true, force: true })
})
describe("a Claude chat whose Claude isn't running lists Claude's own `/` commands", () => {
it('as Claude itself lists it in the chat’s folder, from the folders until it can, and keeps its list through a failed refresh', async () => {
const asked: ListingCall[] = []
@@ -203,11 +99,11 @@ describe("a Claude chat whose Claude isn't running lists Claude's own `/` comman
])
const success = Promise.withResolvers<string>()
answer = success.promise
clock += CLAUDE_AT_REST_COMMANDS_TTL_MS
advanceCatalogClock(CLAUDE_AT_REST_COMMANDS_TTL_MS)
host.readCommands(SESSION)
await vi.waitFor(() => expect(asked).toHaveLength(2))
// A slow refresh is joined, not doubled, however long it runs.
clock += 30_000
advanceCatalogClock(30_000)
host.readCommands(SESSION)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(asked).toHaveLength(2)
@@ -227,14 +123,14 @@ describe("a Claude chat whose Claude isn't running lists Claude's own `/` comman
expect(asked[0]?.[1].inheritedEnv?.CLAUDE_CONFIG_DIR).toBe(account)
// Failed refresh must retain the provider list over folder fallback.
answer = failed
clock += CLAUDE_AT_REST_COMMANDS_TTL_MS
advanceCatalogClock(CLAUDE_AT_REST_COMMANDS_TTL_MS)
host.readCommands(SESSION)
await vi.waitFor(() => expect(warned).toHaveBeenCalledTimes(2))
await new Promise((resolve) => setTimeout(resolve, 20))
expect(asked).toHaveLength(3)
expect(catalog.read(record())).toEqual(claudeList)
// The folder answer still counts as this TTL's scan.
clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - 1
advanceCatalogClock(CLAUDE_AT_REST_COMMANDS_TTL_MS - 1)
host.readCommands(SESSION)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(asked).toHaveLength(3)
@@ -365,3 +261,544 @@ describe("a Claude chat whose Claude isn't running lists Claude's own `/` comman
}
})
})
it('returns pending after the phone budget and hints a later completion without starting closed contexts', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const held = Promise.withResolvers<AgentSessionSlashCommand[]>()
const entered = Promise.withResolvers<void>()
const catalog = new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => workspace,
probe: async () => {
entered.resolve()
return held.promise
}
})
const frames: AgentSessionSubscribeEvent[] = []
let stop = () => {}
try {
await openHost(catalog)
stop = await host.subscribe({
id: 'phone',
sessionId: SESSION,
boundedFrames: true,
commandsOnRequest: true,
emit: (event) => frames.push(event)
})
frames.length = 0
const reply = host.readCommands(SESSION, true)
await entered.promise
await vi.advanceTimersByTimeAsync(10_000)
expect(await reply).toEqual({ commands: undefined, commandsPending: true })
await vi.advanceTimersByTimeAsync(2_000)
held.resolve([{ name: 'plugin:check', kind: 'command' }])
await vi.waitFor(() =>
expect(frames.some((frame) => frame.type !== 'end' && frame.commandsChanged)).toBe(true)
)
expect(frames.every((frame) => !('commands' in frame))).toBe(true)
expect(await host.readCommands(SESSION, true)).toEqual({
commands: [{ name: 'plugin:check', kind: 'command' }],
commandsPending: false
})
frames.length = 0
catalog.retireContext()
await vi.advanceTimersByTimeAsync(0)
expect(frames.some((frame) => frame.type !== 'end' && frame.commandsChanged)).toBe(true)
expect(catalog.snapshot(store.getRecord(SESSION)!).commandsPending).toBe(false)
} finally {
held.resolve([])
stop()
catalog.dispose()
vi.useRealTimers()
}
})
it.each([undefined, null] as const)(
'settles cold failures under source cap %s through the real query and host',
async (maxFailedScans) => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
let scans = 0
const catalog = new AtRestCommandCatalog({
label: 'test',
accepts: () => true,
resolveWorkspacePath: async () => workspace,
maxFailedScans,
scan: async () => {
scans++
throw new Error('private provider detail')
}
})
await openHost(catalog)
const accept = vi.fn()
const read = vi.fn(() => host.readCommands(SESSION, true))
const query = startAgentSessionCommandMenuQuery(read, accept)
await vi.waitFor(() => expect(scans).toBe(1), { interval: 0 })
for (let index = 0; index < 2; index++) {
await vi.advanceTimersByTimeAsync(10_000)
await vi.waitFor(() => expect(scans).toBe(index + 2), { interval: 0 })
}
await vi.advanceTimersByTimeAsync(600_000)
expect(scans).toBe(3)
expect(read).toHaveBeenCalledTimes(3)
if (maxFailedScans === null) {
expect(accept).not.toHaveBeenCalled()
} else {
expect(accept).toHaveBeenLastCalledWith({ commands: undefined, commandsPending: false })
}
query.dispose()
catalog.dispose()
vi.restoreAllMocks()
vi.useRealTimers()
}
)
it.each([undefined, [], [{ name: 'fast', kind: 'command' as const }]])(
'returns current retained/live %j and keeps retryable failure separate from pending',
async (rows) => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
let failing = rows === undefined
let result = rows
let scanGate: Promise<void> | undefined,
entered: ReturnType<typeof Promise.withResolvers<void>> | undefined
const catalog = new AtRestCommandCatalog({
label: 'test',
accepts: () => true,
resolveWorkspacePath: async () => workspace,
scan: async () => {
entered?.resolve()
await scanGate
if (failing) {
throw new Error('private provider detail')
}
return { commands: result, fromAgent: true }
}
})
await openHost(catalog)
if (rows === undefined) {
await expect(host.readCommands(SESSION, true)).rejects.toThrow(
'Command discovery unavailable'
)
expect(await host.readCommands(SESSION)).toEqual({ commands: undefined })
failing = false
result = [{ name: 'fast', kind: 'command' }]
await vi.advanceTimersByTimeAsync(10_000)
expect(await host.readCommands(SESSION, true)).toEqual({
commands: result,
commandsPending: false
})
} else {
expect(await host.readCommands(SESSION, true)).toEqual({
commands: rows,
commandsPending: false
})
failing = true
await vi.advanceTimersByTimeAsync(300_000)
expect(await host.readCommands(SESSION, true)).toEqual({
commands: rows,
commandsPending: false
})
// A newly mounted reader uses host-retained rows, not a client fallback.
expect(await host.readCommands(SESSION, true)).toEqual({
commands: rows,
commandsPending: false
})
}
catalog.retireContext()
const held = Promise.withResolvers<void>()
const live = vi.spyOn(deliveryAdapter, 'readCommands').mockReturnValue(undefined)
scanGate = held.promise
entered = Promise.withResolvers<void>()
failing = true
const waiting = host.readCommands(SESSION, true)
await entered.promise
live.mockReturnValue(rows ?? [])
held.resolve()
expect(await waiting).toEqual({ commands: rows ?? [], commandsPending: false })
catalog.dispose()
vi.restoreAllMocks()
vi.useRealTimers()
}
)
it.each(['claude', 'omp'] as const)(
'delivers %s retirement through router, live reader, demanded query and host settlement after rejection parking',
async (provider) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
let online = false
let calls = 0,
frames = 0
const catalog =
provider === 'claude'
? new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => workspace,
probe: async () => [{ name: 'fast', kind: 'command' }]
})
: undefined
const omp =
provider === 'omp'
? await commandDiscoveryRig((child) => {
child.agent.on('session/new', (frame) => {
child.agent.notify('session/update', {
sessionId: DISCOVERY_SESSION,
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [{ name: 'fast', description: 'Fast', input: { hint: '' } }]
}
})
child.agent.reply(frame, { sessionId: DISCOVERY_SESSION })
})
})
: undefined
const source = catalog ?? omp!.catalog
const unbind = bindAtRestCommandContexts([source])
await openHost(source)
const id = await attachContext(provider, 101)
runtime.call.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.history') {
return host.history(params)
}
calls++
if (!online) {
throw new Error('offline')
}
return host.readCommands(params.sessionId, true)
})
runtime.subscribe.mockImplementation(async (_target, params, emit) => ({
unsubscribe: await host.subscribe({
id: 'live-menu',
...params,
commandsOnRequest: true,
emit: (event) => {
frames++
emit(event)
}
})
}))
const menu = renderer.renderCommandMenu(id)
try {
await act(async () => {
await vi.advanceTimersByTimeAsync(50)
})
await act(async () => menu.setOpen(true))
await act(async () => {
await vi.advanceTimersByTimeAsync(330_000)
})
expect(calls).toBe(3)
expect(source.snapshot(store.getRecord(id)!).commands).toBeUndefined()
online = true
retireAtRestCommandSettings(['agentCmdOverrides'])
await vi.advanceTimersByTimeAsync(50)
await vi.waitFor(
() =>
expect(
source.snapshot(store.getRecord(id)!).commands?.some((row) => row.name === 'fast')
).toBe(true),
{ interval: 0 }
)
await act(async () => {
await vi.advanceTimersByTimeAsync(50)
})
expect(menu.commands()?.some((row) => row.name === 'fast')).toBe(true)
expect(calls).toBe(4)
await vi.advanceTimersByTimeAsync(600_000)
expect(calls).toBe(4)
const baseline = frames
// Unchanged ordinary reads/completions do not masquerade as retirement.
await host.readCommands(id, true)
await vi.advanceTimersByTimeAsync(50)
expect(frames).toBe(baseline)
await refreshAtRestCommandContext(async () => {})
await vi.advanceTimersByTimeAsync(50)
await vi.waitFor(() => expect(calls).toBe(5), { interval: 0 })
act(() => menu.setOpen(false))
retireAtRestCommandSettings(['agentCmdOverrides'])
await vi.advanceTimersByTimeAsync(50)
expect(calls).toBe(5)
} finally {
menu.unmount()
unbind()
await omp?.dispose()
vi.useRealTimers()
}
}
)
it.each([false, true])(
'returns cached rows with pending=%s without inventing absence',
async (empty) => {
vi.useFakeTimers()
const rows = empty ? [] : [{ name: 'fast', kind: 'command' as const }]
let gate: Promise<void> | undefined
const entered = Promise.withResolvers<void>(),
held = Promise.withResolvers<void>()
const catalog = new AtRestCommandCatalog({
label: 'pending',
accepts: () => true,
resolveWorkspacePath: async () => workspace,
scan: async () => {
entered.resolve()
await gate
return { commands: rows, fromAgent: true }
}
})
await openHost(catalog)
await host.readCommands(SESSION, true)
await vi.advanceTimersByTimeAsync(300_000)
gate = held.promise
const waiting = host.readCommands(SESSION, true)
await vi.advanceTimersByTimeAsync(10_000)
expect(await waiting).toEqual({ commands: rows, commandsPending: true })
held.resolve()
expect(await host.readCommands(SESSION, true)).toEqual({
commands: rows,
commandsPending: false
})
catalog.dispose()
vi.useRealTimers()
}
)
it('retires absent contexts by provider/workspace independently of ordinary null publications and waits for readiness', async () => {
vi.useFakeTimers()
let starts = 0
const catalog = new ClaudeAtRestCommandCatalog({
resolveWorkspacePath: async () => workspace,
probe: async () => {
starts++
return []
}
})
await openHost(catalog)
const ids = [
await attachContext('claude', 21),
await attachContext('claude', 22),
await attachContext('omp', 23)
]
const events = ids.map<AgentSessionSubscribeEvent[]>(() => [])
const stops = await Promise.all(
ids.map((sessionId, index) =>
host.subscribe({
id: `scope-${index}`,
sessionId,
commandsOnRequest: true,
emit: (event) => events[index]!.push(event)
})
)
)
const unbind = bindAtRestCommandContexts([catalog])
try {
events.forEach((list) => list.splice(0))
for (let index = 0; index < 2; index++) {
retireAtRestCommandContext('workspace-21')
await vi.advanceTimersByTimeAsync(0)
expect(events[0]).toHaveLength(index + 1)
expect(events.slice(1).map((list) => list.length)).toEqual([0, 0])
expect(starts).toBe(0)
}
const barrier = Promise.withResolvers<void>()
const ready = refreshAtRestCommandContext(() => barrier.promise)
await vi.advanceTimersByTimeAsync(0)
const read = host.readCommands(ids[0]!, true)
await vi.advanceTimersByTimeAsync(1)
expect(starts).toBe(0)
barrier.resolve()
await ready
expect(await read).toMatchObject({ commands: [], commandsPending: false })
expect(starts).toBe(1)
expect(events[2]).toHaveLength(0)
} finally {
stops.forEach((stop) => stop())
unbind()
vi.useRealTimers()
}
})
it('follows retired ownership to replacement data and rejects a late old publication', async () => {
const entered = Promise.withResolvers<void>(),
stale = Promise.withResolvers<void>()
let scans = 0
const catalog = new AtRestCommandCatalog({
label: 'replacement',
accepts: () => true,
resolveWorkspacePath: async () => workspace,
scan: async () => {
if (++scans === 1) {
entered.resolve()
await stale.promise
return { commands: [{ name: 'obsolete', kind: 'command' }], fromAgent: true }
}
return { commands: [{ name: 'fast', kind: 'command' }], fromAgent: true }
}
})
await openHost(catalog)
const old = host.readCommands(SESSION, true)
await entered.promise
catalog.retireContext()
const current = host.readCommands(SESSION, true)
expect(await current).toEqual({
commands: [{ name: 'fast', kind: 'command' }],
commandsPending: false
})
stale.resolve()
expect(await old).toEqual(await current)
expect(catalog.snapshot(store.getRecord(SESSION)!).commands).toEqual([
{ name: 'fast', kind: 'command' }
])
catalog.dispose()
})
it.each(['no-source', 'unsupported', 'unavailable', 'missing'] as const)(
'terminates %s as unknown without a timer or invented list',
async (mode) => {
vi.useFakeTimers()
const scan = vi.fn(async () => ({ commands: undefined, fromAgent: false }))
const catalog = new AtRestCommandCatalog({
label: 'absence',
accepts: () => mode !== 'unsupported',
resolveWorkspacePath: async () =>
mode === 'missing' ? join(directory, 'missing') : workspace,
scan
})
await openHost(mode === 'no-source' ? null : catalog)
const read = vi.fn(() => host.readCommands(SESSION, true)),
accept = vi.fn()
const query = startAgentSessionCommandMenuQuery(read, accept)
await vi.waitFor(
() => expect(accept).toHaveBeenCalledWith({ commands: undefined, commandsPending: false }),
{ interval: 0 }
)
await vi.advanceTimersByTimeAsync(600_000)
expect(read).toHaveBeenCalledTimes(1)
expect(scan).toHaveBeenCalledTimes(mode === 'unavailable' ? 1 : 0)
query.dispose()
catalog.dispose()
vi.useRealTimers()
}
)
it('parks actual unlimited ACP cold failures after three scans, including missing-workspace rejection', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.spyOn(console, 'warn').mockImplementation(() => {})
const omp = await commandDiscoveryRig((child) =>
child.agent.on('session/new', (frame) =>
child.agent.reply(frame, { sessionId: DISCOVERY_SESSION })
)
)
await openHost(omp.catalog)
const id = await attachContext('omp', 201)
const accept = vi.fn(),
read = vi.fn(() => host.readCommands(id, true))
const query = startAgentSessionCommandMenuQuery(read, accept)
try {
for (let attempt = 1; attempt <= 3; attempt++) {
await vi.waitFor(() => expect(omp.children).toHaveLength(attempt), { interval: 0 })
await vi.waitFor(
() => expect(omp.children.at(-1)?.agent.frames.at(-1)?.method).toBe('session/new'),
{ interval: 0 }
)
await vi.advanceTimersByTimeAsync(9_000)
if (attempt < 3) {
await vi.advanceTimersByTimeAsync(10_000)
}
}
await vi.advanceTimersByTimeAsync(600_000)
expect(read).toHaveBeenCalledTimes(3)
expect(accept).not.toHaveBeenCalled()
const missing = await commandDiscoveryRig(undefined, {
resolveWorkspacePath: async () => join(directory, 'missing')
})
try {
await openHost(missing.catalog)
await expect(host.readCommands(id, true)).rejects.toThrow('Command discovery unavailable')
expect(missing.children).toHaveLength(0)
} finally {
await missing.dispose()
}
} finally {
query.dispose()
await omp.dispose()
vi.restoreAllMocks()
vi.useRealTimers()
}
})
it('wakes the same demanded desktop after a real pending episode reaches its deadline', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.spyOn(console, 'warn').mockImplementation(() => {})
const held = Promise.withResolvers<void>()
let repaired = false,
calls = 0
const catalog = new AtRestCommandCatalog({
label: 'pending',
accepts: () => true,
maxFailedScans: null,
resolveWorkspacePath: async () => workspace,
scan: async () => {
if (!repaired) {
await held.promise
}
return {
commands: [{ name: repaired ? 'fast' : 'obsolete', kind: 'command' }],
fromAgent: true
}
}
})
await openHost(catalog)
const unbind = bindAtRestCommandContexts([catalog])
runtime.call.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.history') {
return host.history(params)
}
calls++
return host.readCommands(params.sessionId, true)
})
runtime.subscribe.mockImplementation(async (_target, params, emit) => ({
unsubscribe: await host.subscribe({
id: 'deadline-menu',
...params,
commandsOnRequest: true,
emit
})
}))
const hook = renderer.renderCommandMenu(SESSION)
try {
await act(async () => {
await vi.advanceTimersByTimeAsync(50)
})
await act(async () => hook.setOpen(true))
await vi.waitFor(
() => expect(catalog.snapshot(store.getRecord(SESSION)!).commandsPending).toBe(true),
{ interval: 0 }
)
await act(async () => {
await vi.advanceTimersByTimeAsync(330_000)
})
const parked = calls
expect(parked).toBeGreaterThan(3)
await act(async () => {
await vi.advanceTimersByTimeAsync(100_000)
})
expect(calls).toBe(parked)
repaired = true
retireAtRestCommandSettings(['agentCmdOverrides'])
await act(async () => {
await vi.advanceTimersByTimeAsync(50)
})
await vi.waitFor(
() => expect(catalog.snapshot(store.getRecord(SESSION)!).commands?.[0]?.name).toBe('fast'),
{ interval: 0 }
)
await act(async () => {
await vi.advanceTimersByTimeAsync(50)
})
expect(calls).toBe(parked + 1)
expect(hook.commands()?.[0]?.name).toBe('fast')
} finally {
held.resolve()
hook.unmount()
unbind()
vi.restoreAllMocks()
vi.useRealTimers()
}
})
@@ -1,16 +1,24 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionSlashCommand } from '../../../shared/agent-session-wire'
export type AtRestCommandChange = { retired: (record: AgentSessionRecord) => boolean }
/** Read resting commands on their execution host; undefined until known, onChange when changed. */
export type StructuredAgentSessionAtRestCommands = {
retireContext?(workspaceId?: string): void
dispose?(): void
closeAll?(): Promise<void>
snapshot?(record: AgentSessionRecord): {
commands?: AgentSessionSlashCommand[]
commandsPending: boolean
retryableFailure?: boolean
}
read(record: AgentSessionRecord): AgentSessionSlashCommand[] | undefined
onChange(listener: () => void): () => void
onChange(listener: (change?: AtRestCommandChange) => void): () => void
/** Follow owner changes within one shared read deadline. */
settled?(
record: AgentSessionRecord,
budgetMs: number
budgetMs: number,
independent?: boolean
): Promise<AgentSessionSlashCommand[] | undefined>
}
@@ -90,7 +90,8 @@ export class StructuredAgentSessionClientDelivery {
)
this.waitForSendSettlement = this.sendSettlement.wait
this.subscribers = new AgentSessionSubscribers({
readCommands: (sessionId) => this.readCommands(sessionId),
readCommands: (sessionId, commandsOnRequest) =>
this.readCommands(sessionId, commandsOnRequest),
readQueuePublication: (sessionId) =>
tryReadQueuePublication(
sessions.get(sessionId)?.journal,
@@ -104,32 +105,60 @@ export class StructuredAgentSessionClientDelivery {
watchAtRestCommands(adapter: StructuredAgentSessionHostDeps['adapter']): void {
this.stopAtRestCommandUpdates =
adapter.atRestCommands?.onChange(() => this.subscribers.republishCommands()) ??
(() => undefined)
adapter.atRestCommands?.onChange((change) =>
this.subscribers.republishCommands(
change
? (sessionId) => {
const record = this.deps().store.getRecord(sessionId)
return record != null && change.retired(record)
}
: undefined
)
) ?? (() => undefined)
}
readCommands(sessionId: string) {
readCommands(sessionId: string, commandsOnRequest = false) {
const { adapter, store } = this.deps()
const live = adapter.readCommands?.(sessionId)
if (live !== undefined) {
return live
}
const record = store.getRecord(sessionId)
return record ? adapter.atRestCommands?.read(record) : undefined
return record
? commandsOnRequest
? adapter.atRestCommands?.snapshot?.(record).commands
: adapter.atRestCommands?.read(record)
: undefined
}
/** Wait briefly for scans because one-shot menu readers do not receive later updates. */
async readCommandsSettled(sessionId: string) {
async readCommandsSettled(sessionId: string, commandsOnRequest = false) {
const { adapter, store } = this.deps()
const live = adapter.readCommands?.(sessionId)
const record = store.getRecord(sessionId)
if (live !== undefined || !record) {
return live
return { commands: live, ...(commandsOnRequest ? { commandsPending: false } : {}) }
}
const commands = adapter.atRestCommands?.settled
? await adapter.atRestCommands.settled(record, AT_REST_COMMANDS_READ_BUDGET_MS)
? await adapter.atRestCommands.settled(
record,
AT_REST_COMMANDS_READ_BUDGET_MS,
commandsOnRequest
)
: adapter.atRestCommands?.read(record)
return adapter.readCommands?.(sessionId) ?? commands
const current = adapter.readCommands?.(sessionId)
const snapshot = commandsOnRequest ? adapter.atRestCommands?.snapshot?.(record) : undefined
if (current === undefined && snapshot?.retryableFailure) {
throw new Error('Command discovery unavailable')
}
return {
commands: current ?? (snapshot ? snapshot.commands : commands),
...(commandsOnRequest
? {
commandsPending: current === undefined && (snapshot?.commandsPending ?? false)
}
: {})
}
}
drainStatusWrites = (): Promise<void> => this.savedStatus.drain()
@@ -305,9 +305,8 @@ export class StructuredAgentSessionHost {
conversationCommand = this.conversationCommands.run
/** Undefined means unavailable; an empty array is an authoritative catalog. */
readCommands = async (sessionId: string) => ({
commands: await this.clientDelivery.readCommandsSettled(sessionId)
})
readCommands = (sessionId: string, commandsOnRequest = false) =>
this.clientDelivery.readCommandsSettled(sessionId, commandsOnRequest)
/** Read the store; idle-released chats have no session-map entry. */
handoffStatus = (sessionId: string) => structuredAgentSessionOwnerStatus(this.deps, sessionId)
@@ -3,10 +3,10 @@ export type { AgentSessionSubscribeInput } from './agent-session-subscribe-input
import type { CurrentRequestsSubscriber } from './agent-session-current-requests'
// Advance subscriber cursors independently, sending resets or reducer-projected batches as needed.
// Advance subscriber cursors independently, sending resets or reducer-projected batches as needed.
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
import type {
AgentSessionHistoryPage,
AgentSessionSlashCommand,
AgentSessionSubscribeEvent,
AgentSessionTurnActivity
} from '../../../shared/agent-session-wire'
@@ -33,27 +33,21 @@ import { refreshPublishedWork } from './agent-session-work-refresh'
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void
export type Subscriber = CurrentRequestsSubscriber & {
id: string
sessionId: string
emit: AgentSessionSubscriberEmit
cursor: AgentJournalCursor
fence: number
boundedFrames: boolean
projectQueuePublication?: SubscriberFieldState['projectQueuePublication']
snapshotCatchUp?: boolean
commands?: AgentSessionSlashCommand[] | null
/** Last sent publication; withheld pages must not suppress the final replacement. */
queuePublication?: QueuePublication
backgroundTasks?: string
/** Last frame’s current-work key; row-free changes still require a frame. */
workView?: string
/** Semantic history baseline actually emitted, independent of token/row progress. */
historyView?: string
}
export type Subscriber = CurrentRequestsSubscriber &
SubscriberFieldState & {
id: string
emit: AgentSessionSubscriberEmit
cursor: AgentJournalCursor
fence: number
snapshotCatchUp?: boolean
/** Last frame’s current-work key; row-free changes still require a frame. */
workView?: string
/** Semantic history baseline actually emitted, independent of token/row progress. */
historyView?: string
}
export type AgentSessionSubscribersHooks = {
readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
readCommands?: SubscriberFieldHooks['readCommands']
/** Stable identity avoids serialization until queue changes. */
readQueuePublication?: (sessionId: string) => QueuePublication | undefined
readBackgroundTasks?: SubscriberFieldHooks['readBackgroundTasks']
@@ -84,6 +78,7 @@ export class AgentSessionSubscribers {
): () => void {
const liveCursor = input.journal.cursor()
const subscriber: Subscriber = {
commandsOnRequest: input.commandsOnRequest,
id: input.id,
sessionId: input.sessionId,
emit: input.emit,
@@ -251,17 +246,19 @@ export class AgentSessionSubscribers {
}
}
republishCommands(): void {
republishCommands(retired?: (sessionId: string) => boolean): void {
const hostNow = this.now()
for (const sessionId of this.bySession.keys()) {
for (const subscriber of this.subscribers(sessionId)) {
if (subscriberCommandsChanged(this.hooks, subscriber)) {
const invalidated = subscriber.commandsOnRequest && retired?.(sessionId)
if (invalidated || subscriberCommandsChanged(this.hooks, subscriber)) {
this.emit(subscriber, {
type: 'batch',
sessionId,
batch: emptyAgentSessionBatch(subscriber.cursor),
fence: subscriber.fence,
hostNow
hostNow,
...(invalidated ? { commandsChanged: true as const } : {})
})
}
}
@@ -16,7 +16,10 @@ import {
} 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 {
clientRequestsCommands,
clientReadsOptionsWithoutModel
} from './structured-agent-session-policy'
import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire'
import { structuredAgentSessionOptionModels } from '../../../native-chat/agent-session-wire/structured-agent-session-option-models'
import { finishedListingIds } from '../../../../shared/structured-agent-session-options'
@@ -41,7 +44,10 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [
permission: 'workspace',
params: OptionsParams,
handler: async (params, ctx) =>
(await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(params.sessionId)
(await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(
params.sessionId,
clientRequestsCommands(ctx)
)
}),
defineMethod({
name: 'agentSession.options',
@@ -1,3 +1,4 @@
import { AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities'
import {
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY,
@@ -116,3 +117,7 @@ export function isStructuredNativeChatDefaultFor(
const settings = readAgentLaunchModeSettings(runtime)
return isTuiAgent(agent) ? isNativeChatDefaultFor(settings, agent) : isNativeChatEnabled(settings)
}
export function clientRequestsCommands(ctx: Pick<RpcContext, 'clientCapabilities'>): boolean {
return ctx.clientCapabilities?.includes(AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY) === true
}
@@ -1,3 +1,4 @@
import { AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY } from '../../../../shared/structured-agent-session-surface-capabilities'
import '../unused-default-rpc-methods.test-fixture'
import { readFileSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -575,3 +576,36 @@ describe('agentSession.subscribeTurnCompletions', () => {
expect(subscribed().map((sub) => sub.includePrompts)).toEqual([true])
})
})
it.each([false, true])(
'negotiates command demand per request and subscriber: capable=%s',
async (capable) => {
const client = {
...STRUCTURED_CLIENT,
clientCapabilities: [
...STRUCTURED_CLIENT.clientCapabilities,
...(capable ? [AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY] : [])
]
}
hostCalls.readCommands = vi.fn(async () => ({ commands: [] }))
expect(await call('agentSession.commands', { sessionId: SESSION }, client)).toMatchObject({
ok: true
})
expect(hostCalls.readCommands).toHaveBeenCalledWith(SESSION, capable)
let subscription: AgentSessionSubscribeInput | undefined
hostCalls.subscribe.mockImplementation((input: AgentSessionSubscribeInput) => {
subscription = input
input.emit({ type: 'end' })
return () => {}
})
await call('agentSession.subscribe', { sessionId: SESSION }, client)
expect(subscription?.commandsOnRequest).toBe(capable)
expect(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES).toContain(
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY
)
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY)
expect(
parseRuntimeClientCapabilities([...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES])
).toContain(AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY)
}
)
@@ -1,3 +1,4 @@
import { clientRequestsCommands } from './structured-agent-session-policy'
import {
clientReadsCurrentRequests,
STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS
@@ -255,6 +256,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
}
// Resolve after opening snapshot/missed batch; a racing close disposes the bound stream.
dispose = await host.subscribe({
commandsOnRequest: clientRequestsCommands(ctx),
readsCurrentRequests: clientReadsCurrentRequests(ctx),
id: subscriptionId,
sessionId: params.sessionId,
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import type { RefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -277,6 +278,34 @@ describe('/resume in the native chat composer', () => {
}
)
it.each([undefined, [], [{ name: 'fast', kind: 'command' as const }]])(
'saves typed provider commands with inventory %j through the inline composer',
(inventory) => {
for (const text of ['/fast', '/permissions']) {
mocks.draft = text
const editor: QueuedMessageInlineEditor = {
messageId: 'queued-provider',
draftScopeKey: 'queued-edit:provider',
text,
change: vi.fn(),
acquiring: false,
saving: false,
canSave: true,
imagesCapable: true,
save: vi.fn(),
cancel: vi.fn()
}
const { transport } = renderStructuredChat(editor)
transport.sessionCommands = inventory
act(() => mocks.fieldProps?.onSend?.())
expect(editor.save).toHaveBeenCalledOnce()
expect(transport.send).not.toHaveBeenCalled()
expect(transport.dispatchCommand).not.toHaveBeenCalled()
cleanup()
}
}
)
it('saves ordinary text and a picked provider skill without any send or command RPC', () => {
mocks.draft = '/res'
const editor: QueuedMessageInlineEditor = {
@@ -0,0 +1,23 @@
import { renderHook } from '@testing-library/react'
import type { CommandMenuRendererFixtureModule } from '../../../../shared/agent-session-command-menu.test-fixture-contract'
import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner'
import { useStructuredAgentSessionCommandMenu } from './structured-agent-session-command-refresh'
import { useStructuredAgentSessionRead } from './use-structured-agent-session-read'
export const resetReadOwners: CommandMenuRendererFixtureModule['resetReadOwners'] =
resetStructuredAgentSessionReadOwnersForTests
export const renderCommandMenu: CommandMenuRendererFixtureModule['renderCommandMenu'] = (
sessionId
) => {
const target = { kind: 'local' } as const
const hook = renderHook(() => ({
read: useStructuredAgentSessionRead({ sessionId, target, isViewed: false }),
menu: useStructuredAgentSessionCommandMenu(target, sessionId, true)
}))
return {
commands: () => hook.result.current.read.state.commands,
setOpen: (open) => hook.result.current.menu.setOpen(open),
unmount: hook.unmount
}
}
@@ -53,6 +53,7 @@ export type NativeChatStructuredComposerTransport = {
sessionCommands?: readonly AgentSessionSlashCommand[]
/** Request command refresh; updated choices arrive through sessionCommands. */
refreshCommands?: () => void
setCommandsMenuOpen?: (open: boolean) => void
/** False when the agent takes no image input, as its host registered it. */
acceptsImages?: boolean
/** The session's context usage; null until the journal can state it. */
@@ -0,0 +1,267 @@
// @vitest-environment happy-dom
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire'
import { useStructuredAgentSessionCommandMenu } from './structured-agent-session-command-refresh'
import { useStructuredAgentSessionRead } from './use-structured-agent-session-read'
import { useAppStore } from '@/store'
import {
getStructuredAgentSessionReadOwner,
resetStructuredAgentSessionReadOwnersForTests
} from './structured-agent-session-read-owner'
const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
subscribeStructuredAgentSession: mocks.subscribe
}))
const target = { kind: 'local' } as const
const otherTarget = { kind: 'environment', environmentId: 'other-host' } as const
const streams = new Map<
string,
{ event: (event: AgentSessionSubscribeEvent) => void; close: () => void }
>()
type CommandBatchFields = Pick<
Extract<AgentSessionSubscribeEvent, { type: 'batch' }>,
'commands' | 'commandsChanged'
>
const batch = (sessionId = 'one', extra: CommandBatchFields = {}): AgentSessionSubscribeEvent => ({
type: 'batch',
sessionId,
batch: { cursor: { epoch: 'e', sequence: 0 }, items: [], removedItemIds: [], submissions: [] },
...extra
})
let rejected = false
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
streams.clear()
rejected = false
mocks.call.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.commands') {
if (rejected) {
throw new Error('unreachable')
}
return { commandsPending: false }
}
return {
ok: true,
page: {
sessionId: params.sessionId,
epoch: 'e',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: { oldest: null, newest: null, nextCursor: { epoch: 'e', sequence: 0 } },
liveCursor: { epoch: 'e', sequence: 0 },
hasOlder: false,
hasNewer: false
}
}
})
mocks.subscribe.mockImplementation(async (_target, params, event, _error, close) => {
streams.set(params.sessionId, { event, close })
event(batch(params.sessionId))
return { unsubscribe: vi.fn() }
})
})
afterEach(() => {
cleanup()
resetStructuredAgentSessionReadOwnersForTests()
vi.useRealTimers()
})
const reads = () =>
mocks.call.mock.calls.filter((call) => call[1] === 'agentSession.commands').length
function mount(sessionId = 'one', enabled = true) {
return renderHook(
(props) => {
useStructuredAgentSessionRead({ sessionId: props.sessionId, target, isViewed: false })
return useStructuredAgentSessionCommandMenu(target, props.sessionId, props.enabled)
},
{ initialProps: { sessionId, enabled } }
)
}
it.each([0, 1, 2])(
'coalesces hint at position %i without transcript mutation or closed-menu work',
async (position) => {
const hook = mount()
await act(async () => {})
expect(reads()).toBe(0)
await act(async () => hook.result.current.setOpen(true))
expect(reads()).toBe(1)
await act(async () => {
for (let index = 0; index < 3; index++) {
streams.get('one')!.event(
batch('one', {
commands: null,
...(index === position ? { commandsChanged: true } : {})
})
)
}
await vi.advanceTimersByTimeAsync(49)
})
expect(reads()).toBe(2)
await act(async () => {
await vi.advanceTimersByTimeAsync(600_001)
})
expect(reads()).toBe(2)
act(() => hook.result.current.setOpen(false))
await act(async () => {
streams.get('one')!.event(batch('one', { commandsChanged: true }))
await vi.advanceTimersByTimeAsync(50)
})
expect(reads()).toBe(2)
}
)
it.each(['batch', 'snapshot'] as const)(
'wakes a parked old-host menu on resumed %s with no hint once',
async (shape) => {
const hook = mount()
await act(async () => {})
rejected = true
await act(async () => hook.result.current.setOpen(true))
await act(async () => {
await vi.advanceTimersByTimeAsync(330_000)
})
expect(reads()).toBe(3)
rejected = false
const subscribe = mocks.subscribe.getMockImplementation()!
mocks.subscribe.mockImplementation(async (...args) => {
const [_target, params, event] = args
if (shape === 'snapshot') {
const result = await mocks.call(_target, 'agentSession.history', params)
event({ type: 'snapshot', sessionId: params.sessionId, page: result.page, fence: 0 })
streams.set(params.sessionId, { event, close: args[4] })
return { unsubscribe: vi.fn() }
}
return subscribe(...args)
})
await act(async () => {
streams.get('one')!.close()
await vi.advanceTimersByTimeAsync(10_000)
})
expect(reads()).toBe(4)
await act(async () => {
await vi.advanceTimersByTimeAsync(600_000)
})
expect(reads()).toBe(4)
}
)
it('uses one delivery owner for legacy list loss and removes demand on disable, retarget and unmount', async () => {
const hook = mount()
await act(async () => {})
const owner = getStructuredAgentSessionReadOwner('one', target)
const subscribe = owner.subscribeCommandsChanged
let listeners = 0
vi.spyOn(owner, 'subscribeCommandsChanged').mockImplementation((listener) => {
listeners++
const stop = subscribe(listener)
return () => {
listeners--
stop()
}
})
await act(async () => hook.result.current.setOpen(true))
expect(listeners).toBe(1)
await act(async () => {
streams.get('one')!.event(batch('one', { commands: [{ name: 'fast', kind: 'command' }] }))
await vi.advanceTimersByTimeAsync(49)
streams.get('one')!.event(batch('one', { commands: null, commandsChanged: true }))
await vi.advanceTimersByTimeAsync(49)
})
expect(reads()).toBe(2)
hook.rerender({ sessionId: 'one', enabled: false })
expect(listeners).toBe(0)
await act(async () => {
streams.get('one')!.event(batch('one', { commandsChanged: true }))
await vi.advanceTimersByTimeAsync(49)
})
expect(reads()).toBe(2)
hook.rerender({ sessionId: 'two', enabled: true })
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
const before = reads()
await act(async () => {
streams.get('one')!.event(batch('one', { commandsChanged: true }))
await vi.advanceTimersByTimeAsync(49)
})
expect(reads()).toBe(before)
hook.unmount()
await act(async () => {
streams.get('two')!.event(batch('two', { commandsChanged: true }))
await vi.advanceTimersByTimeAsync(600_000)
})
expect(reads()).toBe(before)
})
it.each([3, 30])(
'isolates %i actual read hooks: hints do not render transcript consumers',
async (count) => {
const renders = Array(count).fill(0)
const hooks = renders.map((_, index) =>
renderHook(() => {
renders[index]++
return {
read: useStructuredAgentSessionRead({
sessionId: `chat-${index}`,
target,
isViewed: false
}),
menu: useStructuredAgentSessionCommandMenu(target, `chat-${index}`, true)
}
})
)
const otherHost = renderHook(() =>
useStructuredAgentSessionRead({
sessionId: 'other-host-chat',
target: otherTarget,
isViewed: false
})
)
await act(async () => {})
expect(reads()).toBe(0)
await act(async () => {
hooks.forEach((hook) => hook.result.current.menu.setOpen(true))
})
expect(reads()).toBe(count)
renders.fill(0)
await act(async () => {
streams
.get('other-host-chat')!
.event(batch('other-host-chat', { commands: [{ name: 'other', kind: 'command' }] }))
await vi.advanceTimersByTimeAsync(49)
})
expect(renders).toEqual(Array(count).fill(0))
otherHost.unmount()
for (let index = 0; index < count; index++) {
await act(async () =>
useAppStore.setState({ removedRuntimeEnvironmentIds: new Set([`other-host-${index}`]) })
)
expect(renders).toEqual(Array(count).fill(0))
}
for (let index = 0; index < count; index++) {
await act(async () => {
streams.get(`chat-${index}`)!.event(batch(`chat-${index}`, { commandsChanged: true }))
await vi.advanceTimersByTimeAsync(49)
})
expect(renders).toEqual(Array(count).fill(0))
expect(reads()).toBe(count + index + 1)
}
await act(async () => {
streams
.get('chat-0')!
.event(batch('chat-0', { commands: [{ name: 'fast', kind: 'command' }] }))
await vi.advanceTimersByTimeAsync(49)
})
expect(renders[0]).toBe(1)
expect(renders.slice(1)).toEqual(Array(count - 1).fill(0))
expect(hooks[0]!.result.current.read.state.commands?.[0]?.name).toBe('fast')
expect(reads()).toBe(2 * count)
}
)
@@ -1,12 +1,38 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type { AgentSessionCommandsResult } from '../../../../shared/agent-session-wire'
import { startAgentSessionCommandMenuQuery } from '../../../../shared/agent-session-command-menu-query'
import { getStructuredAgentSessionReadOwner } from './structured-agent-session-read-owner'
/** Refresh stale commands once; the stream delivers the list, so discard the RPC reply. */
export function refreshStructuredAgentSessionCommands(
export function useStructuredAgentSessionCommandMenu(
target: RuntimeClientTarget,
sessionId: string
): void {
void callStructuredAgentSession(target, 'agentSession.commands', { sessionId }).catch(
() => undefined
)
sessionId: string,
enabled: boolean
) {
const [open, setOpen] = useState(false)
const query = useRef<ReturnType<typeof startAgentSessionCommandMenuQuery> | null>(null)
useEffect(() => {
if (!enabled || !open) {
return
}
const owned = startAgentSessionCommandMenuQuery(
() =>
callStructuredAgentSession<AgentSessionCommandsResult>(target, 'agentSession.commands', {
sessionId
}),
() => {}
)
query.current = owned
const stop = getStructuredAgentSessionReadOwner(sessionId, target).subscribeCommandsChanged(
owned.invalidate
)
return () => {
stop()
query.current = null
owned.dispose()
}
}, [enabled, open, sessionId, target])
const refresh = useCallback(() => query.current?.invalidate(), [])
return { setOpen, refresh }
}
@@ -1,3 +1,4 @@
import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types'
@@ -171,8 +172,13 @@ describe('structured agent session message sender', () => {
]
] as const)('frees the chat for the next send once its send is %s', async (_how, end) => {
const calls = deferredCalls()
const a = send('a')
const a = send('/fast', { attachments: [{ path: '/shot.png', previewUri: '/shot.png' }] })
await flush()
expect(mocks.call.mock.calls[0][2]).toMatchObject({
body: structuredAgentSessionSendBody('/fast', [
{ path: '/shot.png', previewUri: '/shot.png' }
])
})
expect(structuredAgentSessionSendOut(SESSION)).toBe(true)
await end(calls, a.clientMessageId)
await a.outcome
@@ -425,7 +431,9 @@ describe('structured agent session message sender', () => {
it('never probes a host-recorded unknown, and sends what follows it', async () => {
const calls = deferredCalls()
const a = send('a')
const a = send('/permissions', {
attachments: [{ path: '/shot.png', previewUri: '/shot.png' }]
})
await flush()
// The agent died with the message on its way: the host recorded it and can't tell.
settleStructuredAgentSessionSendsFromJournal(
@@ -31,6 +31,7 @@ export type StructuredAgentSessionReadOwner = {
getSnapshot: () => StructuredAgentSessionReadSnapshot
loadOlder: () => Promise<NativeChatOlderPageResult>
subscribe: (listener: () => void) => () => void
subscribeCommandsChanged: (listener: () => void) => () => void
}
export type StructuredAgentSessionAdmission = {
@@ -51,6 +51,7 @@ export function createReadOwner(
let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead
const activations = new Map<symbol, StructuredAgentSessionReadEffects>()
const listeners = new Set<() => void>()
const commandListeners = new Set<() => void>()
let disposed = false
const activity = createStructuredAgentSessionReadActivity(
() => !disposed,
@@ -197,7 +198,15 @@ export function createReadOwner(
subscribeRetryNow: effects.subscribeRetryNow,
applyRequests: apply,
applyEvent: (event, options) => {
const lostCommands =
snapshot.state.commands != null && 'commands' in event && event.commands === null
apply({ type: 'event', event, ...options })
if (
event.type !== 'end' &&
(event.commandsChanged || options?.opensSubscription || lostCommands)
) {
commandListeners.forEach((listener) => listener())
}
observePRLinks(event, effects.getState)
},
applyError: (message, refusal) => {
@@ -225,10 +234,22 @@ export function createReadOwner(
let owner: StructuredAgentSessionReadOwner
const deleteIfUnused = (): void => {
if (activations.size === 0 && listeners.size === 0 && !activity.retained()) {
const subscriptions = activations.size + listeners.size + commandListeners.size
if (subscriptions === 0 && !activity.retained()) {
forgetStructuredReadOwner(key, owner)
}
}
const subscribeTo = (collection: Set<() => void>, listener: () => void): (() => void) => {
if (disposed) {
return () => {}
}
adoptStructuredReadOwner(key, owner)
collection.add(listener)
return () => {
collection.delete(listener)
deleteIfUnused()
}
}
owner = {
trackSend: activity.trackSend,
admit: activity.admit,
@@ -269,9 +290,11 @@ export function createReadOwner(
activity.dispose()
activations.clear()
listeners.clear()
commandListeners.clear()
stopActiveRun?.()
},
getSnapshot: () => snapshot,
subscribeCommandsChanged: (listener) => subscribeTo(commandListeners, listener),
loadOlder: createStructuredAgentSessionOlderPageLoader({
target,
sessionId,
@@ -280,17 +303,7 @@ export function createReadOwner(
apply,
captureHistoryReadGuard: () => captureActiveHistoryReadGuard()
}),
subscribe: (listener) => {
if (disposed) {
return () => {}
}
adoptStructuredReadOwner(key, owner)
listeners.add(listener)
return () => {
listeners.delete(listener)
deleteIfUnused()
}
}
subscribe: (listener) => subscribeTo(listeners, listener)
}
return owner
}
@@ -174,7 +174,7 @@ describe('structured agent-session read transport generations', () => {
expect(attempts[0].unsubscribe).toHaveBeenCalledOnce()
attempts[1].onEvent(snapshot(2))
expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2))
expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2), { opensSubscription: true })
replacement.dispose()
})
@@ -198,7 +198,7 @@ describe('structured agent-session read transport generations', () => {
expect(applyError).not.toHaveBeenCalled()
attempts[1].onEvent(snapshot(2))
expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2))
expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2), { opensSubscription: true })
attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe })
await flushPromises()
transport.dispose()
@@ -573,7 +573,9 @@ describe('structured agent-session read transport unattached refusals', () => {
expect(attempts).toHaveLength(2)
attempts[1].onEvent(snapshot(1))
await flushPromises()
expect(applyEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'snapshot' }))
expect(applyEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'snapshot' }), {
opensSubscription: true
})
transport.dispose()
} finally {
vi.useRealTimers()
@@ -17,7 +17,11 @@ type ResumeArgs = Parameters<typeof useNativeChatResumeCommand>[0]
export function useNativeChatComposerCommandPicker(
args: Omit<
PickerArgs,
'agentCommands' | 'sessionSkills' | 'onPickerOpened' | keyof ResumeArgs['editor']
| 'agentCommands'
| 'sessionSkills'
| 'onPickerOpened'
| 'onPickerDemand'
| keyof ResumeArgs['editor']
> &
Pick<ResumeArgs, 'editor' | 'setNotice'> & {
structuredTransport: NativeChatStructuredComposerTransport | undefined
@@ -43,7 +47,8 @@ export function useNativeChatComposerCommandPicker(
agentCommands,
commandAvailable,
sessionSkills,
...(structuredTransport?.refreshCommands
onPickerDemand: structuredTransport?.setCommandsMenuOpen,
...(!structuredTransport?.setCommandsMenuOpen && structuredTransport?.refreshCommands
? { onPickerOpened: structuredTransport.refreshCommands }
: {})
})
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useNativeChatPickerState } from './use-native-chat-picker-state'
@@ -54,3 +54,38 @@ describe('useNativeChatPickerState', () => {
expect(pickerMode(draft, true)).toBe('none')
})
})
it('owns demand for the actual slash-menu lifetime, including dismissal and unmount', () => {
const onPickerDemand = vi.fn()
const { result, rerender, unmount } = renderHook(
(draft: string) =>
useNativeChatPickerState({
agent: 'claude',
terminalTabId: 'tab-1',
draftScopeKey: 'pane-1',
draft,
caret: draft.length,
agentCommands: [{ name: 'clear' }],
recalledFromHistory: false,
disabled: false,
isComposing: () => false,
textareaRef: { current: null },
setDraft: vi.fn(),
setCaret: vi.fn(),
setActiveSuggestion: vi.fn(),
onPickerDemand
}),
{ initialProps: '' }
)
expect(onPickerDemand).toHaveBeenLastCalledWith(false)
rerender('/cl')
expect(onPickerDemand).toHaveBeenLastCalledWith(true)
const autocomplete = result.current.autocomplete
if (autocomplete.mode !== 'slash') {
throw new Error('Expected the slash menu')
}
act(() => result.current.dismiss(autocomplete.triggerKey))
expect(onPickerDemand).toHaveBeenLastCalledWith(false)
unmount()
expect(onPickerDemand).toHaveBeenLastCalledWith(false)
})
@@ -69,6 +69,7 @@ export function useNativeChatPickerState(args: {
sessionSkills?: readonly NativeChatSessionSkill[]
/** Refresh host catalogs on every / or $ menu opening, even in resting chats. */
onPickerOpened?: () => void
onPickerDemand?: (open: boolean) => void
textareaRef: RefObject<NativeChatComposerInput | null>
setDraft: (value: string) => void
setCaret: Dispatch<SetStateAction<number>>
@@ -87,6 +88,7 @@ export function useNativeChatPickerState(args: {
sessionSkills,
commandAvailable,
onPickerOpened,
onPickerDemand,
textareaRef,
setDraft,
setCaret,
@@ -157,6 +159,12 @@ export function useNativeChatPickerState(args: {
}
}, [agent, autocomplete, dismissalContext, onPickerOpened])
const menuOpen = autocomplete.mode === 'slash'
useEffect(() => {
onPickerDemand?.(menuOpen)
return () => onPickerDemand?.(false)
}, [dismissalContext, menuOpen, onPickerDemand])
// The editor may be ahead of this render when a key lands just before the pick.
const liveDraft = useCallback((): [string, number] => {
const input = textareaRef.current
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
// @vitest-environment happy-dom
import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation'
import {
clearNativeChatComposerDraftsForTests,
@@ -101,8 +102,37 @@ it.each([10, 100])(
expect(structuredTransport.onError).toHaveBeenCalledTimes(2)
expect(structuredTransport.onError).toHaveBeenNthCalledWith(1, null, undefined)
expect(structuredTransport.onError).toHaveBeenNthCalledWith(2, null, undefined)
expect(kinds).toBeGreaterThanOrEqual(count)
expect(names).toBeGreaterThanOrEqual(count)
expect({ kinds, names }).toEqual({ kinds: 0, names: 0 })
}
)
it.each([
undefined,
[],
[
{ name: 'fast', kind: 'command' as const },
{ name: 'permissions', kind: 'command' as const }
]
])(
'sends provider commands with attachments under inventory %j and retains refused drafts',
async (inventory) => {
for (const text of ['/fast', '/permissions']) {
for (const accepted of [true, false]) {
clearNativeChatComposerDraftsForTests()
const { send, structuredTransport, setDraft } = harness('codex')
structuredTransport.sessionCommands = inventory
vi.mocked(structuredTransport.send).mockReturnValue(accepted)
updateNativeChatComposerDraft('tab-1:pane', { text, images: [ATTACHMENT] }, 'immediate')
await send(text)
expect(structuredTransport.send).toHaveBeenCalledExactlyOnceWith(text, [ATTACHMENT])
if (accepted) {
expect(setDraft).toHaveBeenCalledWith('')
} else {
expect(setDraft).not.toHaveBeenCalled()
expect(readNativeChatComposerDraft('tab-1:pane').text).toBe(text)
}
}
}
}
)
@@ -110,8 +140,7 @@ it.each([10, 100])(
describe('attachment guard follows what the host claims', () => {
it.each([
['claude', '/clear'],
['claude', '/model'],
['codex', '/permissions']
['claude', '/model']
] as const)('refuses attachments on the host-claimed %s command %s', (agent, text) => {
const { send, structuredTransport } = harness(agent)
send(text)
@@ -122,6 +151,8 @@ describe('attachment guard follows what the host claims', () => {
})
it.each([
['codex', '/permissions'],
['claude', '/fast'],
['claude', '/init'],
['claude', '/review'],
['codex', '/goal ship the fix']
@@ -178,17 +209,12 @@ describe('reports the sends that bring the latest into view', () => {
expect(onSubmitted).toHaveBeenCalledOnce()
})
it('reports nothing for a host command chat sessions do not run', async () => {
it('reports a provider command sent without an inventory', async () => {
const onSubmitted = vi.fn()
const { send, structuredTransport } = harness('codex', undefined, onSubmitted)
send('/permissions', [])
await vi.waitFor(() =>
expect(structuredTransport.onError).toHaveBeenCalledWith(
expect.stringContaining('not available in chat sessions'),
undefined
)
)
expect(onSubmitted).not.toHaveBeenCalled()
await send('/permissions', [])
expect(structuredTransport.send).toHaveBeenCalledWith('/permissions', [])
expect(onSubmitted).toHaveBeenCalledOnce()
})
})
@@ -101,6 +101,7 @@ export function useNativeChatStructuredComposerTransport(args: {
optionPickerRequest,
sessionCommands: controller.sessionCommands,
refreshCommands: controller.refreshCommands,
setCommandsMenuOpen: controller.setCommandsMenuOpen,
acceptsImages,
contextUsage: controller.contextUsage,
worktreeId,
@@ -1,6 +1,6 @@
import { useQueuedMessageEditTransport } from './use-queued-message-edit-transport'
import type { StructuredAgentSessionViewArgs } from './structured-agent-session-view-contract'
import { refreshStructuredAgentSessionCommands } from './structured-agent-session-command-refresh'
import { useStructuredAgentSessionCommandMenu } from './structured-agent-session-command-refresh'
import { useMemo, useRef } from 'react'
import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options'
import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends'
@@ -72,6 +72,11 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs)
enabled: transportEnabled,
agentName: structuredAgentLabel(agent)
})
const commandMenu = useStructuredAgentSessionCommandMenu(
target,
sessionId,
transportEnabled && args.isVisible !== false
)
const commandPending = useRef(false)
const legacyLaunchStop = useStructuredLegacyLaunchStop(sessionId)
const transportState = useStructuredAgentSessionTransportState(state, transportEnabled)
@@ -285,7 +290,8 @@ export function useStructuredAgentSession(args: StructuredAgentSessionViewArgs)
optionSnapshot,
optionSurface,
sessionCommands: transportEnabled ? (state.commands ?? undefined) : undefined,
refreshCommands: () => refreshStructuredAgentSessionCommands(target, sessionId),
refreshCommands: commandMenu.refresh,
setCommandsMenuOpen: commandMenu.setOpen,
setStructuredOption,
/** Why the host's catalog probe says no chat can start here; the chat shows it as a notice. */
unavailable,
@@ -0,0 +1,100 @@
import { afterEach, expect, it, vi } from 'vitest'
import { startAgentSessionCommandMenuQuery } from './agent-session-command-menu-query'
afterEach(() => vi.useRealTimers())
it.each(['completion', 'lost-hint', 'lost-reply'])(
'recovers slow commands after a %s without closed-menu demand',
async (mode) => {
vi.useFakeTimers()
const commands = [{ name: 'fast', kind: 'command' as const }]
let ready = false
const read = vi.fn(async () => {
if (mode === 'lost-reply' && read.mock.calls.length === 1) {
throw new Error('lost reply')
}
return ready ? { commands, commandsPending: false } : { commandsPending: true }
})
const accept = vi.fn()
const query = startAgentSessionCommandMenuQuery(read, accept)
try {
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(2_000)
ready = true
if (mode === 'completion') {
query.invalidate()
}
await vi.advanceTimersByTimeAsync(mode === 'completion' ? 0 : 8_000)
expect(accept).toHaveBeenLastCalledWith({ commands, commandsPending: false })
expect(read).toHaveBeenCalledTimes(2)
query.dispose()
query.invalidate()
await vi.advanceTimersByTimeAsync(330_000)
expect(read).toHaveBeenCalledTimes(2)
} finally {
query.dispose()
}
}
)
it('coalesces invalidation, discards late answers and bounds failed recovery', async () => {
vi.useFakeTimers()
const pending = Promise.withResolvers<{ commandsPending: boolean }>()
const read = vi.fn(() => pending.promise)
const accept = vi.fn()
const query = startAgentSessionCommandMenuQuery(read, accept)
query.invalidate()
query.invalidate()
query.dispose()
pending.resolve({ commandsPending: false })
await vi.advanceTimersByTimeAsync(330_000)
expect(read).toHaveBeenCalledTimes(1)
expect(accept).not.toHaveBeenCalled()
const fail = vi.fn(async () => {
throw new Error('transport lost')
})
const failed = startAgentSessionCommandMenuQuery(fail, accept)
try {
await vi.advanceTimersByTimeAsync(330_000)
expect(fail).toHaveBeenCalledTimes(3)
} finally {
failed.dispose()
}
})
it.each([undefined, [], [{ name: 'fast', kind: 'command' as const }]])(
'leaves settled %j demanded without a timer, then accepts a real wake',
async (commands) => {
vi.useFakeTimers()
const read = vi.fn(async () => ({ commands, commandsPending: false }))
const query = startAgentSessionCommandMenuQuery(read, vi.fn())
await vi.advanceTimersByTimeAsync(600_001)
expect(read).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
query.invalidate()
await vi.advanceTimersByTimeAsync(0)
expect(read).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
query.dispose()
}
)
it('parks actual pending work at the deadline with one outstanding read and one dirty follow-up', async () => {
vi.useFakeTimers()
const held = Promise.withResolvers<{ commandsPending: boolean }>()
const read = vi.fn(() =>
read.mock.calls.length === 1 ? held.promise : Promise.resolve({ commandsPending: true })
)
const query = startAgentSessionCommandMenuQuery(read, vi.fn())
query.invalidate()
query.invalidate()
await vi.advanceTimersByTimeAsync(20_000)
expect(read).toHaveBeenCalledTimes(1)
held.resolve({ commandsPending: true })
await vi.advanceTimersByTimeAsync(0)
expect(read).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(600_000)
expect(read).toHaveBeenCalledTimes(32)
expect(vi.getTimerCount()).toBe(0)
query.dispose()
})
@@ -0,0 +1,69 @@
import type { AgentSessionCommandsResult } from './agent-session-wire'
/** Own recovery only while this target's menu is demanded. */
export function startAgentSessionCommandMenuQuery(
read: () => Promise<AgentSessionCommandsResult>,
accept: (result: AgentSessionCommandsResult) => void
): { invalidate(): void; dispose(): void } {
let stopped = false
let running = false
let dirty = false
let failures = 0
let deadline = Date.now() + 330_000
let timer: ReturnType<typeof setTimeout> | undefined
const schedule = (delay: number): void => {
clearTimeout(timer)
if (!stopped && Date.now() + delay < deadline) {
timer = setTimeout(() => void run(), delay)
}
}
const run = async (): Promise<void> => {
if (stopped || Date.now() >= deadline) {
return
}
running = true
dirty = false
let delay: number | undefined
try {
const result = await read()
if (stopped || Date.now() >= deadline) {
return
}
accept(result)
failures = 0
if (result.commandsPending) {
delay = 10_000
}
} catch {
if (++failures < 3) {
delay = 10_000
}
} finally {
running = false
if (dirty) {
schedule(0)
} else if (delay !== undefined) {
schedule(delay)
}
}
}
void run()
return {
invalidate() {
if (stopped) {
return
}
failures = 0
deadline = Date.now() + 330_000
dirty = true
if (!running) {
schedule(0)
}
},
dispose() {
stopped = true
dirty = false
clearTimeout(timer)
}
}
}
@@ -0,0 +1,10 @@
import type { AgentSessionSlashCommand } from './agent-session-wire'
export type CommandMenuRendererFixtureModule = {
renderCommandMenu: (sessionId: string) => {
commands: () => AgentSessionSlashCommand[] | null | undefined
setOpen: (open: boolean) => void
unmount: () => void
}
resetReadOwners: () => void
}
+1
View File
@@ -3,6 +3,7 @@ import type { AgentSessionQueuePublicationFields } from './agent-session-queued-
/** Published-frame host wall clock (epoch ms per AgentSessionHistoryPage) and queuedMessages companions. */
export type AgentSessionFrameFields = {
commandsChanged?: true
hostNow?: number
/** Version of the independent current-request read; absent from older hosts. */
currentRequests?: AgentSessionCurrentRequestsMarker
+4 -1
View File
@@ -429,7 +429,10 @@ export type AgentSessionSlashCommand = {
}
/** Per-session provider commands; older hosts return method_not_found and clients retain their curated catalog. */
export type AgentSessionCommandsResult = { commands?: AgentSessionSlashCommand[] }
export type AgentSessionCommandsResult = {
commands?: AgentSessionSlashCommand[]
commandsPending?: boolean
}
export type AgentSessionContinuationContext =
| { status: 'current'; capturedText?: string; transcriptPath?: string }
@@ -1,6 +1,11 @@
import type { AgentJournalMessageItem } from './agent-session-journal-types'
import type { AgentSessionExecutionOptions } from './agent-session-execution-options'
import type { AgentSessionQueuedMessage, AgentSessionSlashCommand } from './agent-session-wire'
import type {
AgentSessionQueuedMessage,
AgentSessionSlashCommand,
AgentSessionCommandsResult,
AgentSessionSubscribeEvent
} from './agent-session-wire'
type StoredQueuedMessage = {
messageId: string
@@ -31,3 +36,26 @@ export type AcpCommandOptionsFixtureModule = {
readCommands: () => AgentSessionSlashCommand[] | undefined
}
}
export type MobileCommandMenuHostFixtureModule = {
createMobileCommandMenuHostFixture: (
provider: 'claude' | 'omp',
holdPreparation?: boolean,
population?: number
) => Promise<{
sessionId: string
sessionIds: readonly string[]
preparations: () => number
pending: (sessionId: string) => boolean
releasePreparation: () => void
request: (
sessionId: string,
clientCapabilities: readonly string[]
) => Promise<AgentSessionCommandsResult>
starts: () => number
read: () => Promise<AgentSessionCommandsResult>
subscribe: (emit: (event: AgentSessionSubscribeEvent) => void) => Promise<() => void>
retire: (scoped: boolean) => Promise<void>
dispose: () => Promise<void>
}>
}
@@ -41,6 +41,7 @@ function mergeBatch(
const working = hostWorkingAfterStructuredAgentSessionBatch(left.working, right)
return {
type: 'batch',
...(left.commandsChanged || right.commandsChanged ? { commandsChanged: true as const } : {}),
...(right.commands !== undefined || left.commands !== undefined
? { commands: right.commands !== undefined ? right.commands : left.commands }
: {}),
@@ -1,3 +1,4 @@
import { queuedMessageEditAcceptsText } from './queued-message-edit-command'
import { describe, expect, it, vi } from 'vitest'
import {
dispatchStructuredAgentSessionComposerCommand,
@@ -158,7 +159,7 @@ describe('agent-implemented commands pass through to the agent', () => {
{ name: 'fast', kind: 'command' },
{ name: 'compact', kind: 'command' }
] as const
expect(isStructuredAgentSessionComposerCommand('/fast', 'omp')).toBe(true)
expect(isStructuredAgentSessionComposerCommand('/fast', 'omp')).toBe(false)
expect(isStructuredAgentSessionComposerCommand('/fast', 'omp', sessionCommands)).toBe(false)
expect(
await dispatchStructuredAgentSessionComposerCommand('/fast status', {
@@ -171,17 +172,31 @@ describe('agent-implemented commands pass through to the agent', () => {
expect(isStructuredAgentSessionComposerCommand('/compact', 'omp', sessionCommands)).toBe(true)
})
it('keeps refusing a Codex command the model cannot carry out', async () => {
expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true)
expect(
await dispatchStructuredAgentSessionComposerCommand('/permissions', {
...controller,
agent: 'codex'
})
).toMatchObject({
handled: true,
error:
'/permissions is not available in chat sessions. Use the slash menu to see available commands.'
})
it('leaves provider text independent of the optional inventory', async () => {
for (const sessionCommands of [
undefined,
[],
[{ name: 'permissions', kind: 'command' as const }]
]) {
expect(queuedMessageEditAcceptsText('/fast status', 'omp', sessionCommands)).toBe(true)
expect(queuedMessageEditAcceptsText('/clear', 'omp', sessionCommands)).toBe(false)
expect(
isStructuredAgentSessionComposerCommand('/permissions', 'codex', sessionCommands)
).toBe(false)
expect(
await dispatchStructuredAgentSessionComposerCommand('/permissions', {
...controller,
agent: 'codex',
sessionCommands
})
).toEqual(PASSED_THROUGH)
expect(
await dispatchStructuredAgentSessionComposerCommand('/fast status', {
...controller,
agent: 'omp',
sessionCommands
})
).toEqual(PASSED_THROUGH)
}
})
})
@@ -1,7 +1,4 @@
import {
getHostClaimedNativeChatCommands,
getTextDrivenNativeChatCommands
} from './native-chat-agent-profiles'
import { getTextDrivenNativeChatCommands } from './native-chat-agent-profiles'
import type { AgentType } from './agent-status-types'
import type {
SessionOptionDescriptor,
@@ -119,20 +116,8 @@ export function structuredSessionSlashCommands(
]
}
/** Claim hidden TUI-only commands too, preventing literal prose delivery; leave agent-handled
* and session-reported commands unclaimed for passthrough. */
function structuredRecognizedCommands(
agent: AgentType,
reported: readonly AgentSessionSlashCommand[] | undefined
): readonly SlashCommandSuggestion[] {
const sessionHandled = new Set(
reported?.filter((entry) => entry.kind === 'command').map((entry) => entry.name)
)
return [
...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS,
...CONVERSATION_COMMANDS,
...getHostClaimedNativeChatCommands(agent).filter((entry) => !sessionHandled.has(entry.name))
]
function structuredRecognizedCommands(): readonly SlashCommandSuggestion[] {
return [...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS]
}
/** Whether the chat host, rather than the agent, owns this command. Callers also
@@ -140,21 +125,20 @@ function structuredRecognizedCommands(
* would be silently dropped, whereas a pass-through command is a real send. */
export function isStructuredAgentSessionComposerCommand(
text: string,
agent: AgentType = 'codex',
reported?: readonly AgentSessionSlashCommand[]
_agent: AgentType = 'codex',
_reported?: readonly AgentSessionSlashCommand[]
): boolean {
const command = commandParts(text)
return Boolean(
command &&
structuredRecognizedCommands(agent, reported).some((entry) => entry.name === command.name)
command && structuredRecognizedCommands().some((entry) => entry.name === command.name)
)
}
export function prepareStructuredAgentSessionComposerCommand(
agent: AgentType,
reported?: readonly AgentSessionSlashCommand[]
_agent: AgentType,
_reported?: readonly AgentSessionSlashCommand[]
): (text: string) => boolean {
const names = new Set(structuredRecognizedCommands(agent, reported).map((entry) => entry.name))
const names = new Set(structuredRecognizedCommands().map((entry) => entry.name))
return (text) => {
const command = commandParts(text)
return command !== null && names.has(command.name)
@@ -164,7 +164,7 @@ export function startStructuredAgentSessionLiveRead(args: {
openingFrameGeneration = 0
}
// First resumed batch replaces detached state; apply alone so coalescing cannot turn it into unchanged state.
if (opensSubscription && event.type === 'batch') {
if (opensSubscription) {
coalescer.flush()
if (isCurrentOpenGeneration(eventOpenGeneration)) {
args.applyEvent(event, { opensSubscription: true })
@@ -1,4 +1,7 @@
import { AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY } from './structured-agent-session-surface-capabilities'
import {
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY,
AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY
} from './structured-agent-session-surface-capabilities'
import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from './agent-session-current-requests-capability'
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from './agent-session-create-capabilities'
@@ -20,6 +23,7 @@ import {
/** Desktop/browser chats share one handshake list; separate copies once omitted structured.v1 in browsers and made hosts refuse every chat. */
export const STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES = [
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
// Render every agent listed by the owning host.
@@ -36,7 +36,11 @@ export const STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY =
export const AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY =
'agent-session.coherent-model-catalog.v1' as const
export const AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY =
'agent-session.commands-on-request.v1' as const
export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [
AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY,
AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY,
AGENT_SESSION_COHERENT_MODEL_CATALOG_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,