From e08cb10cc6f139f1342c2d54d82c592816a68ca2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:16:35 -0700 Subject: [PATCH] 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 --- .../mobile-queued-edit-composer.test.tsx | 42 +- ...obile-structured-agent-session-contract.ts | 1 + ...e-structured-agent-session-host-support.ts | 7 +- ...mobile-structured-composer-command.test.ts | 7 +- ...obileNativeChatComposer.structured.test.ts | 5 +- .../composer/MobileNativeChatComposer.tsx | 2 + .../use-mobile-native-chat-composer-menu.ts | 7 + ...-mobile-native-chat-structured-composer.ts | 14 +- ...ile-native-chat-capability-demand.test.tsx | 490 ++++++++++++ .../use-mobile-native-chat-session-lane.ts | 4 +- ...ile-structured-agent-session-send.test.tsx | 3 +- .../use-mobile-structured-agent-session.ts | 5 +- .../use-mobile-structured-agent-state.ts | 8 + ...structured-native-chat-send-bridge.test.ts | 30 +- ...obile-structured-session-commands.test.tsx | 375 ++++++++- .../use-mobile-structured-session-commands.ts | 86 ++- ...mobile-runtime-client-capabilities.test.ts | 6 + .../mobile-runtime-client-capabilities.ts | 6 +- src/main/acp/acp-at-rest-commands.test.ts | 30 + src/main/acp/acp-at-rest-commands.ts | 23 +- src/main/claude/claude-at-rest-commands.ts | 12 +- ...claude-structured-session-commands.test.ts | 55 ++ .../agent-session-subscribe-input.ts | 1 + .../agent-session-subscriber-frame-fields.ts | 33 +- .../at-rest-command-catalog.ts | 80 +- .../at-rest-command-demand.test.ts | 51 ++ .../at-rest-command-demand.ts | 37 + .../mobile-queued-edit-host.test-fixture.ts | 177 ++++- ...structured-agent-session-adapter-router.ts | 8 +- ...session-at-rest-command-population.test.ts | 423 ++++++++++ ...ession-at-rest-command-rig.test-fixture.ts | 233 ++++++ ...red-agent-session-at-rest-commands.test.ts | 729 ++++++++++++++---- ...ructured-agent-session-at-rest-commands.ts | 12 +- ...tructured-agent-session-client-delivery.ts | 47 +- .../structured-agent-session-host.ts | 5 +- .../structured-agent-session-subscribers.ts | 43 +- .../structured-agent-session-options-read.ts | 10 +- .../structured-agent-session-policy.ts | 5 + ...tured-agent-session-wire-contracts.test.ts | 34 + .../rpc/methods/structured-agent-session.ts | 2 + ...NativeChatComposer.resume-command.test.tsx | 29 + ...gent-session-command-menu.test-fixture.tsx | 23 + .../native-chat/native-chat-composer-types.ts | 1 + ...red-agent-session-command-refresh.test.tsx | 267 +++++++ ...tructured-agent-session-command-refresh.ts | 40 +- ...tured-agent-session-message-sender.test.ts | 12 +- ...tured-agent-session-read-owner-contract.ts | 1 + ...uctured-agent-session-read-owner-create.ts | 37 +- ...tured-agent-session-read-transport.test.ts | 8 +- ...use-native-chat-composer-command-picker.ts | 9 +- .../use-native-chat-picker-state.test.tsx | 37 +- .../use-native-chat-picker-state.ts | 8 + ...ive-chat-structured-composer-send.test.tsx | 52 +- ...tive-chat-structured-composer-transport.ts | 1 + .../use-structured-agent-session.ts | 10 +- .../agent-session-command-menu-query.test.ts | 100 +++ .../agent-session-command-menu-query.ts | 69 ++ ...sion-command-menu.test-fixture-contract.ts | 10 + src/shared/agent-session-frame-fields.ts | 1 + src/shared/agent-session-wire.ts | 5 +- ...-queued-edit-host.test-fixture-contract.ts | 30 +- .../structured-agent-session-coalescer.ts | 1 + .../structured-agent-session-composer.test.ts | 41 +- .../structured-agent-session-composer.ts | 34 +- .../structured-agent-session-live-read.ts | 2 +- ...nt-session-renderer-client-capabilities.ts | 6 +- ...ured-agent-session-surface-capabilities.ts | 4 + 67 files changed, 3639 insertions(+), 347 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-capability-demand.test.tsx create mode 100644 src/main/native-chat/agent-session-wire/at-rest-command-demand.test.ts create mode 100644 src/main/native-chat/agent-session-wire/at-rest-command-demand.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-population.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-rig.test-fixture.ts create mode 100644 src/renderer/src/components/native-chat/agent-session-command-menu.test-fixture.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-command-refresh.test.tsx create mode 100644 src/shared/agent-session-command-menu-query.test.ts create mode 100644 src/shared/agent-session-command-menu-query.ts create mode 100644 src/shared/agent-session-command-menu.test-fixture-contract.ts diff --git a/mobile/src/session/mobile-queued-edit-composer.test.tsx b/mobile/src/session/mobile-queued-edit-composer.test.tsx index fed416bc0f0..4f3c6da9170 100644 --- a/mobile/src/session/mobile-queued-edit-composer.test.tsx +++ b/mobile/src/session/mobile-queued-edit-composer.test.tsx @@ -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' }, diff --git a/mobile/src/session/mobile-structured-agent-session-contract.ts b/mobile/src/session/mobile-structured-agent-session-contract.ts index f0d476ab2d4..16ff9076a68 100644 --- a/mobile/src/session/mobile-structured-agent-session-contract.ts +++ b/mobile/src/session/mobile-structured-agent-session-contract.ts @@ -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 diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 5af99dc6641..9f84b9ee0c8 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -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), diff --git a/mobile/src/session/mobile-structured-composer-command.test.ts b/mobile/src/session/mobile-structured-composer-command.test.ts index 42c2f940ca4..e1e6288060b 100644 --- a/mobile/src/session/mobile-structured-composer-command.test.ts +++ b/mobile/src/session/mobile-structured-composer-command.test.ts @@ -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() diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts index 76d501efbfb..bc1fcbfeee4 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.structured.test.ts @@ -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')) diff --git a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx index cf50f00b43e..92dee1b6ebc 100644 --- a/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx +++ b/mobile/src/session/native-chat/composer/MobileNativeChatComposer.tsx @@ -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({ diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts index 73cf0040305..8b6bdd7aabe 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-composer-menu.ts @@ -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 }) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts index a538f8106ac..979e7759634 100644 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts +++ b/mobile/src/session/native-chat/composer/use-mobile-native-chat-structured-composer.ts @@ -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 + ] ) } diff --git a/mobile/src/session/use-mobile-native-chat-capability-demand.test.tsx b/mobile/src/session/use-mobile-native-chat-capability-demand.test.tsx new file mode 100644 index 00000000000..47aa393a99b --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-capability-demand.test.tsx @@ -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( + '../../../src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture' + ) +type Host = Awaited> +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>[] = [] + let statusFailure: 'refusal' | 'timeout' | undefined, + rejectCommands = false + let responseGate: Promise | undefined + const send = vi.fn(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() + 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) => { + responseGate = gate + } + } +} + +async function mounted(initial: ReturnType, sessionId: string, connected = true) { + let props: { + peer: ReturnType + 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>() } + } satisfies Partial[0]> + let current: + | { + support: ReturnType['agentSessionHostSupport'] + menu: ReturnType + commands: ReturnType['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() }, + registerTerminalInventoryRecoveryAction: () => () => {} + } satisfies Partial[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 = {}) => { + 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>) => { + 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() + 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>[] = [] + 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() + } + } +) diff --git a/mobile/src/session/use-mobile-native-chat-session-lane.ts b/mobile/src/session/use-mobile-native-chat-session-lane.ts index 0d12a6a3754..5495528e9d0 100644 --- a/mobile/src/session/use-mobile-native-chat-session-lane.ts +++ b/mobile/src/session/use-mobile-native-chat-session-lane.ts @@ -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 } : {}) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index be178992178..a80642c23db 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -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 }) } ) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b721ac9f501..efb030ec149 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -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>()) - const stateArgs = { client, sessionId, sessionKey, enabled, connected } + const stateArgs = { client, sessionId, sessionKey, enabled, connected, onCommandsChanged } const { state, stateRef, queuedMessages, queuePause, olderPages, loadEarlier } = useMobileStructuredAgentState(stateArgs) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 9b1e9221218..4948076fc64 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -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>( () => 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) { diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index d93a9cc5127..75f61873e36 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -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 + let sendWithOutcome: ReturnType['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() + } + } + } + ) }) diff --git a/mobile/src/session/use-mobile-structured-session-commands.test.tsx b/mobile/src/session/use-mobile-structured-session-commands.test.tsx index 9dd62c6159e..e500b36cb0d 100644 --- a/mobile/src/session/use-mobile-structured-session-commands.test.tsx +++ b/mobile/src/session/use-mobile-structured-session-commands.test.tsx @@ -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( + '../../../src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture' + ) + type Hook = ReturnType 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((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 | undefined + let frame: (value: unknown) => void = () => {} + const sendRequest = vi.fn(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() + 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(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() + } + } + ) }) diff --git a/mobile/src/session/use-mobile-structured-session-commands.ts b/mobile/src/session/use-mobile-structured-session-commands.ts index b90d80fe230..86cb1647825 100644 --- a/mobile/src/session/use-mobile-structured-session-commands.ts +++ b/mobile/src/session/use-mobile-structured-session-commands.ts @@ -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 | null>(null) const [reported, setReported] = useState(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 } } diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index 1fa14da6fb4..e0375eca122 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -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 diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 92e0953a9fc..5bb7195022f 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -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, diff --git a/src/main/acp/acp-at-rest-commands.test.ts b/src/main/acp/acp-at-rest-commands.test.ts index 2974cf29023..fdffa6f7623 100644 --- a/src/main/acp/acp-at-rest-commands.test.ts +++ b/src/main/acp/acp-at-rest-commands.test.ts @@ -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() + 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() + } +}) diff --git a/src/main/acp/acp-at-rest-commands.ts b/src/main/acp/acp-at-rest-commands.ts index 1243718ea6f..e4b9a8e3417 100644 --- a/src/main/acp/acp-at-rest-commands.ts +++ b/src/main/acp/acp-at-rest-commands.ts @@ -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> | undefined diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts index 1defb782674..884112b0928 100644 --- a/src/main/claude/claude-at-rest-commands.ts +++ b/src/main/claude/claude-at-rest-commands.ts @@ -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 { 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 } diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index ae7e0fc11ec..42ecba76049 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -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() + 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((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() + } +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscribe-input.ts b/src/main/native-chat/agent-session-wire/agent-session-subscribe-input.ts index 15a7c71244e..d1d6376352b 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscribe-input.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscribe-input.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts index 265ab7a7ce2..731cede4042 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-frame-fields.ts @@ -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 ) } diff --git a/src/main/native-chat/agent-session-wire/at-rest-command-catalog.ts b/src/main/native-chat/agent-session-wire/at-rest-command-catalog.ts index 681fff59cdc..55b5503a828 100644 --- a/src/main/native-chat/agent-session-wire/at-rest-command-catalog.ts +++ b/src/main/native-chat/agent-session-wire/at-rest-command-catalog.ts @@ -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 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() - 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) + } } diff --git a/src/main/native-chat/agent-session-wire/at-rest-command-demand.test.ts b/src/main/native-chat/agent-session-wire/at-rest-command-demand.test.ts new file mode 100644 index 00000000000..849f5fb4e69 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/at-rest-command-demand.test.ts @@ -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)() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/at-rest-command-demand.ts b/src/main/native-chat/agent-session-wire/at-rest-command-demand.ts new file mode 100644 index 00000000000..ff1226fc69f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/at-rest-command-demand.ts @@ -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) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts b/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts index 1233b7ce2c8..da9f3f0b49b 100644 --- a/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/mobile-queued-edit-host.test-fixture.ts @@ -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() + 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 }) + } + } + } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 42e61b2b3b2..9751fe523fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -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) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-population.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-population.test.ts new file mode 100644 index 00000000000..8e38a464924 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-population.test.ts @@ -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 | undefined + let releaseCleanup = () => {} + let selected = account, + cwd = workspace + const roots: CommandDiscoveryChild[] = [] + let rootPeak = 0, + signals = 0 + const gates: ReturnType>[] = [] + 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() + 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() + 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() + } + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-rig.test-fixture.ts new file mode 100644 index 00000000000..a1fd41e8cf5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-command-rig.test-fixture.ts @@ -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( + '../../../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 { + 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 5c88d1da6b4..08521499f64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -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 -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 { - 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 { - 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 { - 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 { - 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() 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() + const entered = Promise.withResolvers() + 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 | undefined, + entered: ReturnType> | 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() + const live = vi.spyOn(deliveryAdapter, 'readCommands').mockReturnValue(undefined) + scanGate = held.promise + entered = Promise.withResolvers() + 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 | undefined + const entered = Promise.withResolvers(), + held = Promise.withResolvers() + 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(() => []) + 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() + 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(), + stale = Promise.withResolvers() + 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() + 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() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts index 5fb65915a58..6f2e861a43a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.ts @@ -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 + 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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 1e7c787f0d7..27f3d5e0146 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -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 => this.savedStatus.drain() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b00c7dd5be8..0d91fe94820 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index fc1a080a22d..7fcfd946974 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -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 } : {}) }) } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 22c50710e2e..6f9717c0df4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 38f8a6ec21f..115b4a4b329 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -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): boolean { + return ctx.clientCapabilities?.includes(AGENT_SESSION_COMMANDS_ON_REQUEST_CAPABILITY) === true +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts index 3a91463bd3c..c4f4fbefe10 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts @@ -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) + } +) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index de829e50725..9c295ea7b03 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -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, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx index 5c687735b47..d1e913bb8b3 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.resume-command.test.tsx @@ -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 = { diff --git a/src/renderer/src/components/native-chat/agent-session-command-menu.test-fixture.tsx b/src/renderer/src/components/native-chat/agent-session-command-menu.test-fixture.tsx new file mode 100644 index 00000000000..37afba9a1af --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-command-menu.test-fixture.tsx @@ -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 + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index b36a774c5f0..bc0f0d1ede1 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -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. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.test.tsx new file mode 100644 index 00000000000..cdf83f15877 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.test.tsx @@ -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, + '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) + } +) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.ts b/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.ts index 96f8f071c3b..fea9595705d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-command-refresh.ts @@ -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 | null>(null) + useEffect(() => { + if (!enabled || !open) { + return + } + const owned = startAgentSessionCommandMenuQuery( + () => + callStructuredAgentSession(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 } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts index af29654dd98..9b628b3a75f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-contract.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-contract.ts index 837ef3331f2..7cd554a586b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-contract.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-contract.ts @@ -31,6 +31,7 @@ export type StructuredAgentSessionReadOwner = { getSnapshot: () => StructuredAgentSessionReadSnapshot loadOlder: () => Promise subscribe: (listener: () => void) => () => void + subscribeCommandsChanged: (listener: () => void) => () => void } export type StructuredAgentSessionAdmission = { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-create.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-create.ts index b88ded97cd2..6633e49383a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-create.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-create.ts @@ -51,6 +51,7 @@ export function createReadOwner( let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead const activations = new Map() 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 } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 420b3594be1..60e81867a5c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -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() diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts index a1b07fa0221..ae81ab0ed4c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-command-picker.ts @@ -17,7 +17,11 @@ type ResumeArgs = Parameters[0] export function useNativeChatComposerCommandPicker( args: Omit< PickerArgs, - 'agentCommands' | 'sessionSkills' | 'onPickerOpened' | keyof ResumeArgs['editor'] + | 'agentCommands' + | 'sessionSkills' + | 'onPickerOpened' + | 'onPickerDemand' + | keyof ResumeArgs['editor'] > & Pick & { 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 } : {}) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx index 960f83ef07a..eceb210dd65 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.test.tsx @@ -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) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts index 34de28f46e0..2d7a235a48f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -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 setDraft: (value: string) => void setCaret: Dispatch> @@ -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 diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 1b695af3a98..decefd91622 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -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() }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index f1548c47bc2..48ecd379d4f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -101,6 +101,7 @@ export function useNativeChatStructuredComposerTransport(args: { optionPickerRequest, sessionCommands: controller.sessionCommands, refreshCommands: controller.refreshCommands, + setCommandsMenuOpen: controller.setCommandsMenuOpen, acceptsImages, contextUsage: controller.contextUsage, worktreeId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 98c3ba592ae..9d617b0b45b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -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, diff --git a/src/shared/agent-session-command-menu-query.test.ts b/src/shared/agent-session-command-menu-query.test.ts new file mode 100644 index 00000000000..b5ace752b58 --- /dev/null +++ b/src/shared/agent-session-command-menu-query.test.ts @@ -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() +}) diff --git a/src/shared/agent-session-command-menu-query.ts b/src/shared/agent-session-command-menu-query.ts new file mode 100644 index 00000000000..637fe5b74b7 --- /dev/null +++ b/src/shared/agent-session-command-menu-query.ts @@ -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, + 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 | undefined + const schedule = (delay: number): void => { + clearTimeout(timer) + if (!stopped && Date.now() + delay < deadline) { + timer = setTimeout(() => void run(), delay) + } + } + const run = async (): Promise => { + 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) + } + } +} diff --git a/src/shared/agent-session-command-menu.test-fixture-contract.ts b/src/shared/agent-session-command-menu.test-fixture-contract.ts new file mode 100644 index 00000000000..6e0b9d29762 --- /dev/null +++ b/src/shared/agent-session-command-menu.test-fixture-contract.ts @@ -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 +} diff --git a/src/shared/agent-session-frame-fields.ts b/src/shared/agent-session-frame-fields.ts index a4a12e9971a..2627876cab2 100644 --- a/src/shared/agent-session-frame-fields.ts +++ b/src/shared/agent-session-frame-fields.ts @@ -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 diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 3382f77290f..45c5d449bae 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -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 } diff --git a/src/shared/mobile-queued-edit-host.test-fixture-contract.ts b/src/shared/mobile-queued-edit-host.test-fixture-contract.ts index afb9c0b991c..fb11cb07cc7 100644 --- a/src/shared/mobile-queued-edit-host.test-fixture-contract.ts +++ b/src/shared/mobile-queued-edit-host.test-fixture-contract.ts @@ -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 + starts: () => number + read: () => Promise + subscribe: (emit: (event: AgentSessionSubscribeEvent) => void) => Promise<() => void> + retire: (scoped: boolean) => Promise + dispose: () => Promise + }> +} diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index bcf2366a2ed..2719118eabe 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -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 } : {}), diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts index 15dec6c82c4..e227c3e25fe 100644 --- a/src/shared/structured-agent-session-composer.test.ts +++ b/src/shared/structured-agent-session-composer.test.ts @@ -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) + } }) }) diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 3fc38a9d9e4..595d012aead 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -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) diff --git a/src/shared/structured-agent-session-live-read.ts b/src/shared/structured-agent-session-live-read.ts index 71414a971f4..3ee8e4db316 100644 --- a/src/shared/structured-agent-session-live-read.ts +++ b/src/shared/structured-agent-session-live-read.ts @@ -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 }) diff --git a/src/shared/structured-agent-session-renderer-client-capabilities.ts b/src/shared/structured-agent-session-renderer-client-capabilities.ts index 802ec87139f..6d640d714a0 100644 --- a/src/shared/structured-agent-session-renderer-client-capabilities.ts +++ b/src/shared/structured-agent-session-renderer-client-capabilities.ts @@ -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. diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index e5285cdb396..06e5ba4dae7 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -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,