mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 })
|
||||
|
||||
+12
-2
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
+423
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
+233
@@ -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
|
||||
}
|
||||
+583
-146
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
+10
-2
@@ -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>
|
||||
}
|
||||
|
||||
+38
-9
@@ -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. */
|
||||
|
||||
+267
@@ -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)
|
||||
}
|
||||
)
|
||||
+33
-7
@@ -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 }
|
||||
}
|
||||
|
||||
+10
-2
@@ -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(
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ export type StructuredAgentSessionReadOwner = {
|
||||
getSnapshot: () => StructuredAgentSessionReadSnapshot
|
||||
loadOlder: () => Promise<NativeChatOlderPageResult>
|
||||
subscribe: (listener: () => void) => () => void
|
||||
subscribeCommandsChanged: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionAdmission = {
|
||||
|
||||
+25
-12
@@ -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
|
||||
}
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+39
-13
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user