diff --git a/apps/mobile/src/components/agents/chat-composer-exit-slash-command.test.ts b/apps/mobile/src/components/agents/chat-composer-exit-slash-command.test.ts new file mode 100644 index 0000000000..0628b33c90 --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-exit-slash-command.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { type SlashCommandInfo } from 'cloud-agent-sdk'; +import { type RemoteCommandState } from 'cloud-agent-sdk/remote-command-catalog'; + +import { + createMobileSlashCommandList, + LOCAL_EXIT_SLASH_COMMAND, + LOCAL_NEW_SLASH_COMMAND, + parseChatComposerSubmission, +} from '@/components/agents/chat-composer-slash-commands'; + +const COMPACT: SlashCommandInfo = { name: 'compact', description: 'Compact', hints: [] }; +const EXIT: SlashCommandInfo = { name: 'exit', description: 'Remote exit', hints: ['force'] }; +const QUIT: SlashCommandInfo = { name: 'quit', description: 'Quit alias', hints: [] }; +const Q: SlashCommandInfo = { name: 'q', description: 'Short quit alias', hints: [] }; + +function remoteState(overrides: Partial = {}): RemoteCommandState { + return { + ownerConnectionId: 'conn-1', + refresh: 'idle', + commands: [COMPACT, EXIT], + ...overrides, + }; +} + +describe('remote /exit command list', () => { + it('strips reserved CLI entries and appends canonical local /new and /exit without aliases', () => { + const commands = [COMPACT, LOCAL_NEW_SLASH_COMMAND, EXIT, QUIT, Q]; + const list = createMobileSlashCommandList('remote', commands, remoteState({ commands })); + + expect(list).toEqual([COMPACT, LOCAL_NEW_SLASH_COMMAND, LOCAL_EXIT_SLASH_COMMAND]); + expect(list.filter(command => command.name === 'new')).toHaveLength(1); + expect(list.filter(command => command.name === 'exit')).toHaveLength(1); + expect(list.some(command => command.name === 'quit' || command.name === 'q')).toBe(false); + }); + + it('omits local /exit when the current live catalog has no canonical exit capability', () => { + expect( + createMobileSlashCommandList('remote', [COMPACT, EXIT], remoteState({ commands: [COMPACT] })) + ).toEqual([COMPACT, LOCAL_NEW_SLASH_COMMAND]); + }); + + it('keeps local /exit available during upgrade-required when the live catalog advertises it', () => { + expect( + createMobileSlashCommandList( + 'remote', + [], + remoteState({ commands: [EXIT], refresh: 'upgrade-required', message: 'Please upgrade' }) + ) + ).toEqual([LOCAL_NEW_SLASH_COMMAND, LOCAL_EXIT_SLASH_COMMAND]); + }); +}); + +describe('remote /exit parser', () => { + it('routes exact remote /exit to exit-cli', () => { + expect( + parseChatComposerSubmission('/exit', [LOCAL_NEW_SLASH_COMMAND, LOCAL_EXIT_SLASH_COMMAND], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'exit-cli' }); + }); + + it('rejects attachments with the command attachment error', () => { + expect( + parseChatComposerSubmission('/exit', [LOCAL_EXIT_SLASH_COMMAND], { + hasAttachments: true, + sessionType: 'remote', + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'attachment-error' }); + }); + + it('rejects arguments with command-specific feedback data', () => { + expect( + parseChatComposerSubmission('/exit now', [LOCAL_EXIT_SLASH_COMMAND], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'argument-error', message: '/exit does not take arguments.' }); + }); + + it.each(['/quit', '/q'])('keeps remote alias %s as an ordinary prompt', input => { + expect( + parseChatComposerSubmission(input, [LOCAL_NEW_SLASH_COMMAND, LOCAL_EXIT_SLASH_COMMAND], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ commands: [EXIT, QUIT, Q] }), + }) + ).toEqual({ type: 'prompt', prompt: input }); + }); + + it('returns upgrade-required for reserved /exit with an empty catalog', () => { + expect( + parseChatComposerSubmission('/exit', [], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + commands: [], + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'upgrade-required', message: 'Please upgrade your CLI' }); + }); + + it.each(['/quit', '/q'])('keeps alias %s as a prompt when upgrade is required', input => { + expect( + parseChatComposerSubmission(input, [], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + commands: [], + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'prompt', prompt: input }); + }); + + it.each(['cloud-agent', 'read-only', null] as const)( + 'does not reserve /exit for %s sessions', + sessionType => { + expect( + parseChatComposerSubmission('/exit', [], { + hasAttachments: false, + sessionType, + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'prompt', prompt: '/exit' }); + } + ); +}); diff --git a/apps/mobile/src/components/agents/chat-composer-exit-submit-lock.test.ts b/apps/mobile/src/components/agents/chat-composer-exit-submit-lock.test.ts new file mode 100644 index 0000000000..d0e73e0fef --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-exit-submit-lock.test.ts @@ -0,0 +1,155 @@ +import { type AlertButton, type AlertOptions } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { showRemoteCliExitConfirmation } from '@/components/agents/remote-cli-exit-alert'; +import { executeChatComposerSubmission } from '@/components/agents/chat-composer-submission'; +import { createSubmitLock, type SubmitLock } from '@/lib/submit-lock'; +import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-submit'; + +type AlertCall = [title: string, message: string, buttons: AlertButton[], options?: AlertOptions]; + +const reactNativeMock = vi.hoisted(() => ({ + alert: vi.fn<(...args: AlertCall) => void>(), +})); + +vi.mock('react-native', () => ({ Alert: { alert: reactNativeMock.alert } })); + +function createSubmitLockAdapter(lock: SubmitLock): { current: boolean } { + return { + get current() { + return lock.isLocked(); + }, + set current(next: boolean) { + if (next) { + lock.acquire(); + } else { + lock.release(); + } + }, + }; +} + +function createExitSubmissionHarness() { + const lock = createSubmitLock(); + const lockAdapter = createSubmitLockAdapter(lock); + const order: string[] = []; + const onExitCli = vi.fn(async (onAccepted: () => void) => { + order.push('exit'); + await Promise.resolve(); + order.push('accepted'); + onAccepted(); + }); + const clearDraft = vi.fn(() => { + order.push('clear'); + }); + const dismiss = vi.fn(() => { + order.push('dismiss'); + }); + const resetAttachments = vi.fn((): void => undefined); + + return { + lock, + order, + onExitCli, + clearDraft, + dismiss, + resetAttachments, + submit: async () => { + const submitted = await settleVoiceInputBeforeSubmit({ + lock: lockAdapter, + settleVoiceInput: async () => { + await Promise.resolve(); + return true; + }, + submit: async () => { + await executeChatComposerSubmission( + { type: 'exit-cli' }, + { + confirmExitCli: showRemoteCliExitConfirmation, + onExitCli, + onSendCommand: vi.fn(), + onCreateSession: vi.fn(), + onSendPrompt: vi.fn(), + }, + { clearDraft, dismiss, resetAttachments } + ); + }, + }); + return submitted; + }, + }; +} + +function getAlertCall(index: number): AlertCall { + const call = reactNativeMock.alert.mock.calls[index]; + if (!call) { + throw new Error(`Expected Alert.alert call ${index + 1}`); + } + return call; +} + +describe('remote CLI exit submit lock integration', () => { + beforeEach(() => { + reactNativeMock.alert.mockReset(); + }); + + it('holds the lock until native cancellation and allows a later submission', async () => { + const harness = createExitSubmissionHarness(); + const first = harness.submit(); + let firstSettled = false; + async function observeFirstSettlement() { + await first; + firstSettled = true; + } + void observeFirstSettlement(); + + await vi.waitFor(() => { + expect(reactNativeMock.alert).toHaveBeenCalledTimes(1); + }); + await Promise.resolve(); + + expect(firstSettled).toBe(false); + expect(harness.lock.isLocked()).toBe(true); + await expect(harness.submit()).resolves.toBe(false); + expect(reactNativeMock.alert).toHaveBeenCalledTimes(1); + + getAlertCall(0)[2][0]?.onPress?.(); + await expect(first).resolves.toBe(true); + + expect(harness.onExitCli).not.toHaveBeenCalled(); + expect(harness.clearDraft).not.toHaveBeenCalled(); + expect(harness.dismiss).not.toHaveBeenCalled(); + expect(harness.lock.isLocked()).toBe(false); + + const afterCancel = harness.submit(); + await vi.waitFor(() => { + expect(reactNativeMock.alert).toHaveBeenCalledTimes(2); + }); + getAlertCall(1)[3]?.onDismiss?.(); + await expect(afterCancel).resolves.toBe(true); + + expect(harness.onExitCli).not.toHaveBeenCalled(); + expect(harness.clearDraft).not.toHaveBeenCalled(); + expect(harness.dismiss).not.toHaveBeenCalled(); + expect(harness.lock.isLocked()).toBe(false); + }); + + it('executes once after destructive confirmation and releases the lock', async () => { + const harness = createExitSubmissionHarness(); + const first = harness.submit(); + + await vi.waitFor(() => { + expect(reactNativeMock.alert).toHaveBeenCalledTimes(1); + }); + await expect(harness.submit()).resolves.toBe(false); + expect(reactNativeMock.alert).toHaveBeenCalledTimes(1); + + getAlertCall(0)[2][1]?.onPress?.(); + await expect(first).resolves.toBe(true); + + expect(harness.onExitCli).toHaveBeenCalledTimes(1); + expect(harness.order).toEqual(['exit', 'accepted', 'clear', 'dismiss']); + expect(harness.resetAttachments).not.toHaveBeenCalled(); + expect(harness.lock.isLocked()).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-composer-slash-commands-new-reservation.test.ts b/apps/mobile/src/components/agents/chat-composer-slash-commands-new-reservation.test.ts new file mode 100644 index 0000000000..29757d3c3c --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-slash-commands-new-reservation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { type SlashCommandInfo } from 'cloud-agent-sdk'; + +import { + LOCAL_NEW_SLASH_COMMAND, + parseChatComposerSubmission, +} from '@/components/agents/chat-composer-slash-commands'; + +const COMPACT: SlashCommandInfo = { name: 'compact', description: 'Compact', hints: [] }; +const REVIEW: SlashCommandInfo = { name: 'review', description: 'Review', hints: [] }; +const SAMPLE_COMMANDS: SlashCommandInfo[] = [COMPACT, REVIEW]; + +describe('parseChatComposerSubmission — /new reserved only for remote sessions', () => { + it('keeps /new as a prompt for cloud-agent sessions when it is not reported', () => { + expect( + parseChatComposerSubmission('/new', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'prompt', prompt: '/new' }); + }); + + it('treats reported /new as a normal command for cloud-agent sessions, preserving arguments', () => { + expect( + parseChatComposerSubmission( + '/new extra args', + [...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND], + { + hasAttachments: false, + sessionType: 'cloud-agent', + remoteCommandState: null, + } + ) + ).toEqual({ type: 'command', command: 'new', arguments: 'extra args' }); + }); + + it('applies ordinary command attachment semantics to reported /new in cloud-agent sessions', () => { + expect( + parseChatComposerSubmission('/new', [...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND], { + hasAttachments: true, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'attachment-error' }); + }); + + it('keeps /new as a prompt for read-only sessions', () => { + expect( + parseChatComposerSubmission('/new', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'read-only', + remoteCommandState: null, + }) + ).toEqual({ type: 'prompt', prompt: '/new' }); + }); + + it('keeps /new as a prompt for unresolved sessions', () => { + expect( + parseChatComposerSubmission('/new', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: null, + remoteCommandState: null, + }) + ).toEqual({ type: 'prompt', prompt: '/new' }); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-composer-slash-commands.test.ts b/apps/mobile/src/components/agents/chat-composer-slash-commands.test.ts new file mode 100644 index 0000000000..4797898f1f --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-slash-commands.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'vitest'; + +import { type SlashCommandInfo } from 'cloud-agent-sdk'; +import { type RemoteCommandState } from 'cloud-agent-sdk/remote-command-catalog'; + +import { + createMobileSlashCommandList, + getSlashCommandCandidate, + getSlashCommandSuggestions, + LOCAL_NEW_SLASH_COMMAND, + parseChatComposerSubmission, +} from '@/components/agents/chat-composer-slash-commands'; + +const COMPACT: SlashCommandInfo = { name: 'compact', description: 'Compact', hints: [] }; +const REVIEW: SlashCommandInfo = { name: 'review', description: 'Review', hints: [] }; +const SAMPLE_COMMANDS: SlashCommandInfo[] = [COMPACT, REVIEW]; + +function remoteState(overrides: Partial = {}): RemoteCommandState { + return { + ownerConnectionId: 'conn-1', + refresh: 'idle', + commands: SAMPLE_COMMANDS, + ...overrides, + }; +} + +describe('createMobileSlashCommandList', () => { + it('returns the live CLI catalog verbatim for remote sessions, with the reserved /new injected', () => { + const list = createMobileSlashCommandList('remote', SAMPLE_COMMANDS, remoteState()); + expect(list).toEqual([...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND]); + }); + + it('strips any CLI-reported /new before injecting the local reserved one', () => { + const list = createMobileSlashCommandList( + 'remote', + [COMPACT, LOCAL_NEW_SLASH_COMMAND], + remoteState() + ); + expect(list.filter(command => command.name === 'new')).toEqual([LOCAL_NEW_SLASH_COMMAND]); + expect(list[0]).toBe(COMPACT); + }); + + it('still exposes /new when the remote catalog is empty but the session is live', () => { + const list = createMobileSlashCommandList( + 'remote', + [], + remoteState({ commands: [], refresh: 'idle' }) + ); + expect(list).toEqual([LOCAL_NEW_SLASH_COMMAND]); + }); + + it('exposes only the reserved /new command when the remote catalog is empty and upgrade-required', () => { + const list = createMobileSlashCommandList( + 'remote', + [], + remoteState({ commands: [], refresh: 'upgrade-required', message: 'Please upgrade your CLI' }) + ); + expect(list).toEqual([LOCAL_NEW_SLASH_COMMAND]); + expect(list.some(command => command.name === 'compact')).toBe(false); + }); + + it('keeps /new available under an upgrade-required refresh so the user gets a clear upgrade message instead of a silent prompt', () => { + const list = createMobileSlashCommandList( + 'remote', + SAMPLE_COMMANDS, + remoteState({ refresh: 'upgrade-required', message: 'Please upgrade' }) + ); + expect(list.map(command => command.name)).toEqual(['compact', 'review', 'new']); + }); + + it('returns the live catalog verbatim for cloud-agent sessions without injecting /new', () => { + const list = createMobileSlashCommandList('cloud-agent', SAMPLE_COMMANDS, null); + expect(list).toBe(SAMPLE_COMMANDS); + }); + + it('exposes no commands for read-only, unresolved, or other noninteractive session types', () => { + expect(createMobileSlashCommandList('read-only', SAMPLE_COMMANDS, null)).toEqual([]); + expect(createMobileSlashCommandList(null, SAMPLE_COMMANDS, null)).toEqual([]); + }); +}); + +describe('getSlashCommandCandidate', () => { + it('keeps prefix-typed slash inputs that can still match a command', () => { + expect(getSlashCommandCandidate('/')).toBe('/'); + expect(getSlashCommandCandidate('/re')).toBe('/re'); + expect(getSlashCommandCandidate('/new')).toBe('/new'); + }); + + it('collapses prose and any input with arguments or trailing whitespace to null', () => { + expect(getSlashCommandCandidate('hello')).toBeNull(); + expect(getSlashCommandCandidate('/review main')).toBeNull(); + expect(getSlashCommandCandidate('/review ')).toBeNull(); + expect(getSlashCommandCandidate('')).toBeNull(); + }); +}); + +describe('getSlashCommandSuggestions', () => { + it('filters the current catalog by the command-name prefix', () => { + expect(getSlashCommandSuggestions('/re', SAMPLE_COMMANDS)).toEqual([REVIEW]); + }); + + it('returns every command for the empty prefix', () => { + expect(getSlashCommandSuggestions('/', SAMPLE_COMMANDS)).toEqual(SAMPLE_COMMANDS); + }); + + it('closes after command arguments begin or when input is not slash-prefixed', () => { + expect(getSlashCommandSuggestions('/review main', SAMPLE_COMMANDS)).toEqual([]); + expect(getSlashCommandSuggestions('review', SAMPLE_COMMANDS)).toEqual([]); + }); +}); + +describe('parseChatComposerSubmission — happy path', () => { + it('parses a recognized command and preserves its argument text', () => { + expect( + parseChatComposerSubmission(' /review main branch ', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'command', command: 'review', arguments: 'main branch' }); + }); + + it('preserves empty arguments for a command with no trailing args', () => { + expect( + parseChatComposerSubmission('/compact', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'command', command: 'compact', arguments: '' }); + }); + + it('routes /new with no arguments to create-session', () => { + expect( + parseChatComposerSubmission('/new', [...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'create-session' }); + }); + + it('keeps an unknown slash-prefixed input as a prompt', () => { + expect( + parseChatComposerSubmission(' /unknown keep this ', SAMPLE_COMMANDS, { + hasAttachments: true, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'prompt', prompt: '/unknown keep this' }); + }); +}); + +describe('parseChatComposerSubmission — attachment errors', () => { + it('rejects attachments only for recognized commands', () => { + expect( + parseChatComposerSubmission('/compact', SAMPLE_COMMANDS, { + hasAttachments: true, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'attachment-error' }); + }); + + it('rejects attachments for /new create-session', () => { + expect( + parseChatComposerSubmission('/new', [...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND], { + hasAttachments: true, + sessionType: 'remote', + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'attachment-error' }); + }); + + it('does not flag attachments for an unknown slash command (it stays a prompt)', () => { + expect( + parseChatComposerSubmission('/not-a-command', SAMPLE_COMMANDS, { + hasAttachments: true, + sessionType: 'cloud-agent', + remoteCommandState: null, + }) + ).toEqual({ type: 'prompt', prompt: '/not-a-command' }); + }); +}); + +describe('parseChatComposerSubmission — argument errors', () => { + it('rejects /new with any argument text', () => { + expect( + parseChatComposerSubmission('/new extra', [...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState(), + }) + ).toEqual({ type: 'argument-error', message: '/new does not take arguments.' }); + }); +}); + +describe('parseChatComposerSubmission — upgrade-required', () => { + it('returns upgrade-required for any known remote command when the CLI must upgrade', () => { + expect( + parseChatComposerSubmission('/compact', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'upgrade-required', message: 'Please upgrade your CLI' }); + }); + + it('returns upgrade-required for the reserved /new command when the CLI must upgrade', () => { + expect( + parseChatComposerSubmission('/new', [...SAMPLE_COMMANDS, LOCAL_NEW_SLASH_COMMAND], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'upgrade-required', message: 'Please upgrade your CLI' }); + }); + + it('keeps unknown slash commands as prompts even when the CLI must upgrade', () => { + expect( + parseChatComposerSubmission('/foo', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'prompt', prompt: '/foo' }); + }); + + it('returns upgrade-required for the reserved /compact command even when the remote catalog is empty', () => { + expect( + parseChatComposerSubmission('/compact', [], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + commands: [], + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'upgrade-required', message: 'Please upgrade your CLI' }); + }); + + it('returns upgrade-required for the reserved /new command even when the remote catalog is empty', () => { + expect( + parseChatComposerSubmission('/new', [], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + commands: [], + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'upgrade-required', message: 'Please upgrade your CLI' }); + }); + + it('keeps unknown slash commands as prompts with an empty catalog when the CLI must upgrade', () => { + expect( + parseChatComposerSubmission('/foo', [], { + hasAttachments: false, + sessionType: 'remote', + remoteCommandState: remoteState({ + refresh: 'upgrade-required', + commands: [], + message: 'Please upgrade your CLI', + }), + }) + ).toEqual({ type: 'prompt', prompt: '/foo' }); + }); +}); + +describe('parseChatComposerSubmission — non-remote sessions ignore the remote state', () => { + it('does not raise upgrade-required for cloud-agent sessions even if a remote state is passed', () => { + expect( + parseChatComposerSubmission('/compact', SAMPLE_COMMANDS, { + hasAttachments: false, + sessionType: 'cloud-agent', + remoteCommandState: remoteState({ refresh: 'upgrade-required' }), + }) + ).toEqual({ type: 'command', command: 'compact', arguments: '' }); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-composer-slash-commands.ts b/apps/mobile/src/components/agents/chat-composer-slash-commands.ts new file mode 100644 index 0000000000..d24bfaa3bf --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-slash-commands.ts @@ -0,0 +1,188 @@ +import { type ActiveSessionType, type SlashCommandInfo } from 'cloud-agent-sdk'; +import { type RemoteCommandState } from 'cloud-agent-sdk/remote-command-catalog'; + +/** + * Local reserved /new command — surfaced only for remote sessions, never + * pushed to the CLI. Remote CLIs have no /new (they create sessions through a + * dedicated control message), so a slash-style "new" must live in the mobile + * client. + */ +export const LOCAL_NEW_SLASH_COMMAND: SlashCommandInfo = { + name: 'new', + description: 'Start a new session', + hints: [], +}; + +export const LOCAL_EXIT_SLASH_COMMAND: SlashCommandInfo = { + name: 'exit', + description: 'Exit the CLI', + hints: [], +}; + +const NEW_COMMAND_NAME = 'new'; +const EXIT_COMMAND_NAME = 'exit'; +const LOCAL_COMMAND_NAMES = new Set([NEW_COMMAND_NAME, EXIT_COMMAND_NAME, 'quit', 'q']); +const SLASH_PREFIX_PATTERN = /^\/[\w.-]*$/; +const SLASH_FULL_PATTERN = /^\/([\w.-]+)(?:\s+([\s\S]*))?$/; + +/** + * Reserved commands the mobile client promises to intercept when a remote CLI + * reports `refresh: 'upgrade-required'`. The live catalog may be empty for an + * old CLI, so the composer relies on this fixed allowlist rather than the + * dynamic command list for fail-closed upgrade handling. This set is the + * explicit mobile promise: any new mobile-reserved slash commands must be + * added here; do not refactor the SDK to assume this list. + */ +const RESERVED_UPGRADE_REQUIRED_COMMANDS = new Set([ + 'compact', + NEW_COMMAND_NAME, + EXIT_COMMAND_NAME, +]); + +type ChatComposerParseContext = { + hasAttachments: boolean; + sessionType: ActiveSessionType | null; + remoteCommandState: RemoteCommandState | null; +}; + +export type ChatComposerParseResult = + | { type: 'prompt'; prompt: string } + | { type: 'command'; command: string; arguments: string } + | { type: 'create-session' } + | { type: 'exit-cli' } + | { type: 'attachment-error' } + | { type: 'argument-error'; message: string } + | { type: 'upgrade-required'; message: string }; + +const UPGRADE_REQUIRED_FALLBACK_MESSAGE = 'Please upgrade your CLI to use this command.'; + +/** + * Select the slash command catalog the mobile composer should surface. + * + * - `cloud-agent` sessions use the live reported catalog verbatim — empty + * stays empty and the Cloud Agent defaults live in the worker, not here. + * - `remote` sessions strip CLI-reported `new`, `exit`, `quit`, and `q`, then + * append the locally reserved `/new` and capability-gated local `/exit` when + * the live catalog includes canonical `exit`. + * - `read-only` and `null` (unresolved) sessions expose no commands. + * + * We expose reserved `/new` even when the remote catalog is empty; `/exit` is + * exposed only when the current live catalog advertises canonical `exit`. + */ +export function createMobileSlashCommandList( + sessionType: ActiveSessionType | null, + availableCommands: SlashCommandInfo[], + remoteCommandState: RemoteCommandState | null +): SlashCommandInfo[] { + if (sessionType === 'cloud-agent') { + return availableCommands; + } + if (sessionType !== 'remote' || !remoteCommandState) { + return []; + } + const supportsExit = remoteCommandState.commands.some( + command => command.name === EXIT_COMMAND_NAME + ); + const remoteCommands = remoteCommandState.commands.filter( + command => !LOCAL_COMMAND_NAMES.has(command.name) + ); + return [ + ...remoteCommands, + LOCAL_NEW_SLASH_COMMAND, + ...(supportsExit ? [LOCAL_EXIT_SLASH_COMMAND] : []), + ]; +} + +/** + * Returns the input when it can still match a command name, `null` otherwise. + * Keeping non-candidates collapsed to `null` lets the composer skip + * re-rendering on every keystroke of ordinary prose. + */ +export function getSlashCommandCandidate(input: string): string | null { + return SLASH_PREFIX_PATTERN.test(input) ? input : null; +} + +/** + * Return the catalog entries whose name starts with the prefix in `input`. + * Returns `[]` for anything that is not still a slash-name candidate. + */ +export function getSlashCommandSuggestions( + input: string, + commands: SlashCommandInfo[] +): SlashCommandInfo[] { + const match = /^\/([\w.-]*)$/.exec(input); + if (!match) { + return []; + } + const prefix = match[1] ?? ''; + return commands.filter(command => command.name.startsWith(prefix)); +} + +function findCommand(commands: SlashCommandInfo[], name: string): SlashCommandInfo | undefined { + return commands.find(command => command.name === name); +} + +/** + * Classify a composer input into the action the composer should take. + * + * Order matters: the upgrade-required short-circuit runs before recognition so + * that the reserved commands mobile promises to handle (`compact`, `new`, and `exit`) + * are surfaced to the user when the remote CLI requires an upgrade, instead of + * silently falling through as ordinary prompts. Unknown slash inputs (`/foo`) + * still fall through to `prompt` so the user can send arbitrary text the CLI + * may know about. + */ +export function parseChatComposerSubmission( + input: string, + commands: SlashCommandInfo[], + context: ChatComposerParseContext +): ChatComposerParseResult { + const trimmed = input.trim(); + const match = SLASH_FULL_PATTERN.exec(trimmed); + const commandName = match?.[1]; + const argumentsText = match?.[2]?.trim() ?? ''; + + if ( + context.sessionType === 'remote' && + context.remoteCommandState?.refresh === 'upgrade-required' + ) { + if (commandName && RESERVED_UPGRADE_REQUIRED_COMMANDS.has(commandName)) { + return { + type: 'upgrade-required', + message: context.remoteCommandState.message ?? UPGRADE_REQUIRED_FALLBACK_MESSAGE, + }; + } + return { type: 'prompt', prompt: trimmed }; + } + + if (commandName === NEW_COMMAND_NAME && context.sessionType === 'remote') { + // /new is reserved for remote sessions only. + if (context.hasAttachments) { + return { type: 'attachment-error' }; + } + if (argumentsText.length > 0) { + return { type: 'argument-error', message: '/new does not take arguments.' }; + } + return { type: 'create-session' }; + } + // Non-remote /new falls through to the command-or-prompt logic below. + + if (commandName === EXIT_COMMAND_NAME && context.sessionType === 'remote') { + if (context.hasAttachments) { + return { type: 'attachment-error' }; + } + if (argumentsText.length > 0) { + return { type: 'argument-error', message: '/exit does not take arguments.' }; + } + return { type: 'exit-cli' }; + } + + if (commandName && findCommand(commands, commandName)) { + if (context.hasAttachments) { + return { type: 'attachment-error' }; + } + return { type: 'command', command: commandName, arguments: argumentsText }; + } + + return { type: 'prompt', prompt: trimmed }; +} diff --git a/apps/mobile/src/components/agents/chat-composer-submission.test.ts b/apps/mobile/src/components/agents/chat-composer-submission.test.ts new file mode 100644 index 0000000000..0983499ddd --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-submission.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type ExecutableChatComposerSubmission, + executeChatComposerSubmission, +} from '@/components/agents/chat-composer-submission'; + +type CommandSubmission = Extract; +type PromptSubmission = Extract; + +function makeCommandSubmission( + overrides: Partial = {} +): ExecutableChatComposerSubmission { + return { type: 'command', command: 'review', arguments: 'main', ...overrides }; +} + +function makeCreateSessionSubmission(): ExecutableChatComposerSubmission { + return { type: 'create-session' }; +} + +function makeExitCliSubmission(): ExecutableChatComposerSubmission { + return { type: 'exit-cli' }; +} + +function makePromptSubmission( + overrides: Partial = {} +): ExecutableChatComposerSubmission { + return { type: 'prompt', prompt: 'hello', ...overrides }; +} + +function makeCleanup() { + return { + clearDraft: vi.fn(), + resetAttachments: vi.fn(), + dismiss: vi.fn(), + }; +} + +function makeHandlers( + overrides: { + onSendCommand?: () => Promise; + onCreateSession?: () => Promise; + onExitCli?: (onAccepted: () => void) => Promise; + confirmExitCli?: () => Promise; + onSendPrompt?: () => Promise; + } = {} +) { + return { + onSendCommand: vi.fn( + overrides.onSendCommand ?? + (async () => { + await Promise.resolve(); + return true; + }) + ), + onCreateSession: vi.fn( + overrides.onCreateSession ?? + (async () => { + await Promise.resolve(); + return true; + }) + ), + onExitCli: vi.fn( + overrides.onExitCli ?? + (async onAccepted => { + await Promise.resolve(); + onAccepted(); + }) + ), + confirmExitCli: vi.fn( + overrides.confirmExitCli ?? + (async () => { + await Promise.resolve(); + return true; + }) + ), + onSendPrompt: vi.fn( + overrides.onSendPrompt ?? + (async () => { + await Promise.resolve(); + }) + ), + }; +} + +describe('executeChatComposerSubmission', () => { + describe('command submission', () => { + it('clears the draft and dismisses once when the command is accepted', async () => { + const handlers = makeHandlers({ + onSendCommand: async () => { + await Promise.resolve(); + return true; + }, + }); + const cleanup = makeCleanup(); + + await executeChatComposerSubmission( + makeCommandSubmission({ command: 'review', arguments: 'main' }), + handlers, + cleanup + ); + + expect(handlers.onSendCommand).toHaveBeenCalledTimes(1); + expect(handlers.onSendCommand).toHaveBeenCalledWith('review', 'main'); + expect(cleanup.clearDraft).toHaveBeenCalledTimes(1); + expect(cleanup.dismiss).toHaveBeenCalledTimes(1); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + + it('preserves draft and dismisses nothing when the command is rejected', async () => { + const handlers = makeHandlers({ + onSendCommand: async () => { + await Promise.resolve(); + return false; + }, + }); + const cleanup = makeCleanup(); + + await expect( + executeChatComposerSubmission( + makeCommandSubmission({ command: 'compact', arguments: '' }), + handlers, + cleanup + ) + ).rejects.toThrow('Command send rejected'); + + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + + it('propagates rejection without cleanup when the command throws', async () => { + const handlers = makeHandlers({ + onSendCommand: async () => { + await Promise.resolve(); + throw new Error('transport failed'); + }, + }); + const cleanup = makeCleanup(); + + await expect( + executeChatComposerSubmission( + makeCommandSubmission({ command: 'compact', arguments: '' }), + handlers, + cleanup + ) + ).rejects.toThrow('transport failed'); + + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + }); + + describe('create-session submission', () => { + it('clears the draft and dismisses once when creation is accepted', async () => { + const handlers = makeHandlers({ + onCreateSession: async () => { + await Promise.resolve(); + return true; + }, + }); + const cleanup = makeCleanup(); + + await executeChatComposerSubmission(makeCreateSessionSubmission(), handlers, cleanup); + + expect(handlers.onCreateSession).toHaveBeenCalledTimes(1); + expect(cleanup.clearDraft).toHaveBeenCalledTimes(1); + expect(cleanup.dismiss).toHaveBeenCalledTimes(1); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + + it('preserves draft and dismisses nothing when creation is rejected', async () => { + const handlers = makeHandlers({ + onCreateSession: async () => { + await Promise.resolve(); + return false; + }, + }); + const cleanup = makeCleanup(); + + await expect( + executeChatComposerSubmission(makeCreateSessionSubmission(), handlers, cleanup) + ).rejects.toThrow('Create session rejected'); + + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + + it('propagates rejection without cleanup when creation throws', async () => { + const handlers = makeHandlers({ + onCreateSession: async () => { + await Promise.resolve(); + throw new Error('cli unavailable'); + }, + }); + const cleanup = makeCleanup(); + + await expect( + executeChatComposerSubmission(makeCreateSessionSubmission(), handlers, cleanup) + ).rejects.toThrow('cli unavailable'); + + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + }); + + describe('exit-cli submission', () => { + it('does no cleanup and never exits when confirmation is cancelled', async () => { + const handlers = makeHandlers({ + confirmExitCli: async () => { + await Promise.resolve(); + return false; + }, + }); + const cleanup = makeCleanup(); + + await executeChatComposerSubmission(makeExitCliSubmission(), handlers, cleanup); + + expect(handlers.confirmExitCli).toHaveBeenCalledTimes(1); + expect(handlers.onExitCli).not.toHaveBeenCalled(); + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + + it('awaits confirmation and exit before clearing the draft and dismissing', async () => { + const order: string[] = []; + const handlers = makeHandlers({ + confirmExitCli: async () => { + order.push('confirm'); + await Promise.resolve(); + return true; + }, + onExitCli: async onAccepted => { + order.push('exit'); + await Promise.resolve(); + onAccepted(); + }, + }); + const cleanup = { + clearDraft: vi.fn(() => order.push('clear')), + resetAttachments: vi.fn(() => order.push('reset')), + dismiss: vi.fn(() => order.push('dismiss')), + }; + + await executeChatComposerSubmission(makeExitCliSubmission(), handlers, cleanup); + + expect(order).toEqual(['confirm', 'exit', 'clear', 'dismiss']); + expect(handlers.onExitCli).toHaveBeenCalledTimes(1); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + + it('preserves the draft and keyboard when confirmed exit fails', async () => { + const handlers = makeHandlers({ + onExitCli: async () => { + await Promise.resolve(); + throw new Error('CLI is already offline'); + }, + }); + const cleanup = makeCleanup(); + + await expect( + executeChatComposerSubmission(makeExitCliSubmission(), handlers, cleanup) + ).rejects.toThrow('CLI is already offline'); + + expect(handlers.onExitCli).toHaveBeenCalledTimes(1); + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + }); + }); + + describe('prompt submission', () => { + it('clears the draft, resets attachments, and dismisses once when the prompt resolves', async () => { + const handlers = makeHandlers({ + onSendPrompt: async () => { + await Promise.resolve(); + }, + }); + const cleanup = makeCleanup(); + + await executeChatComposerSubmission( + makePromptSubmission({ prompt: 'hello world' }), + handlers, + cleanup + ); + + expect(handlers.onSendPrompt).toHaveBeenCalledTimes(1); + expect(handlers.onSendPrompt).toHaveBeenCalledWith('hello world'); + expect(cleanup.clearDraft).toHaveBeenCalledTimes(1); + expect(cleanup.resetAttachments).toHaveBeenCalledTimes(1); + expect(cleanup.dismiss).toHaveBeenCalledTimes(1); + }); + + it('preserves draft and attachments when the prompt send rejects', async () => { + const handlers = makeHandlers({ + onSendPrompt: async () => { + await Promise.resolve(); + throw new Error('rate limited'); + }, + }); + const cleanup = makeCleanup(); + + await expect( + executeChatComposerSubmission(makePromptSubmission({ prompt: 'hello' }), handlers, cleanup) + ).rejects.toThrow('rate limited'); + + expect(cleanup.clearDraft).not.toHaveBeenCalled(); + expect(cleanup.resetAttachments).not.toHaveBeenCalled(); + expect(cleanup.dismiss).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-composer-submission.ts b/apps/mobile/src/components/agents/chat-composer-submission.ts new file mode 100644 index 0000000000..cbae03dfec --- /dev/null +++ b/apps/mobile/src/components/agents/chat-composer-submission.ts @@ -0,0 +1,74 @@ +import { type ChatComposerParseResult } from '@/components/agents/chat-composer-slash-commands'; +import { confirmRemoteCliExit } from '@/components/agents/remote-cli-exit-confirmation'; + +/** + * Submission types that have passed validation and are ready to execute. + * Attachment, argument, and upgrade-required errors are handled before the + * composer reaches this stage. + */ +export type ExecutableChatComposerSubmission = Extract< + ChatComposerParseResult, + { type: 'command' } | { type: 'create-session' } | { type: 'exit-cli' } | { type: 'prompt' } +>; + +type ChatComposerSubmissionHandlers = { + onSendCommand: (command: string, argumentsText: string) => Promise; + onCreateSession: () => Promise; + onExitCli: (onAccepted: () => void) => Promise; + confirmExitCli: () => Promise; + onSendPrompt: (prompt: string) => Promise; +}; + +type ChatComposerSubmissionCleanup = { + clearDraft: () => void; + resetAttachments: () => void; + dismiss: () => void; +}; + +/** + * Execute the outcome of `parseChatComposerSubmission`. + * + * The caller owns validation, locking, and feedback (toasts/haptics). This + * helper only orchestrates callbacks and cleanup, and it rejects on any failure + * so the caller can preserve the composer draft. + */ +export async function executeChatComposerSubmission( + submission: ExecutableChatComposerSubmission, + handlers: ChatComposerSubmissionHandlers, + cleanup: ChatComposerSubmissionCleanup +): Promise { + if (submission.type === 'command') { + const accepted = await handlers.onSendCommand(submission.command, submission.arguments); + if (!accepted) { + throw new Error('Command send rejected'); + } + cleanup.clearDraft(); + cleanup.dismiss(); + return; + } + + if (submission.type === 'create-session') { + const accepted = await handlers.onCreateSession(); + if (!accepted) { + throw new Error('Create session rejected'); + } + cleanup.clearDraft(); + cleanup.dismiss(); + return; + } + + if (submission.type === 'exit-cli') { + await confirmRemoteCliExit(handlers.confirmExitCli, async () => { + await handlers.onExitCli(() => { + cleanup.clearDraft(); + cleanup.dismiss(); + }); + }); + return; + } + + await handlers.onSendPrompt(submission.prompt); + cleanup.clearDraft(); + cleanup.resetAttachments(); + cleanup.dismiss(); +} diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index eb0ed6f5e7..f494a5f684 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -1,6 +1,13 @@ +/* eslint-disable max-lines -- Composer owns its uncontrolled input, slash suggestions, and submission flow end-to-end. + * The wiring between the TextInput and SlashCommandSuggestions is covered by + * Maestro E2E; this app has no @testing-library/react-native dependency, so it + * is not expressed as a unit test. + */ import * as Haptics from 'expo-haptics'; import { useActionSheet } from '@expo/react-native-action-sheet'; -import { useCallback, useRef, useState } from 'react'; +import { type SlashCommandInfo } from 'cloud-agent-sdk'; +import { type RemoteCommandState } from 'cloud-agent-sdk/remote-command-catalog'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { Keyboard, type LayoutChangeEvent, @@ -15,6 +22,15 @@ import { AttachmentPreviewStrip } from '@/components/agents/attachment-preview-s import { ChatToolbar } from '@/components/agents/chat-toolbar'; import { type AgentMode } from '@/components/agents/mode-selector'; import { pickAgentAttachments } from '@/components/agents/attachment-picker'; +import { + createMobileSlashCommandList, + getSlashCommandCandidate, + getSlashCommandSuggestions, + parseChatComposerSubmission, +} from '@/components/agents/chat-composer-slash-commands'; +import { executeChatComposerSubmission } from '@/components/agents/chat-composer-submission'; +import { showRemoteCliExitConfirmation } from '@/components/agents/remote-cli-exit-alert'; +import { SlashCommandSuggestions } from '@/components/agents/slash-command-suggestions'; import { useTextHeight } from '@/components/agents/use-text-height'; import { resolveChatComposerControlState } from '@/components/agents/chat-composer-input-state'; import { ChatComposerInputRow } from '@/components/agents/chat-composer-input-row'; @@ -28,6 +44,7 @@ import { import { type ModelOption } from '@/lib/hooks/use-available-models'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; +import { createSubmitLock, type SubmitLock } from '@/lib/submit-lock'; import { useVoiceInput } from '@/lib/voice-input/use-voice-input'; import { applyVoiceDraftToInput } from '@/lib/voice-input/voice-input-draft'; import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-submit'; @@ -43,6 +60,9 @@ const TEXT_INPUT_FONT_SIZE = 16; type ChatComposerProps = { onSend: (text: string, attachments?: AgentAttachmentWire) => void | Promise; + onSendCommand: (command: string, argumentsText: string) => Promise; + onCreateSession: () => Promise; + onExitCli: (onAccepted: () => void) => Promise; onStop?: () => void | Promise; disabled?: boolean; isStreaming?: boolean; @@ -56,10 +76,19 @@ type ChatComposerProps = { organizationId?: string; /** Only Cloud Agent sessions can receive attachments. */ attachmentsEnabled?: boolean; + /** Active resolved session type — drives slash command selection. */ + activeSessionType?: 'cloud-agent' | 'remote' | 'read-only' | null; + /** Wrapper commands; remote presentation adds /new and capability-gated /exit after stripping aliases. */ + commands?: SlashCommandInfo[]; + /** Remote command state — empty for non-remote sessions. */ + commandState?: RemoteCommandState | null; }; export function ChatComposer({ onSend, + onSendCommand, + onCreateSession, + onExitCli, onStop, disabled = false, isStreaming = false, @@ -72,16 +101,43 @@ export function ChatComposer({ onModelSelect, organizationId, attachmentsEnabled = true, + activeSessionType = null, + commands = [], + commandState = null, }: Readonly) { const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); const textRef = useRef(''); const inputRef = useRef(null); const [hasText, setHasText] = useState(false); + const [slashCommandInput, setSlashCommandInput] = useState(null); const [inputWidth, setInputWidth] = useState(0); const [isFocused, setIsFocused] = useState(false); const [isSending, setIsSending] = useState(false); - const submissionLockRef = useRef(false); + + // Single send-admission authority. `settleVoiceInputBeforeSubmit` owns + // this lock for the full voice-settle + asynchronous send sequence, and + // `handleSelectSlashCommand` consults it synchronously so a suggestion tap + // cannot mutate the draft while a send is in flight. A second submit can + // never slip through the brief window where React has not yet committed + // `isSending=true`. + const sendLockRef = useRef(createSubmitLock()); + // `settleVoiceInputBeforeSubmit` expects a `{ current: boolean }` ref-like + // and writes through it during settle. The adapter routes every read and + // write through the SubmitLock above, so the helper participates in the + // same admission gate without introducing a second, racing authority. + const submissionLockRef: { current: boolean } = { + get current() { + return sendLockRef.current.isLocked(); + }, + set current(next: boolean) { + if (next) { + sendLockRef.current.acquire(); + } else { + sendLockRef.current.release(); + } + }, + }; const upload = useAgentAttachmentUpload({ organizationId }); const measure = useTextHeight({ @@ -121,10 +177,26 @@ export function ChatComposer({ voiceInputActive: voiceInput.isActive, }); + const commandList = useMemo( + () => createMobileSlashCommandList(activeSessionType, commands, commandState), + [activeSessionType, commandState, commands] + ); + const slashCommandSuggestions = + slashCommandInput === null ? [] : getSlashCommandSuggestions(slashCommandInput, commandList); + function handleChangeText(value: string) { textRef.current = value; measure.setText(value); setHasText(value.trim().length > 0); + setSlashCommandInput(getSlashCommandCandidate(value)); + } + + function clearDraft() { + textRef.current = ''; + setHasText(false); + setSlashCommandInput(null); + measure.reset(); + inputRef.current?.clear(); } async function handleSend() { @@ -140,30 +212,86 @@ export function ChatComposer({ toast.error('Remove or retry failed attachments first.'); return; } - if (trimmed.startsWith('/') && upload.attachments.length > 0) { + + const submission = parseChatComposerSubmission(trimmed, commandList, { + hasAttachments: upload.attachments.length > 0, + sessionType: activeSessionType, + remoteCommandState: commandState, + }); + + if (submission.type === 'attachment-error') { toast.error('Attachments cannot be sent with slash commands.'); return; } + if (submission.type === 'argument-error') { + toast.error(submission.message); + return; + } + if (submission.type === 'upgrade-required') { + toast.error(submission.message); + return; + } + // The admission lock is owned by `settleVoiceInputBeforeSubmit` for the + // full settle + submit sequence, so `handleSend` performs validation and + // executes the submission without re-acquiring/releasing the lock or + // toggling pending state. That keeps one authority and lets the lock + // protect the entire asynchronous send. void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - const payload = upload.toWirePayload(); try { - // Only clear the draft once the send actually succeeds — a failed - // send must leave the text and attachments exactly as the user left - // them (the parent already surfaces the error toast). - await onSend(trimmed, payload); - textRef.current = ''; - setHasText(false); - measure.reset(); - inputRef.current?.clear(); - upload.reset(); - Keyboard.dismiss(); + await executeChatComposerSubmission( + submission, + { + onSendCommand, + onCreateSession, + onExitCli, + confirmExitCli: showRemoteCliExitConfirmation, + onSendPrompt: async prompt => { + await onSend(prompt, upload.toWirePayload()); + }, + }, + { + clearDraft, + resetAttachments: () => { + upload.reset(); + }, + dismiss: () => { + Keyboard.dismiss(); + }, + } + ); } catch { - // Draft preserved; error already surfaced by the caller. + // Draft preserved; error already surfaced by the caller. The helper + // will release the lock and clear pending state in its finally block. } } + function handleSelectSlashCommand(command: SlashCommandInfo) { + // Same-render race guard: a suggestion row rendered before the send started + // can be tapped while the lock is held. Because the lock is the authority + // for admission to any composer mutation, bail synchronously instead of + // relying on a later render to hide the list. + if (sendLockRef.current.isLocked()) { + return; + } + const value = `/${command.name} `; + textRef.current = value; + measure.setText(value); + setHasText(true); + setSlashCommandInput(null); + inputRef.current?.setNativeProps({ + text: value, + selection: { start: value.length, end: value.length }, + }); + inputRef.current?.focus(); + } + async function submit() { + // `settleVoiceInputBeforeSubmit` is the sole admission owner for the + // entire voice-settle + asynchronous send sequence. It acquires the + // SubmitLock, sets pending state, waits for the final transcript, runs + // `handleSend`, and releases the lock in its finally block. Because the + // lock is held throughout, `handleSend` does not acquire or release it. await settleVoiceInputBeforeSubmit({ lock: submissionLockRef, onPendingChange: setIsSending, @@ -226,6 +354,15 @@ export function ChatComposer({ /> ) : null} + {slashCommandSuggestions.length > 0 && !isSending ? ( + + + + ) : null} + diff --git a/apps/mobile/src/components/agents/create-and-navigate-agent-session.test.ts b/apps/mobile/src/components/agents/create-and-navigate-agent-session.test.ts new file mode 100644 index 0000000000..543a88a251 --- /dev/null +++ b/apps/mobile/src/components/agents/create-and-navigate-agent-session.test.ts @@ -0,0 +1,91 @@ +import { type KiloSessionId } from 'cloud-agent-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { createAndNavigateAgentSession } from '@/components/agents/create-and-navigate-agent-session'; +import { CREATE_REMOTE_SESSION_FALLBACK_MESSAGE } from '@/components/agents/create-remote-session-with-feedback'; + +const SESSION_ID = 'ses_12345678901234567890123456' as KiloSessionId; +const ORG_ID = 'org_abc123'; + +function makeRouter() { + return { replace: vi.fn(() => undefined) }; +} + +describe('createAndNavigateAgentSession', () => { + it('replaces with the personal session route on success and returns the new id', async () => { + const router = makeRouter(); + const onError = vi.fn(() => undefined); + const create = vi.fn(async () => { + await Promise.resolve(); + return SESSION_ID; + }); + + const result = await createAndNavigateAgentSession({ create, router, onError }); + + expect(create).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true, sessionId: SESSION_ID }); + expect(router.replace).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith(`/(app)/agent-chat/${SESSION_ID}`); + }); + + it('preserves the organizationId when replacing on success', async () => { + const router = makeRouter(); + const onError = vi.fn(() => undefined); + + const result = await createAndNavigateAgentSession({ + create: vi.fn(async () => { + await Promise.resolve(); + return SESSION_ID; + }), + router, + organizationId: ORG_ID, + onError, + }); + + expect(onError).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true, sessionId: SESSION_ID }); + expect(router.replace).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith( + `/(app)/agent-chat/${SESSION_ID}?organizationId=${ORG_ID}` + ); + }); + + it('toasts once and never navigates when create rejects with an Error', async () => { + const router = makeRouter(); + const onError = vi.fn(() => undefined); + + const result = await createAndNavigateAgentSession({ + create: vi.fn(() => { + throw new Error('CLI_UPGRADE_REQUIRED'); + }), + router, + organizationId: ORG_ID, + onError, + }); + + expect(result).toEqual({ success: false }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith('CLI_UPGRADE_REQUIRED'); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it('toasts the fallback message once when create rejects with a non-Error value', async () => { + const router = makeRouter(); + const onError = vi.fn(() => undefined); + + const result = await createAndNavigateAgentSession({ + create: vi.fn(() => { + // eslint-disable-next-line no-throw-literal, @typescript-eslint/only-throw-error + throw 'mystery failure'; + }), + router, + onError, + }); + + expect(result).toEqual({ success: false }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(CREATE_REMOTE_SESSION_FALLBACK_MESSAGE); + expect(router.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/create-and-navigate-agent-session.ts b/apps/mobile/src/components/agents/create-and-navigate-agent-session.ts new file mode 100644 index 0000000000..201e9e28bb --- /dev/null +++ b/apps/mobile/src/components/agents/create-and-navigate-agent-session.ts @@ -0,0 +1,43 @@ +import { type KiloSessionId } from 'cloud-agent-sdk'; + +import { createRemoteSessionWithFeedback } from '@/components/agents/create-remote-session-with-feedback'; +import { replaceWithAgentSession } from '@/components/agents/session-detail-routes'; +import { type AgentSessionRouterLike } from '@/components/agents/session-router-like'; + +type CreateAndNavigateAgentSessionInput = { + create: () => Promise; + router: AgentSessionRouterLike; + onError: (message: string) => void; + organizationId?: string; +}; + +/** + * Orchestrate `manager.createRemoteSession()` with single-toast error feedback + * and `router.replace` to the new session route on success. + * + * - On success: the new `KiloSessionId` is the canonical session route key; + * `router.replace` is invoked exactly once before we resolve, so the + * composer's "accepted" signal (and draft clear) only fires after + * navigation has been initiated. The route-keyed `AgentSessionProvider` + * creates a fresh manager for the new id — we never call + * `manager.switchSession` here. + * - On failure: a single toast is surfaced through `onError` (using the + * underlying Error message, or a fallback for non-Error throws) and + * `router.replace` is never called, so the user stays on the current + * session with their draft preserved. + */ +export async function createAndNavigateAgentSession({ + create, + router, + onError, + organizationId, +}: Readonly): Promise< + { success: true; sessionId: KiloSessionId } | { success: false } +> { + const feedback = await createRemoteSessionWithFeedback(create, onError); + if (!feedback.success) { + return { success: false }; + } + replaceWithAgentSession(router, feedback.sessionId, organizationId); + return { success: true, sessionId: feedback.sessionId }; +} diff --git a/apps/mobile/src/components/agents/create-remote-session-with-feedback.test.ts b/apps/mobile/src/components/agents/create-remote-session-with-feedback.test.ts new file mode 100644 index 0000000000..d653625709 --- /dev/null +++ b/apps/mobile/src/components/agents/create-remote-session-with-feedback.test.ts @@ -0,0 +1,44 @@ +import { type KiloSessionId } from 'cloud-agent-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CREATE_REMOTE_SESSION_FALLBACK_MESSAGE, + createRemoteSessionWithFeedback, +} from '@/components/agents/create-remote-session-with-feedback'; + +describe('createRemoteSessionWithFeedback', () => { + it('returns success and the new session ID when create resolves', async () => { + const onError = vi.fn(() => undefined); + const result = await createRemoteSessionWithFeedback(async () => { + await Promise.resolve(); + return 'ses_12345678901234567890123456' as KiloSessionId; + }, onError); + + expect(result).toEqual({ success: true, sessionId: 'ses_12345678901234567890123456' }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('toasts the Error message once when create rejects with an Error', async () => { + const onError = vi.fn(() => undefined); + const result = await createRemoteSessionWithFeedback(() => { + throw new Error('CLI_UPGRADE_REQUIRED'); + }, onError); + + expect(result).toEqual({ success: false }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith('CLI_UPGRADE_REQUIRED'); + }); + + it('toasts the fallback message once when create rejects with a non-Error value', async () => { + const onError = vi.fn(() => undefined); + const nonError: unknown = 'mystery failure'; + const result = await createRemoteSessionWithFeedback(() => { + // eslint-disable-next-line no-throw-literal, @typescript-eslint/only-throw-error + throw nonError; + }, onError); + + expect(result).toEqual({ success: false }); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(CREATE_REMOTE_SESSION_FALLBACK_MESSAGE); + }); +}); diff --git a/apps/mobile/src/components/agents/create-remote-session-with-feedback.ts b/apps/mobile/src/components/agents/create-remote-session-with-feedback.ts new file mode 100644 index 0000000000..d46cdf7980 --- /dev/null +++ b/apps/mobile/src/components/agents/create-remote-session-with-feedback.ts @@ -0,0 +1,24 @@ +import { type KiloSessionId } from 'cloud-agent-sdk'; + +export const CREATE_REMOTE_SESSION_FALLBACK_MESSAGE = 'Failed to create remote session'; + +/** + * Run `createRemoteSession()` and surface exactly one actionable toast when it + * fails. The success result carries the new session ID so the caller can + * navigate after creation in a follow-up slice; the failure result is a stable + * boolean that lets the composer preserve its draft without adding a second + * toast. + */ +export async function createRemoteSessionWithFeedback( + create: () => Promise, + onError: (message: string) => void +): Promise<{ success: true; sessionId: KiloSessionId } | { success: false }> { + try { + const sessionId = await create(); + return { success: true, sessionId }; + } catch (error) { + const message = error instanceof Error ? error.message : CREATE_REMOTE_SESSION_FALLBACK_MESSAGE; + onError(message); + return { success: false }; + } +} diff --git a/apps/mobile/src/components/agents/exit-remote-cli-with-feedback.test.ts b/apps/mobile/src/components/agents/exit-remote-cli-with-feedback.test.ts new file mode 100644 index 0000000000..e63a2a9fef --- /dev/null +++ b/apps/mobile/src/components/agents/exit-remote-cli-with-feedback.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { exitRemoteCliWithFeedback } from '@/components/agents/exit-remote-cli-with-feedback'; + +const SESSIONS_ROUTE = '/(app)/(tabs)/(2_agents)'; + +describe('exitRemoteCliWithFeedback', () => { + it('calls the manager once, then success toast, then replaces with the sessions route', async () => { + const order: string[] = []; + const exit = vi.fn(async () => { + order.push('exit'); + await Promise.resolve(); + }); + const onSuccess = vi.fn(() => { + order.push('success'); + }); + const onError = vi.fn((_message: string): void => undefined); + const onAccepted = vi.fn(() => { + order.push('accepted'); + }); + const router = { + replace: vi.fn(() => { + order.push('replace'); + }), + }; + + await exitRemoteCliWithFeedback({ exit, onAccepted, onSuccess, onError, router }); + + expect(exit).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledWith('CLI exited'); + expect(router.replace).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith(SESSIONS_ROUTE); + expect(onError).not.toHaveBeenCalled(); + expect(order).toEqual(['exit', 'accepted', 'success', 'replace']); + }); + + it('shows one actionable error and rethrows without success or navigation', async () => { + const error = new Error('Upgrade Kilo Code CLI to exit remotely.'); + const exit = vi.fn(async () => { + await Promise.resolve(); + throw error; + }); + const onSuccess = vi.fn((_message: string): void => undefined); + const onError = vi.fn((_message: string): void => undefined); + const onAccepted = vi.fn((): void => undefined); + const router = { replace: vi.fn() }; + + await expect( + exitRemoteCliWithFeedback({ exit, onAccepted, onSuccess, onError, router }) + ).rejects.toBe(error); + + expect(exit).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(error.message); + expect(onAccepted).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(router.replace).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/exit-remote-cli-with-feedback.ts b/apps/mobile/src/components/agents/exit-remote-cli-with-feedback.ts new file mode 100644 index 0000000000..cd61aa228d --- /dev/null +++ b/apps/mobile/src/components/agents/exit-remote-cli-with-feedback.ts @@ -0,0 +1,31 @@ +import { type AgentSessionRouterLike } from '@/components/agents/session-router-like'; + +type ExitRemoteCliWithFeedbackInput = { + exit: () => Promise; + onAccepted: () => void; + onSuccess: (message: string) => void; + onError: (message: string) => void; + router: AgentSessionRouterLike; +}; + +const SESSIONS_ROUTE = '/(app)/(tabs)/(2_agents)' as const; + +export async function exitRemoteCliWithFeedback({ + exit, + onAccepted, + onSuccess, + onError, + router, +}: Readonly): Promise { + try { + await exit(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to exit CLI'; + onError(message); + throw error; + } + + onAccepted(); + onSuccess('CLI exited'); + router.replace(SESSIONS_ROUTE); +} diff --git a/apps/mobile/src/components/agents/mobile-session-manager.test.ts b/apps/mobile/src/components/agents/mobile-session-manager.test.ts index 07563c5892..a664cb53f2 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.test.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.test.ts @@ -23,6 +23,7 @@ const userWebConnection: UserWebConnection = { destroy: vi.fn(() => undefined), subscribeToCliSession: vi.fn(() => noCleanup), sendCommand: vi.fn(), + sendCommandToConnection: vi.fn(), onCliEvent: vi.fn(() => noCleanup), onSystemEvent: vi.fn(() => noCleanup), onReconnect: vi.fn(() => noCleanup), diff --git a/apps/mobile/src/components/agents/remote-cli-exit-alert.ts b/apps/mobile/src/components/agents/remote-cli-exit-alert.ts new file mode 100644 index 0000000000..0ad0101854 --- /dev/null +++ b/apps/mobile/src/components/agents/remote-cli-exit-alert.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/promise-function-async, require-await -- Native Alert callbacks settle this Promise asynchronously. */ +import { Alert } from 'react-native'; + +export function showRemoteCliExitConfirmation(): Promise { + return new Promise(resolve => { + let settled = false; + const settle = (confirmed: boolean) => { + if (settled) { + return; + } + settled = true; + resolve(confirmed); + }; + + Alert.alert( + 'Exit CLI?', + 'This will stop the CLI on your computer and take all sessions connected to it offline.', + [ + { + text: 'Keep CLI running', + style: 'cancel', + onPress: () => { + settle(false); + }, + }, + { + text: 'Exit CLI', + style: 'destructive', + onPress: () => { + settle(true); + }, + }, + ], + { + cancelable: true, + onDismiss: () => { + settle(false); + }, + } + ); + }); +} diff --git a/apps/mobile/src/components/agents/remote-cli-exit-confirmation.test.ts b/apps/mobile/src/components/agents/remote-cli-exit-confirmation.test.ts new file mode 100644 index 0000000000..729cbfea52 --- /dev/null +++ b/apps/mobile/src/components/agents/remote-cli-exit-confirmation.test.ts @@ -0,0 +1,98 @@ +import { type AlertButton } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { confirmRemoteCliExit } from '@/components/agents/remote-cli-exit-confirmation'; +import { showRemoteCliExitConfirmation } from '@/components/agents/remote-cli-exit-alert'; + +const reactNativeMock = vi.hoisted(() => ({ + alert: vi.fn<(_title: string, _message: string, buttons: AlertButton[]) => void>(), +})); + +vi.mock('react-native', () => ({ Alert: { alert: reactNativeMock.alert } })); + +describe('showRemoteCliExitConfirmation', () => { + beforeEach(() => { + reactNativeMock.alert.mockReset(); + }); + + it('uses the exact native destructive Alert configuration', () => { + void showRemoteCliExitConfirmation(); + + expect(reactNativeMock.alert).toHaveBeenCalledTimes(1); + expect(reactNativeMock.alert).toHaveBeenCalledWith( + 'Exit CLI?', + 'This will stop the CLI on your computer and take all sessions connected to it offline.', + [ + { text: 'Keep CLI running', style: 'cancel', onPress: expect.any(Function) }, + { text: 'Exit CLI', style: 'destructive', onPress: expect.any(Function) }, + ], + { cancelable: true, onDismiss: expect.any(Function) } + ); + }); + + it('settles once when destructive callbacks fire more than once', async () => { + const exit = vi.fn(async () => { + await Promise.resolve(); + }); + const confirmation = showRemoteCliExitConfirmation(); + const buttons = reactNativeMock.alert.mock.calls[0]?.[2]; + + buttons?.[1]?.onPress?.(); + buttons?.[1]?.onPress?.(); + + await expect( + confirmRemoteCliExit(async () => { + await Promise.resolve(); + return confirmation; + }, exit) + ).resolves.toBe('accepted'); + expect(exit).toHaveBeenCalledTimes(1); + }); +}); + +describe('confirmRemoteCliExit', () => { + it('returns cancelled without exiting', async () => { + const exit = vi.fn(async () => { + await Promise.resolve(); + }); + + await expect( + confirmRemoteCliExit(async () => { + await Promise.resolve(); + return false; + }, exit) + ).resolves.toBe('cancelled'); + expect(exit).not.toHaveBeenCalled(); + }); + + it('calls exit once and returns accepted after it settles', async () => { + const exit = vi.fn(async () => { + await Promise.resolve(); + }); + + await expect( + confirmRemoteCliExit(async () => { + await Promise.resolve(); + return true; + }, exit) + ).resolves.toBe('accepted'); + expect(exit).toHaveBeenCalledTimes(1); + }); + + it('propagates exit failure', async () => { + const error = new Error('Upgrade the CLI first'); + + await expect( + confirmRemoteCliExit( + async () => { + await Promise.resolve(); + return true; + }, + async () => { + await Promise.resolve(); + throw error; + } + ) + ).rejects.toBe(error); + }); +}); diff --git a/apps/mobile/src/components/agents/remote-cli-exit-confirmation.ts b/apps/mobile/src/components/agents/remote-cli-exit-confirmation.ts new file mode 100644 index 0000000000..36e6dd8df8 --- /dev/null +++ b/apps/mobile/src/components/agents/remote-cli-exit-confirmation.ts @@ -0,0 +1,12 @@ +type RemoteCliExitConfirmation = () => Promise; + +export async function confirmRemoteCliExit( + confirm: RemoteCliExitConfirmation, + exit: () => Promise +): Promise<'accepted' | 'cancelled'> { + if (!(await confirm())) { + return 'cancelled'; + } + await exit(); + return 'accepted'; +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index c2927bba06..3a4467563c 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -10,6 +10,8 @@ import { toast } from 'sonner-native'; import { getBlockingInteraction } from '@/components/agents/agent-interaction-policy'; import { ChatComposer } from '@/components/agents/chat-composer'; +import { createAndNavigateAgentSession } from '@/components/agents/create-and-navigate-agent-session'; +import { exitRemoteCliWithFeedback } from '@/components/agents/exit-remote-cli-with-feedback'; import { ConnectivityBanner } from '@/components/agents/connectivity-banner'; import { MessageBubble } from '@/components/agents/message-bubble'; import { ModelPickerSelectionScopeProvider } from '@/components/agents/model-selector'; @@ -124,6 +126,8 @@ export function SessionDetailContent({ const remoteModelState = useAtomValue(manager.atoms.remoteModelState); const observedModel = useAtomValue(manager.atoms.observedModel); const remoteModelOverride = useAtomValue(manager.atoms.remoteModelOverride); + const availableCommands = useAtomValue(manager.atoms.availableCommands); + const remoteCommandState = useAtomValue(manager.atoms.remoteCommandState); const contextUsage = useAtomValue(manager.atoms.contextUsage); const hasOlderMessages = useAtomValue(manager.atoms.hasOlderMessages); const isLoadingOlderMessages = useAtomValue(manager.atoms.isLoadingOlderMessages); @@ -482,6 +486,63 @@ export function SessionDetailContent({ ] ); + const handleSendCommand = useCallback( + async (command: string, argumentsText: string) => { + // Slash commands ride the same manager.send() pipeline. The manager + // resolves the active remoteModelOverride from its own store and is + // the sole transport-toast owner; we throw a stable error on a + // false return purely so the composer preserves the draft, and never + // emit a duplicate toast of our own. + const sent = await manager.send({ + payload: { type: 'command', command, arguments: argumentsText }, + }); + if (!sent) { + throw new Error('Failed to send slash command'); + } + return true; + }, + [manager] + ); + + const handleCreateSession = useCallback(async () => { + // The orchestrator surfaces exactly one actionable toast on failure and + // calls `router.replace` to the new session route on success — the + // route-keyed `AgentSessionProvider` creates a fresh manager for the new + // id, so we deliberately do not `manager.switchSession()` here. The + // resolve order (replace → resolve) is what makes the composer's + // "accepted" signal fire only after navigation has been initiated, so + // the draft is cleared exactly when the new route is being pushed. + // No cache mutation: the destination route fetches its own session + // via trpc, the active-sessions poll picks up the new id on its next + // tick, and the agents tab refetches on focus. + const result = await createAndNavigateAgentSession({ + create: manager.createRemoteSession.bind(manager), + router, + organizationId, + onError: message => { + toast.error(message); + }, + }); + return result.success; + }, [manager, router, organizationId]); + + const handleExitCli = useCallback( + async (onAccepted: () => void) => { + await exitRemoteCliWithFeedback({ + exit: manager.exitRemoteCli.bind(manager), + onAccepted, + onSuccess: message => { + toast.success(message); + }, + onError: message => { + toast.error(message); + }, + router, + }); + }, + [manager, router] + ); + return ( diff --git a/apps/mobile/src/components/agents/session-detail-routes.test.ts b/apps/mobile/src/components/agents/session-detail-routes.test.ts new file mode 100644 index 0000000000..f28b8757fa --- /dev/null +++ b/apps/mobile/src/components/agents/session-detail-routes.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + getAgentSessionPath, + replaceWithAgentSession, +} from '@/components/agents/session-detail-routes'; + +const SESSION_ID = 'ses_12345678901234567890123456'; +const ORG_ID = 'org_abc123'; + +describe('getAgentSessionPath', () => { + it('routes personal sessions to the canonical agent-chat detail screen', () => { + expect(getAgentSessionPath(SESSION_ID)).toBe(`/(app)/agent-chat/${SESSION_ID}`); + }); + + it('preserves the organization context when provided', () => { + expect(getAgentSessionPath(SESSION_ID, ORG_ID)).toBe( + `/(app)/agent-chat/${SESSION_ID}?organizationId=${ORG_ID}` + ); + }); + + it('treats an empty organizationId as the personal case', () => { + expect(getAgentSessionPath(SESSION_ID, '')).toBe(`/(app)/agent-chat/${SESSION_ID}`); + }); +}); + +describe('replaceWithAgentSession', () => { + it('replaces with the personal agent-chat route exactly once', () => { + const router = { replace: vi.fn(() => undefined) }; + + replaceWithAgentSession(router, SESSION_ID); + + expect(router.replace).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith(`/(app)/agent-chat/${SESSION_ID}`); + }); + + it('replaces with the org-scoped agent-chat route when organizationId is provided', () => { + const router = { replace: vi.fn(() => undefined) }; + + replaceWithAgentSession(router, SESSION_ID, ORG_ID); + + expect(router.replace).toHaveBeenCalledTimes(1); + expect(router.replace).toHaveBeenCalledWith( + `/(app)/agent-chat/${SESSION_ID}?organizationId=${ORG_ID}` + ); + }); +}); diff --git a/apps/mobile/src/components/agents/session-detail-routes.ts b/apps/mobile/src/components/agents/session-detail-routes.ts new file mode 100644 index 0000000000..9f3afd80cb --- /dev/null +++ b/apps/mobile/src/components/agents/session-detail-routes.ts @@ -0,0 +1,31 @@ +import { type Href } from 'expo-router'; + +import { type AgentSessionRouterLike } from '@/components/agents/session-router-like'; + +/** + * Build the canonical agent-chat detail `Href` for a session, preserving the + * organization context when the source route was org-scoped. The route is + * keyed by `session-id` in `apps/mobile/src/app/(app)/agent-chat/[session-id].tsx`, + * and the provider at that route re-keys on the `organizationId` so the new + * session's manager picks up the right org context. + */ +export function getAgentSessionPath(kiloSessionId: string, organizationId?: string): Href { + return organizationId + ? (`/(app)/agent-chat/${kiloSessionId}?organizationId=${organizationId}` as Href) + : (`/(app)/agent-chat/${kiloSessionId}` as Href); +} + +/** + * Replace the current route with the canonical agent-chat detail for the new + * session. Using `replace` (not `push`) means the previous session route is + * not on the stack, so the system back gesture does not return to it. The + * route-keyed `AgentSessionProvider` recreates the manager for the new id, so + * the caller does not switch the current manager before navigation. + */ +export function replaceWithAgentSession( + router: AgentSessionRouterLike, + kiloSessionId: string, + organizationId?: string +): void { + router.replace(getAgentSessionPath(kiloSessionId, organizationId)); +} diff --git a/apps/mobile/src/components/agents/session-router-like.ts b/apps/mobile/src/components/agents/session-router-like.ts new file mode 100644 index 0000000000..081800fe05 --- /dev/null +++ b/apps/mobile/src/components/agents/session-router-like.ts @@ -0,0 +1,11 @@ +import { type Href } from 'expo-router'; + +/** + * The structural subset of Expo Router's router used by the agent-chat + * navigation helpers. Importing the full `Router` type from `expo-router` + * pulls in native modules; this shape keeps the helpers unit-testable with + * a plain mock. + */ +export type AgentSessionRouterLike = { + replace: (href: Href) => void; +}; diff --git a/apps/mobile/src/components/agents/slash-command-suggestions.tsx b/apps/mobile/src/components/agents/slash-command-suggestions.tsx new file mode 100644 index 0000000000..8a21a3308f --- /dev/null +++ b/apps/mobile/src/components/agents/slash-command-suggestions.tsx @@ -0,0 +1,58 @@ +import { type SlashCommandInfo } from 'cloud-agent-sdk'; +import { Pressable, ScrollView, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; + +type SlashCommandSuggestionsProps = { + commands: SlashCommandInfo[]; + onSelect: (command: SlashCommandInfo) => void; +}; + +/** + * Slash command suggestions rendered inline directly above the chat composer. + * + * The list is a sibling of the TextInput, not a modal/overlay — tapping a row + * commits the chosen command back into the composer's existing uncontrolled + * input via the `onSelect` callback. Rows are 44pt tall to satisfy the + * platform touch-target minimum and announce their command via accessibility + * labels. + */ +export function SlashCommandSuggestions({ + commands, + onSelect, +}: Readonly) { + if (commands.length === 0) { + return null; + } + + return ( + + {commands.map(command => ( + { + onSelect(command); + }} + accessibilityRole="button" + accessibilityLabel={`Use /${command.name}`} + accessibilityHint={command.description ?? undefined} + hitSlop={4} + className="min-h-[44px] flex-row items-center justify-between gap-3 border-b border-black/5 px-4 py-2 active:bg-muted dark:border-white/5" + > + + /{command.name} + {command.description ? ( + + {command.description} + + ) : null} + + Insert + + ))} + + ); +} diff --git a/apps/mobile/src/lib/submit-lock.test.ts b/apps/mobile/src/lib/submit-lock.test.ts new file mode 100644 index 0000000000..41c9b64683 --- /dev/null +++ b/apps/mobile/src/lib/submit-lock.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { createSubmitLock } from '@/lib/submit-lock'; + +describe('createSubmitLock', () => { + it('reports not locked initially, locked after acquire, and not locked after release', () => { + const lock = createSubmitLock(); + expect(lock.isLocked()).toBe(false); + lock.acquire(); + expect(lock.isLocked()).toBe(true); + lock.release(); + expect(lock.isLocked()).toBe(false); + }); + + it('acquires on first call and rejects a second synchronous acquire', () => { + const lock = createSubmitLock(); + expect(lock.acquire()).toBe(true); + expect(lock.acquire()).toBe(false); + }); + + it('releases so the next acquire succeeds', () => { + const lock = createSubmitLock(); + expect(lock.acquire()).toBe(true); + lock.release(); + expect(lock.acquire()).toBe(true); + }); + + it('blocks a concurrent async attempt while the first is in flight', async () => { + const lock = createSubmitLock(); + const outcomes: string[] = []; + + async function attempt(label: string) { + if (!lock.acquire()) { + outcomes.push(`${label}:busy`); + return; + } + try { + outcomes.push(`${label}:acquired`); + await Promise.resolve(); + await Promise.resolve(); + } finally { + lock.release(); + } + } + + await Promise.all([attempt('first'), attempt('second')]); + expect(outcomes).toEqual(['first:acquired', 'second:busy']); + }); + + it('releases on callback failure so a manual retry can re-acquire', async () => { + const lock = createSubmitLock(); + + async function attemptThatThrows() { + if (!lock.acquire()) { + throw new Error('should not be busy'); + } + try { + await Promise.resolve(); + throw new Error('boom'); + } finally { + lock.release(); + } + } + + await expect(attemptThatThrows()).rejects.toThrow('boom'); + expect(lock.acquire()).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/submit-lock.ts b/apps/mobile/src/lib/submit-lock.ts new file mode 100644 index 0000000000..ddb5b691fa --- /dev/null +++ b/apps/mobile/src/lib/submit-lock.ts @@ -0,0 +1,37 @@ +/** + * A minimal synchronous submission lock. + * + * React state updates are batched and deferred to the next render, so two + * synchronous `handleSend()` invocations in the same tick can both observe the + * captured `canSend=true` and proceed. A ref-backed lock with synchronous + * acquire/release semantics closes that window: the second call sees the lock + * held and bails before any side effect, upload, parser, or callback runs. + */ +export type SubmitLock = { + acquire(): boolean; + release(): void; + /** + * Read-only state for synchronous guards that cannot safely mutate the lock + * (e.g. ignoring a tap on a suggestion while a send is already in flight). + */ + isLocked(): boolean; +}; + +export function createSubmitLock(): SubmitLock { + let locked = false; + return { + acquire() { + if (locked) { + return false; + } + locked = true; + return true; + }, + release() { + locked = false; + }, + isLocked() { + return locked; + }, + }; +} diff --git a/apps/mobile/src/lib/voice-input/voice-input-submit.test.ts b/apps/mobile/src/lib/voice-input/voice-input-submit.test.ts index 6d80f08a92..6e2ad2bc57 100644 --- a/apps/mobile/src/lib/voice-input/voice-input-submit.test.ts +++ b/apps/mobile/src/lib/voice-input/voice-input-submit.test.ts @@ -1,7 +1,26 @@ import { describe, expect, it, vi } from 'vitest'; +import { createSubmitLock } from '@/lib/submit-lock'; + import { settleVoiceInputBeforeSubmit } from './voice-input-submit'; +function createSubmitLockAdapter() { + const lock = createSubmitLock(); + const adapter: { current: boolean } = { + get current() { + return lock.isLocked(); + }, + set current(next: boolean) { + if (next) { + lock.acquire(); + } else { + lock.release(); + } + }, + }; + return { lock, adapter }; +} + describe('settleVoiceInputBeforeSubmit', () => { it('rejects a duplicate submission while the first submission is settling', async () => { const settlement = Promise.withResolvers(); @@ -118,17 +137,31 @@ describe('settleVoiceInputBeforeSubmit', () => { expect(submit).not.toHaveBeenCalled(); }); - it('invokes submit exactly once when settlement returns true', async () => { - const submit = vi.fn<() => void>(); + it('invokes submit exactly once and rejects a synchronous duplicate while the lock stays held until asynchronous submit completes', async () => { + const { lock, adapter } = createSubmitLockAdapter(); + const submit = vi.fn(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); - await expect( - settleVoiceInputBeforeSubmit({ - lock: { current: false }, - settleVoiceInput: vi.fn().mockResolvedValueOnce(true), - submit, - }) - ).resolves.toBe(true); + const first = settleVoiceInputBeforeSubmit({ + lock: adapter, + settleVoiceInput: vi.fn().mockResolvedValueOnce(true), + submit, + }); + const duplicate = await settleVoiceInputBeforeSubmit({ + lock: adapter, + settleVoiceInput: vi.fn().mockResolvedValueOnce(true), + submit, + }); + + expect(duplicate).toBe(false); + expect(submit).toHaveBeenCalledTimes(1); + expect(lock.isLocked()).toBe(true); + + await expect(first).resolves.toBe(true); expect(submit).toHaveBeenCalledTimes(1); + expect(lock.isLocked()).toBe(false); }); }); diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index ccb9839fab..eebcd19a60 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -18,6 +18,9 @@ export default defineConfig({ 'cloud-agent-sdk/remote-model-order': fileURLToPath( new URL('../../apps/web/src/lib/cloud-agent-sdk/remote-model-order.ts', import.meta.url) ), + 'cloud-agent-sdk/remote-command-catalog': fileURLToPath( + new URL('../../apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.ts', import.meta.url) + ), 'cloud-agent-sdk': fileURLToPath( new URL('../../apps/web/src/lib/cloud-agent-sdk/index.ts', import.meta.url) ), diff --git a/apps/web/src/components/cloud-agent-next/BrowseCommandsDialog.tsx b/apps/web/src/components/cloud-agent-next/BrowseCommandsDialog.tsx index fdb003404b..c4802b1dd4 100644 --- a/apps/web/src/components/cloud-agent-next/BrowseCommandsDialog.tsx +++ b/apps/web/src/components/cloud-agent-next/BrowseCommandsDialog.tsx @@ -13,15 +13,23 @@ import { } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { HelpCircle } from 'lucide-react'; +import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; import { useSlashCommandSets } from '@/hooks/useSlashCommandSets'; +import { selectBrowseCommandSets } from './browse-command-sets'; type BrowseCommandsDialogProps = { trigger?: React.ReactNode; + /** Explicit command list. When provided, the dialog renders exactly these + * commands so callers (NewSessionPanel, ChatInput) can keep browse and + * autocomplete in sync. Without it, the dialog falls back to the active + * session's hook-derived command sets. */ + commands?: SlashCommand[]; }; -export function BrowseCommandsDialog({ trigger }: BrowseCommandsDialogProps) { +export function BrowseCommandsDialog({ trigger, commands }: BrowseCommandsDialogProps) { const { allSets } = useSlashCommandSets(); const [open, setOpen] = useState(false); + const displayedSets = selectBrowseCommandSets(allSets, commands); return ( @@ -47,7 +55,7 @@ export function BrowseCommandsDialog({ trigger }: BrowseCommandsDialogProps) {
- {allSets.map(set => ( + {displayedSets.map(set => (
diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index 106190a588..a17265bc40 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -590,7 +590,7 @@ export function ChatInput({ )} {slashCommands.length > 0 && (
- +
)}
diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index 47fdcc3f6f..4d145dce09 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -1445,7 +1445,7 @@ export function NewSessionPanel({ organizationId, isDevcontainerAvailable }: New {slashCommands.length > 0 && (
- +
)} diff --git a/apps/web/src/components/cloud-agent-next/browse-command-sets.test.ts b/apps/web/src/components/cloud-agent-next/browse-command-sets.test.ts new file mode 100644 index 0000000000..93339c4926 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/browse-command-sets.test.ts @@ -0,0 +1,43 @@ +import type { CommandSet, SlashCommand } from '@/lib/cloud-agent/slash-commands'; +import { selectBrowseCommandSets } from './browse-command-sets'; + +const explicitCommands: SlashCommand[] = [ + { trigger: 'review', label: 'review', description: 'Review changes', expansion: '' }, + { trigger: 'custom', label: 'custom', description: 'Custom profile command', expansion: '' }, +]; + +const hookSets: CommandSet[] = [ + { + id: 'kilo', + name: 'Kilo', + description: 'Project and MCP commands available in this session', + prefix: '', + commands: [{ trigger: 'default', label: 'default', description: 'Default', expansion: '' }], + }, +]; + +describe('selectBrowseCommandSets', () => { + it('uses exactly the explicit commands when provided, overriding hook sets', () => { + const result = selectBrowseCommandSets(hookSets, explicitCommands); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'kilo', + name: 'Kilo', + description: 'Project and MCP commands available in this session', + prefix: '', + }); + expect(result[0].commands).toEqual(explicitCommands); + expect(result[0].commands).not.toEqual(hookSets[0].commands); + }); + + it('uses hook sets when no explicit commands are supplied', () => { + const result = selectBrowseCommandSets(hookSets, undefined); + expect(result).toEqual(hookSets); + }); + + it('preserves an empty explicit command list exactly instead of falling back to hook sets', () => { + const result = selectBrowseCommandSets(hookSets, []); + expect(result).toHaveLength(1); + expect(result[0].commands).toEqual([]); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/browse-command-sets.ts b/apps/web/src/components/cloud-agent-next/browse-command-sets.ts new file mode 100644 index 0000000000..9d73f98655 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/browse-command-sets.ts @@ -0,0 +1,31 @@ +import type { CommandSet, SlashCommand } from '@/lib/cloud-agent/slash-commands'; + +/** + * Choose which command sets the BrowseCommandsDialog should render. + * + * Callers that already know their exact slash-command list (e.g. NewSessionPanel + * and ChatInput) can pass `explicitCommands` to guarantee the browse dialog + * matches the adjacent autocomplete. Callers without explicit commands fall back + * to the sets produced by useSlashCommandSets. + * + * When explicit commands are supplied, the synthetic set keeps the same + * id/name/description/prefix as the hook-derived set so the dialog remains + * visually consistent; only the command list is replaced exactly. + */ +export function selectBrowseCommandSets( + allSets: CommandSet[], + explicitCommands?: SlashCommand[] +): CommandSet[] { + if (explicitCommands !== undefined) { + return [ + { + id: 'kilo', + name: 'Kilo', + description: 'Project and MCP commands available in this session', + prefix: '', + commands: explicitCommands, + }, + ]; + } + return allSets; +} diff --git a/apps/web/src/hooks/slash-command-selection.ts b/apps/web/src/hooks/slash-command-selection.ts new file mode 100644 index 0000000000..8b2ac1c45e --- /dev/null +++ b/apps/web/src/hooks/slash-command-selection.ts @@ -0,0 +1,46 @@ +import { commandsOrDefault } from '@cloud-agent-shared'; +import type { SlashCommandInfo } from '@/lib/cloud-agent-sdk'; +import type { ActiveSessionType } from '@/lib/cloud-agent-sdk'; +import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; + +/** + * Pure selector: given the manager's active session type and the most recent + * reported command list, return the commands the chat composer should + * surface. + * + * - `cloud-agent` keeps the historical pinned-default fallback: when the + * wrapper has not (yet) reported any commands we still want autocomplete + * to suggest the project and MCP defaults the wrapper would have published. + * The `commandsOrDefault` helper also appends local session commands + * (compaction) that the wrapper does not register. + * - `remote` sessions use exactly the live CLI catalog. We never substitute + * the Cloud Agent defaults because those commands do not exist in a + * remote CLI session and suggesting them would be misleading. An empty + * catalog means "no commands available", not "fall back to defaults". + * - `read-only` and `null` (unresolved) sessions expose no commands. + * + * `expansion` is intentionally empty: the cloud-agent worker receives a + * structured `command` payload and performs any template substitution, so + * the client must not invent `$ARGUMENTS`/`$1`/etc. expansions here. + */ +export function selectSlashCommands( + sessionType: ActiveSessionType | null, + commands: SlashCommandInfo[] +): SlashCommand[] { + const selectedCommands = + sessionType === 'cloud-agent' + ? commandsOrDefault(commands) + : sessionType === 'remote' + ? commands + : []; + return selectedCommands.map(toSlashCommand); +} + +function toSlashCommand(info: SlashCommandInfo): SlashCommand { + return { + trigger: info.name, + label: info.name, + description: info.description ?? '', + expansion: '', + }; +} diff --git a/apps/web/src/hooks/useSlashCommandSets.test.ts b/apps/web/src/hooks/useSlashCommandSets.test.ts new file mode 100644 index 0000000000..ad3cbff8b1 --- /dev/null +++ b/apps/web/src/hooks/useSlashCommandSets.test.ts @@ -0,0 +1,204 @@ +import type { SlashCommandInfo } from '@/lib/cloud-agent-sdk'; +import type { ActiveSessionType } from '@/lib/cloud-agent-sdk'; +import { atom, createStore } from 'jotai'; +import type { SessionManager } from '@/lib/cloud-agent-sdk'; +import { selectSlashCommands } from './slash-command-selection'; +import { useSlashCommandSets } from './useSlashCommandSets'; + +// The shared package is mocked so that tests of the routing logic do not depend +// on the exact contents of the real pinned default catalog or the +// session-local compaction append. commandsOrDefault is deliberately simplified +// to isolate the selector rule (cloud-agent falls back to defaults, remote does +// not) from the actual shared implementation. +jest.mock( + '@cloud-agent-shared', + () => ({ + commandsOrDefault: (commands: SlashCommandInfo[] | null | undefined) => + commands && commands.length > 0 + ? commands + : [ + { + name: 'compact', + description: 'compact the current session context', + hints: [], + }, + ], + }), + { virtual: true } +); + +jest.mock('@/components/cloud-agent-next/CloudAgentProvider', () => ({ + useManager: (): SessionManager => mockManager as SessionManager, +})); + +let mockManager: SessionManager; + +const reportedRemoteCommands: SlashCommandInfo[] = [ + { + name: 'review', + description: 'Review changes', + hints: ['$ARGUMENTS'], + }, + { + name: 'init', + hints: [], + }, +]; + +describe('selectSlashCommands', () => { + it('preserves the cloud-agent default fallback when the live list is empty', () => { + expect(selectSlashCommands('cloud-agent', [])).toEqual([ + { + trigger: 'compact', + label: 'compact', + description: 'compact the current session context', + expansion: '', + }, + ]); + }); + + it('uses the live list verbatim for cloud-agent sessions when it is non-empty', () => { + expect(selectSlashCommands('cloud-agent', reportedRemoteCommands)).toEqual([ + { + trigger: 'review', + label: 'review', + description: 'Review changes', + expansion: '', + }, + { + trigger: 'init', + label: 'init', + description: '', + expansion: '', + }, + ]); + }); + + it('uses only the live CLI catalog for remote sessions and never falls back to defaults', () => { + expect(selectSlashCommands('remote', reportedRemoteCommands)).toEqual([ + { + trigger: 'review', + label: 'review', + description: 'Review changes', + expansion: '', + }, + { + trigger: 'init', + label: 'init', + description: '', + expansion: '', + }, + ]); + }); + + it('returns an empty list for remote sessions when the live catalog is empty', () => { + expect(selectSlashCommands('remote', [])).toEqual([]); + }); + + it('exposes no commands for read-only sessions even when the catalog is populated', () => { + expect(selectSlashCommands('read-only', reportedRemoteCommands)).toEqual([]); + }); + + it('exposes no commands before the session type is resolved', () => { + expect(selectSlashCommands(null, reportedRemoteCommands)).toEqual([]); + }); + + it('maps SlashCommandInfo to the SlashCommand UI shape without inventing templates', () => { + const result = selectSlashCommands('remote', [ + { + name: 'review', + description: 'Review changes', + hints: ['$ARGUMENTS', 'some ignored template'], + }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + trigger: 'review', + label: 'review', + description: 'Review changes', + expansion: '', + }); + // No $ARGUMENTS / $1 / $2 substitution is performed here — the cloud-agent + // worker still receives the structured payload. + expect(result[0]?.expansion).toBe(''); + }); + + it('coerces a missing description to an empty string for autocomplete', () => { + expect( + selectSlashCommands('remote', [{ name: 'no-desc', hints: [] } as SlashCommandInfo]) + ).toEqual([ + { + trigger: 'no-desc', + label: 'no-desc', + description: '', + expansion: '', + }, + ]); + }); +}); + +describe('useSlashCommandSets', () => { + let store: ReturnType; + let availableCommandsAtom: ReturnType>; + let activeSessionTypeAtom: ReturnType>; + + beforeEach(() => { + store = createStore(); + availableCommandsAtom = atom([]); + activeSessionTypeAtom = atom(null); + mockManager = { + atoms: { + availableCommands: availableCommandsAtom, + activeSessionType: activeSessionTypeAtom, + }, + } as unknown as SessionManager; + }); + + it('recomputes availableCommands when activeSessionType or availableCommands atoms change', () => { + // This exercises the same atoms that useSlashCommandSets reads. The hook + // is a thin wrapper around useAtomValue for these two atoms plus the + // selectSlashCommands selector, so driving the store directly proves the + // reactive output the hook would return on each re-render. + + // 1. Remote session with a live CLI catalog: surface the reported commands. + store.set(activeSessionTypeAtom, 'remote'); + store.set(availableCommandsAtom, reportedRemoteCommands); + expect( + selectSlashCommands(store.get(activeSessionTypeAtom), store.get(availableCommandsAtom)) + ).toEqual([ + { + trigger: 'review', + label: 'review', + description: 'Review changes', + expansion: '', + }, + { + trigger: 'init', + label: 'init', + description: '', + expansion: '', + }, + ]); + + // 2. Switch session type to read-only: output becomes empty. + store.set(activeSessionTypeAtom, 'read-only'); + expect( + selectSlashCommands(store.get(activeSessionTypeAtom), store.get(availableCommandsAtom)) + ).toEqual([]); + + // 3. Switch back to remote, but the wrapper reports an empty catalog: + // remote sessions stay empty instead of falling back to Cloud defaults. + store.set(activeSessionTypeAtom, 'remote'); + store.set(availableCommandsAtom, []); + expect( + selectSlashCommands(store.get(activeSessionTypeAtom), store.get(availableCommandsAtom)) + ).toEqual([]); + }); + + it('exports a stable public API shape', () => { + // Sanity check that the hook module remains a callable hook that returns + // the expected object shape for consumers (BrowseCommandsDialog, CloudChatPage). + expect(typeof useSlashCommandSets).toBe('function'); + }); +}); diff --git a/apps/web/src/hooks/useSlashCommandSets.ts b/apps/web/src/hooks/useSlashCommandSets.ts index 4c80b29244..57de157f6d 100644 --- a/apps/web/src/hooks/useSlashCommandSets.ts +++ b/apps/web/src/hooks/useSlashCommandSets.ts @@ -1,9 +1,8 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useManager } from '@/components/cloud-agent-next/CloudAgentProvider'; -import type { SlashCommandInfo } from '@/lib/cloud-agent-sdk'; -import { commandsOrDefault } from '@cloud-agent-shared'; import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; +import { selectSlashCommands } from './slash-command-selection'; /** * Source of slash commands for the chat composer. @@ -11,10 +10,15 @@ import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; * The list comes from the cloud-agent session manager's `availableCommands` * Jotai atom, which is hydrated by `commands.available` events sent by the * cloud-agent worker on every /stream connect (and any time the wrapper - * re-pushes the catalog). When the live list is empty (no wrapper connection - * yet, or wrapper reported no commands), this hook falls back to the pinned - * default catalog so the new-session screen and empty-wrapper cases still - * get autocomplete. + * re-pushes the catalog). The active session type, also exposed by the + * manager, drives which slice of the catalog (or fallbacks) the composer + * should surface: + * + * - Cloud Agent sessions keep the historical pinned-default fallback so the + * new-session screen and empty-wrapper cases still get autocomplete. + * - Remote sessions use only the exact live CLI catalog — empty stays empty + * and the Cloud Agent defaults are not substituted in. + * - Read-only and unresolved (null) sessions expose no commands. * * `expansion` is vestigial — kept for type compatibility with the existing * `SlashCommand` UI shape, but unused now that ChatInput invokes the @@ -23,10 +27,11 @@ import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; export function useSlashCommandSets() { const manager = useManager(); const commands = useAtomValue(manager.atoms.availableCommands); + const activeSessionType = useAtomValue(manager.atoms.activeSessionType); const availableCommands: SlashCommand[] = useMemo( - () => commandsOrDefault(commands).map(toSlashCommand), - [commands] + () => selectSlashCommands(activeSessionType, commands), + [activeSessionType, commands] ); return { @@ -37,7 +42,7 @@ export function useSlashCommandSets() { { id: 'kilo', name: 'Kilo', - description: 'Project, MCP, and skill commands available in this session', + description: 'Project and MCP commands available in this session', prefix: '', commands: availableCommands, }, @@ -46,12 +51,3 @@ export function useSlashCommandSets() { ), }; } - -function toSlashCommand(info: SlashCommandInfo): SlashCommand { - return { - trigger: info.name, - label: info.name, - description: info.description ?? '', - expansion: '', - }; -} diff --git a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts index 03262174b3..8fc568bbb1 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.test.ts @@ -1,10 +1,12 @@ import type { ChatEvent, ServiceEvent } from './normalizer'; import { createCliLiveTransport } from './cli-live-transport'; +import type { SlashCommandInfo } from './schemas'; import type { RemoteModelCatalogV1, RemoteModelCatalogWireV1, RemoteModelState, } from './remote-model-catalog'; +import type { RemoteCommandState } from './remote-command-catalog'; import { UserWebCommandError, type UserWebCliEvent, @@ -15,6 +17,28 @@ import type { KiloSessionId, SessionSnapshot, SessionSnapshotPageOutcome } from import { kiloId, makeSnapshot, stubTextPart, stubUserMessage } from './test-helpers'; const KILO_SESSION_ID = kiloId('kilo-ses-1'); +const NEW_KILO_SESSION_ID = 'ses_12345678901234567890123456'; +const ROTATED_KILO_SESSION_ID = 'ses_abcdefghijklmnopqrstuvwxyz'; +const COMMAND_WIRE_CATALOG = { + protocolVersion: 1, + commands: [ + { + name: 'review', + description: 'Review changes', + source: 'command' as const, + hints: ['$ARGUMENTS'], + }, + { + name: 'compact', + description: 'compact the current session context', + hints: [], + }, + ], +} satisfies { protocolVersion: 1; commands: SlashCommandInfo[] }; +const PARSED_COMMAND_CATALOG: SlashCommandInfo[] = COMMAND_WIRE_CATALOG.commands.map(command => ({ + ...command, + hints: [...command.hints], +})); const WIRE_CATALOG = { all: [ { @@ -104,6 +128,7 @@ function createConnection(): FakeUserWebConnection { destroy: jest.fn(), subscribeToCliSession: jest.fn(() => release), sendCommand: jest.fn(() => Promise.resolve({ ok: true })), + sendCommandToConnection: jest.fn(() => Promise.resolve({ ok: true })), onCliEvent: jest.fn((_sessionId, listener) => { cliListeners.push(listener); return jest.fn(); @@ -129,6 +154,7 @@ function createTransportWithSinks(opts?: { fetchSnapshot?: (kiloSessionId: KiloSessionId) => Promise; onError?: (message: string) => void; onRemoteModelStateChange?: (state: RemoteModelState) => void; + onRemoteCommandStateChange?: (state: RemoteCommandState) => void; onCapabilityChange?: () => void; }) { const userWebConnection = opts?.connection ?? createConnection(); @@ -141,6 +167,7 @@ function createTransportWithSinks(opts?: { fetchSnapshot: opts?.fetchSnapshot, onError: opts?.onError, onRemoteModelStateChange: opts?.onRemoteModelStateChange, + onRemoteCommandStateChange: opts?.onRemoteCommandStateChange, onCapabilityChange: opts?.onCapabilityChange, })({ onChatEvent: event => chatEvents.push(event), @@ -344,10 +371,10 @@ describe('CliLiveTransport unified user web connection', () => { ...REMOTE_CATALOG, providers: [{ ...REMOTE_CATALOG.providers[0], id: 'replacement-provider' }], } satisfies RemoteModelCatalogV1; - jest - .mocked(connection.sendCommand) - .mockReturnValueOnce(firstCatalog) - .mockResolvedValueOnce(replacementWireCatalog); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command, _data, owner) => { + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return owner === 'owner-a' ? firstCatalog : Promise.resolve(replacementWireCatalog); + }); const states: RemoteModelState[] = []; const { transport } = createTransportWithSinks({ connection, @@ -379,10 +406,12 @@ describe('CliLiveTransport unified user web connection', () => { it('keeps one catalog request in flight for an owner', async () => { const connection = createConnection(); let resolveCatalog: ((catalog: RemoteModelCatalogWireV1) => void) | undefined; - jest.mocked(connection.sendCommand).mockReturnValue( - new Promise(resolve => { - resolveCatalog = resolve; - }) + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => + command === 'list_commands' + ? Promise.resolve(COMMAND_WIRE_CATALOG) + : new Promise(resolve => { + resolveCatalog = resolve; + }) ); const { transport } = createTransportWithSinks({ connection }); @@ -391,7 +420,11 @@ describe('CliLiveTransport unified user web connection', () => { transport.retryRemoteModels?.(); connection.emitReconnect(); - expect(connection.sendCommand).toHaveBeenCalledTimes(1); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_models') + ).toHaveLength(1); resolveCatalog?.(WIRE_CATALOG); await Promise.resolve(); @@ -401,10 +434,14 @@ describe('CliLiveTransport unified user web connection', () => { it('retains a v1 catalog when a same-owner reconnect refresh fails', async () => { const connection = createConnection(); - jest - .mocked(connection.sendCommand) - .mockResolvedValueOnce(WIRE_CATALOG) - .mockRejectedValueOnce(new Error('catalog refresh timed out')); + let modelCatalogRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + modelCatalogRequest += 1; + return modelCatalogRequest === 1 + ? Promise.resolve(WIRE_CATALOG) + : Promise.reject(new Error('catalog refresh timed out')); + }); const states: RemoteModelState[] = []; const { transport } = createTransportWithSinks({ connection, @@ -428,13 +465,21 @@ describe('CliLiveTransport unified user web connection', () => { refresh: 'error', error: 'catalog refresh timed out', }); - expect(connection.sendCommand).toHaveBeenCalledTimes(2); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_models') + ).toHaveLength(2); transport.destroy(); }); it('clears owner-scoped catalog state and rediscovers after session reappearance', async () => { const connection = createConnection(); - jest.mocked(connection.sendCommand).mockResolvedValue(WIRE_CATALOG); + jest + .mocked(connection.sendCommand) + .mockImplementation((_sessionId, command) => + Promise.resolve(command === 'list_models' ? WIRE_CATALOG : COMMAND_WIRE_CATALOG) + ); const states: RemoteModelState[] = []; const { transport } = createTransportWithSinks({ connection, @@ -458,13 +503,17 @@ describe('CliLiveTransport unified user web connection', () => { await Promise.resolve(); await Promise.resolve(); - expect(connection.sendCommand).toHaveBeenLastCalledWith( - KILO_SESSION_ID, - 'list_models', - { protocolVersion: 1 }, - 'owner-b' - ); - expect(connection.sendCommand).toHaveBeenCalledTimes(2); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_models') + ).toHaveLength(2); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_models') + .at(-1) + ).toEqual([KILO_SESSION_ID, 'list_models', { protocolVersion: 1 }, 'owner-b']); transport.destroy(); }); @@ -563,7 +612,10 @@ describe('CliLiveTransport unified user web connection', () => { userWebConnection.emitSystem({ event: 'cli.disconnected', data: { connectionId: 'owner' } }); userWebConnection.emitSystem({ event: 'cli.disconnected', data: { connectionId: 'owner' } }); - expect(serviceEvents).toEqual([{ type: 'stopped', reason: 'disconnected' }]); + expect(serviceEvents).toEqual([ + { type: 'commands.available', commands: [] }, + { type: 'stopped', reason: 'disconnected' }, + ]); userWebConnection.emitSystem({ event: 'sessions.heartbeat', @@ -600,6 +652,7 @@ describe('CliLiveTransport unified user web connection', () => { // a session.status event clears the disconnected UI state. expect(serviceEvents).toEqual([ { type: 'session.status', sessionId: KILO_SESSION_ID, status: { type: 'idle' } }, + { type: 'commands.available', commands: [] }, { type: 'stopped', reason: 'disconnected' }, { type: 'session.status', sessionId: KILO_SESSION_ID, status: { type: 'idle' } }, ]); @@ -1208,12 +1261,1550 @@ describe('CliLiveTransport unified user web connection', () => { } ); - it('rejects structured slash commands without sending a viewer command', async () => { - const { userWebConnection, transport } = createTransportWithSinks(); + it('sends a structured command with arguments, message ID, model, and variant', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.resolve({ ok: true }); + }); + const { userWebConnection, transport } = createTransportWithSinks({ connection }); - await expect( - transport.send!({ payload: { type: 'command', command: 'review', arguments: '' } }) - ).rejects.toThrow('Slash commands are not supported on the CLI live transport yet'); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await transport.send?.({ + payload: { type: 'command', command: 'review', arguments: 'main --fix' }, + messageId: 'msg-command-1', + remoteModelOverride: { + source: 'cli-catalog', + selection: { + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + variant: 'high', + }, + }, + }); + + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'send_command', + { + protocolVersion: 1, + command: 'review', + arguments: 'main --fix', + messageID: 'msg-command-1', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + variant: 'high', + }, + 'owner' + ); + transport.destroy(); + }); + + it('sends a structured command without a message ID and without a model override', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.resolve({ ok: true }); + }); + const { userWebConnection, transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await transport.send?.({ + payload: { type: 'command', command: 'compact', arguments: '' }, + }); + + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'send_command', + { + protocolVersion: 1, + command: 'compact', + arguments: '', + }, + 'owner' + ); + transport.destroy(); + }); + + it('sends a legacy command override as a structured kilo model with the bare modelID', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') + return Promise.reject(new Error('unknown command: list_models')); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.resolve({ ok: true }); + }); + const { userWebConnection, transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await transport.send?.({ + payload: { type: 'command', command: 'review', arguments: '' }, + remoteModelOverride: { + source: 'legacy-gateway', + selection: { + model: { providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' }, + variant: 'high', + }, + }, + }); + + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'send_command', + { + protocolVersion: 1, + command: 'review', + arguments: '', + model: { providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' }, + variant: 'high', + }, + 'owner' + ); + transport.destroy(); + }); + + it('strips the kilo/ prefix from a legacy command override modelID to match CLI dispatch', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') + return Promise.reject(new Error('unknown command: list_models')); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.resolve({ ok: true }); + }); + const { userWebConnection, transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + // The CLI normalizer (`dispatchedKilocodeModelId`) strips the `kilo/` + // prefix from a legacy override modelID. For the structured + // `send_command` wire to dispatch the same model the CLI would see from + // a prompt wire, the modelID in the structured payload must be the + // dispatched (stripped) form. + await transport.send?.({ + payload: { type: 'command', command: 'review', arguments: '' }, + remoteModelOverride: { + source: 'legacy-gateway', + selection: { + model: { providerID: 'kilo', modelID: 'kilo/anthropic/claude-sonnet-4' }, + }, + }, + }); + + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'send_command', + { + protocolVersion: 1, + command: 'review', + arguments: '', + model: { providerID: 'kilo', modelID: 'anthropic/claude-sonnet-4' }, + }, + 'owner' + ); + transport.destroy(); + }); +}); + +describe('CliLiveTransport remote command catalog', () => { + it('discovers and publishes commands for the current owner', async () => { + const connection = createConnection(); + jest + .mocked(connection.sendCommand) + .mockImplementation((_sessionId, command) => + Promise.resolve(command === 'list_models' ? WIRE_CATALOG : COMMAND_WIRE_CATALOG) + ); + const commandStates: RemoteCommandState[] = []; + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(connection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'list_commands', + { protocolVersion: 1 }, + 'owner' + ); + expect(serviceEvents.filter(event => event.type === 'commands.available')).toEqual([ + { type: 'commands.available', commands: PARSED_COMMAND_CATALOG }, + ]); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'idle', + commands: PARSED_COMMAND_CATALOG, + }); + transport.destroy(); + }); + + it('publishes an empty command catalog for a legacy CLI without list_commands support', async () => { + const connection = createConnection(); + const onError = jest.fn(); + jest + .mocked(connection.sendCommand) + .mockImplementation((_sessionId, command) => + command === 'list_models' + ? Promise.resolve(WIRE_CATALOG) + : Promise.reject(new Error('unknown command: list_commands')) + ); + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onError, + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(serviceEvents.filter(event => event.type === 'commands.available')).toEqual([ + { type: 'commands.available', commands: [] }, + ]); + expect(onError).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('clears the catalog on a malformed same-owner refresh without populating fatal error', async () => { + const connection = createConnection(); + const onError = jest.fn(); + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + return commandRequest === 1 + ? Promise.resolve(COMMAND_WIRE_CATALOG) + : Promise.resolve({ invalid: true }); + }); + const commandStates: RemoteCommandState[] = []; + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onError, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(serviceEvents.filter(event => event.type === 'commands.available').at(-1)).toEqual({ + type: 'commands.available', + commands: [], + }); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'error', + message: 'Invalid remote command catalog', + commands: [], + }); + expect(onError).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('retains the catalog on a transient same-owner refresh failure', async () => { + const connection = createConnection(); + const onError = jest.fn(); + let rejectRefresh: ((error: Error) => void) | undefined; + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + if (commandRequest === 1) return Promise.resolve(COMMAND_WIRE_CATALOG); + return new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + }); + const commandStates: RemoteCommandState[] = []; + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onError, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const publishedBeforeRefresh = serviceEvents.filter( + event => event.type === 'commands.available' + ); + + connection.emitReconnect(); + expect(serviceEvents.filter(event => event.type === 'commands.available')).toEqual( + publishedBeforeRefresh + ); + + rejectRefresh?.(new Error('command catalog timed out')); + await Promise.resolve(); + await Promise.resolve(); + + expect(serviceEvents.filter(event => event.type === 'commands.available')).toEqual( + publishedBeforeRefresh + ); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'error', + message: 'command catalog timed out', + commands: PARSED_COMMAND_CATALOG, + }); + expect(onError).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('clears the catalog and reports a nonfatal error on a CATALOG_TOO_LARGE relay failure', async () => { + const connection = createConnection(); + const onError = jest.fn(); + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + return commandRequest === 1 + ? Promise.resolve(COMMAND_WIRE_CATALOG) + : Promise.reject( + new UserWebCommandError({ + code: 'CATALOG_TOO_LARGE', + message: 'Command catalog response is too large', + }) + ); + }); + const commandStates: RemoteCommandState[] = []; + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onError, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + + expect(serviceEvents.filter(event => event.type === 'commands.available').at(-1)).toEqual({ + type: 'commands.available', + commands: [], + }); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'error', + message: 'Command catalog response is too large', + commands: [], + }); + expect(onError).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('publishes actionable upgrade-required state when the relay reports CLI_UPGRADE_REQUIRED', async () => { + const connection = createConnection(); + const onError = jest.fn(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + return Promise.reject( + new UserWebCommandError({ + code: 'CLI_UPGRADE_REQUIRED', + message: 'Update Kilo CLI to v8.4.0 to use slash commands', + }) + ); + }); + const commandStates: RemoteCommandState[] = []; + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onError, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(serviceEvents.filter(event => event.type === 'commands.available')).toEqual([ + { type: 'commands.available', commands: [] }, + ]); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'upgrade-required', + message: 'Update Kilo CLI to v8.4.0 to use slash commands', + commands: [], + }); + expect(onError).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('propagates actionable copy from send_command CLI_UPGRADE_REQUIRED failures', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.reject( + new UserWebCommandError({ + code: 'CLI_UPGRADE_REQUIRED', + message: 'Update Kilo CLI to v8.4.0 to send slash commands', + }) + ); + }); + const { transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + await expect( + transport.send?.({ payload: { type: 'command', command: 'review', arguments: '' } }) + ).rejects.toThrow('Update Kilo CLI to v8.4.0 to send slash commands'); + transport.destroy(); + }); + + it('keeps one command catalog request in flight per owner across reconnects', async () => { + const connection = createConnection(); + let resolveCatalog: ((catalog: typeof COMMAND_WIRE_CATALOG) => void) | undefined; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + return new Promise(resolve => { + resolveCatalog = resolve; + }); + }); + const { transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + connection.emitReconnect(); + connection.emitReconnect(); + + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands') + ).toHaveLength(1); + + resolveCatalog?.(COMMAND_WIRE_CATALOG); + await Promise.resolve(); + await Promise.resolve(); + transport.destroy(); + }); + + it('keeps command discovery independent from model discovery generation', async () => { + const connection = createConnection(); + let resolveModelCatalog: ((catalog: RemoteModelCatalogWireV1) => void) | undefined; + let resolveCommandCatalog: ((catalog: typeof COMMAND_WIRE_CATALOG) => void) | undefined; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') { + return new Promise(resolve => { + resolveModelCatalog = resolve; + }); + } + return new Promise(resolve => { + resolveCommandCatalog = resolve; + }); + }); + const { transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + + // Resolving the model catalog must not resolve the command catalog + // (different in-flight promises) and must not leave the command request + // stuck — it should still be awaiting its own response. + resolveModelCatalog?.(WIRE_CATALOG); + await Promise.resolve(); + await Promise.resolve(); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands') + ).toHaveLength(1); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_models') + ).toHaveLength(1); + + resolveCommandCatalog?.(COMMAND_WIRE_CATALOG); + await Promise.resolve(); + await Promise.resolve(); + transport.destroy(); + }); + + it('ignores a late command catalog from a replaced owner', async () => { + const connection = createConnection(); + let resolveFirstCatalog: ((catalog: typeof COMMAND_WIRE_CATALOG) => void) | undefined; + const firstCatalog = new Promise(resolve => { + resolveFirstCatalog = resolve; + }); + const replacementCatalog = { + protocolVersion: 1, + commands: [{ name: 'compact', hints: [] }], + }; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command, _data, owner) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') { + return owner === 'owner-a' ? firstCatalog : Promise.resolve(replacementCatalog); + } + return Promise.resolve({ ok: true }); + }); + const { transport, serviceEvents } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection, 'owner-a'); + emitOwner(connection, 'owner-b'); + await Promise.resolve(); + await Promise.resolve(); + + resolveFirstCatalog?.(COMMAND_WIRE_CATALOG); + await Promise.resolve(); + await Promise.resolve(); + + expect(serviceEvents.filter(event => event.type === 'commands.available').at(-1)).toEqual({ + type: 'commands.available', + commands: replacementCatalog.commands, + }); + transport.destroy(); + }); + + it('clears the published command catalog when the current owner disconnects', async () => { + const connection = createConnection(); + jest + .mocked(connection.sendCommand) + .mockImplementation((_sessionId, command) => + Promise.resolve(command === 'list_models' ? WIRE_CATALOG : COMMAND_WIRE_CATALOG) + ); + const { transport, serviceEvents } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + connection.emitSystem({ event: 'cli.disconnected', data: { connectionId: 'owner' } }); + + expect(serviceEvents.filter(event => event.type === 'commands.available').at(-1)).toEqual({ + type: 'commands.available', + commands: [], + }); + transport.destroy(); + }); + + it('rejects send_command with Remote session has no connected owner when no owner is known', async () => { + const { transport } = createTransportWithSinks(); + + await expect( + transport.send?.({ payload: { type: 'command', command: 'review', arguments: '' } }) + ).rejects.toThrow('Remote session has no connected owner'); + transport.destroy(); + }); + + it('rejects send_command with SESSION_OWNER_CHANGED error when the owner changes mid-flight', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.reject( + new UserWebCommandError({ code: 'SESSION_OWNER_CHANGED', message: 'Session owner changed' }) + ); + }); + const { transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + await expect( + transport.send?.({ payload: { type: 'command', command: 'review', arguments: '' } }) + ).rejects.toMatchObject({ code: 'SESSION_OWNER_CHANGED' }); + transport.destroy(); + }); + + it('keeps an ordinary prompt on send_message after command discovery is enabled', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(COMMAND_WIRE_CATALOG); + return Promise.resolve({ ok: true }); + }); + const { userWebConnection, transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await transport.send?.({ payload: { type: 'prompt', prompt: 'hello' } }); + + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'send_message', + { + sessionID: KILO_SESSION_ID, + parts: [{ type: 'text', text: 'hello' }], + }, + 'owner' + ); + transport.destroy(); + }); + + it('retains the last valid commands on a transient same-owner refresh failure (state)', async () => { + const connection = createConnection(); + const onError = jest.fn(); + let rejectRefresh: ((error: Error) => void) | undefined; + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + if (commandRequest === 1) return Promise.resolve(COMMAND_WIRE_CATALOG); + return new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + }); + const commandStates: RemoteCommandState[] = []; + const { transport } = createTransportWithSinks({ + connection, + onError, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Initial discovery: state carries the parsed commands and `idle`. + const idleState = commandStates.at(-1); + expect(idleState).toEqual({ + ownerConnectionId: 'owner', + refresh: 'idle', + commands: PARSED_COMMAND_CATALOG, + }); + + // Reconnect triggers a new in-flight request; loading state keeps the + // previously valid commands so consumers can keep showing them. + connection.emitReconnect(); + await Promise.resolve(); + const loadingState = commandStates.at(-1); + expect(loadingState).toEqual({ + ownerConnectionId: 'owner', + refresh: 'loading', + commands: PARSED_COMMAND_CATALOG, + }); + + // Transient failure: error state keeps the same cached commands. + rejectRefresh?.(new Error('command catalog timed out')); + await Promise.resolve(); + await Promise.resolve(); + const errorState = commandStates.at(-1); + expect(errorState).toEqual({ + ownerConnectionId: 'owner', + refresh: 'error', + message: 'command catalog timed out', + commands: PARSED_COMMAND_CATALOG, + }); + expect(onError).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('clears cached commands on malformed, oversized, upgrade-required, disconnect, and teardown', async () => { + const connection = createConnection(); + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + if (commandRequest === 2) return Promise.resolve({ invalid: true }); + if (commandRequest === 4) { + return Promise.reject( + new UserWebCommandError({ + code: 'CATALOG_TOO_LARGE', + message: 'Command catalog response is too large', + }) + ); + } + if (commandRequest === 6) { + return Promise.reject( + new UserWebCommandError({ + code: 'CLI_UPGRADE_REQUIRED', + message: 'Update Kilo CLI to v8.4.0', + }) + ); + } + return Promise.resolve(COMMAND_WIRE_CATALOG); + }); + const commandStates: RemoteCommandState[] = []; + const { transport } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)?.commands).toEqual(PARSED_COMMAND_CATALOG); + + // Malformed refresh: cache cleared. + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'error', + message: 'Invalid remote command catalog', + commands: [], + }); + + // Reconnect, retry → recovers cache. + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)?.commands).toEqual(PARSED_COMMAND_CATALOG); + + // CATALOG_TOO_LARGE: cache cleared. + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'error', + message: 'Command catalog response is too large', + commands: [], + }); + + // Reconnect, retry → recovers cache. + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)?.commands).toEqual(PARSED_COMMAND_CATALOG); + + // CLI_UPGRADE_REQUIRED: cache cleared and upgrade-required surfaced. + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: 'owner', + refresh: 'upgrade-required', + message: 'Update Kilo CLI to v8.4.0', + commands: [], + }); + + // Owner disconnect clears the cache. + connection.emitSystem({ event: 'cli.disconnected', data: { connectionId: 'owner' } }); + expect(commandStates.at(-1)).toEqual({ + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }); + transport.destroy(); + + // After transport teardown the next state (if any) keeps `[]` rather + // than resurrecting the old cache. + expect(commandStates.at(-1)?.commands).toEqual([]); + }); + + it('publishes immutable command arrays so later mutation cannot rewrite prior state history', async () => { + const connection = createConnection(); + jest + .mocked(connection.sendCommand) + .mockImplementation((_sessionId, command) => + Promise.resolve(command === 'list_models' ? WIRE_CATALOG : COMMAND_WIRE_CATALOG) + ); + const commandStates: RemoteCommandState[] = []; + const { transport } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const firstCommands = commandStates.at(-1)!.commands; + expect(firstCommands).toEqual(PARSED_COMMAND_CATALOG); + + // Mutating the array reference held by the consumer must not bleed + // into the next state — each emitted state carries its own copy. + firstCommands.length = 0; + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + expect(commandStates.at(-1)?.commands).toEqual(PARSED_COMMAND_CATALOG); + transport.destroy(); + }); + + it('exposes retryRemoteCommands that re-discovers after a transient failure', async () => { + const connection = createConnection(); + let rejectFirstRefresh: ((error: Error) => void) | undefined; + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + if (commandRequest === 1) return Promise.resolve(COMMAND_WIRE_CATALOG); + if (commandRequest === 2) { + return new Promise((_resolve, reject) => { + rejectFirstRefresh = reject; + }); + } + return Promise.resolve(COMMAND_WIRE_CATALOG); + }); + const { transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(transport.retryRemoteCommands).toBeDefined(); + + // Reconnect kicks off a refresh that we will then reject. + connection.emitReconnect(); + await Promise.resolve(); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands') + ).toHaveLength(2); + rejectFirstRefresh?.(new Error('transient timeout')); + await Promise.resolve(); + await Promise.resolve(); + + // Retry must re-issue exactly one more list_commands call and recover. + transport.retryRemoteCommands?.(); + await Promise.resolve(); + await Promise.resolve(); + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands') + ).toHaveLength(3); + + // After recovery the cached commands are published again. + const finalService = transport as unknown as { + // accessor used by serviceEvent assertion helper is not exposed; + // the cache is observed through the state callback instead. + }; + void finalService; + transport.destroy(); + }); + + it('retryRemoteCommands is a no-op when there is no current owner', () => { + const { transport } = createTransportWithSinks(); + transport.connect(); + expect(transport.retryRemoteCommands).toBeDefined(); + expect(() => transport.retryRemoteCommands?.()).not.toThrow(); + transport.destroy(); + }); + + it('retryRemoteCommands does not issue a duplicate request while one is in flight', async () => { + const connection = createConnection(); + let resolveCatalog: ((catalog: typeof COMMAND_WIRE_CATALOG) => void) | undefined; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + return new Promise(resolve => { + resolveCatalog = resolve; + }); + }); + const { transport } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + const initialCalls = jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands').length; + + transport.retryRemoteCommands?.(); + transport.retryRemoteCommands?.(); + transport.retryRemoteCommands?.(); + + expect( + jest + .mocked(connection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands') + ).toHaveLength(initialCalls); + + resolveCatalog?.(COMMAND_WIRE_CATALOG); + await Promise.resolve(); + await Promise.resolve(); + transport.destroy(); + }); + + it('isolates the commands.available event from later mutation of the outer array, command fields, and hints', async () => { + const connection = createConnection(); + let rejectSecond: ((error: Error) => void) | undefined; + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + if (commandRequest === 1) return Promise.resolve(COMMAND_WIRE_CATALOG); + return new Promise((_resolve, reject) => { + rejectSecond = reject; + }); + }); + const commandStates: RemoteCommandState[] = []; + const { transport, serviceEvents } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Grab the event the transport published. + const availableEvent = serviceEvents.find( + (event): event is Extract => + event.type === 'commands.available' + ); + expect(availableEvent).toBeDefined(); + const eventCommands = availableEvent!.commands; + const eventFirst = eventCommands[0]; + expect(eventFirst).toBeDefined(); + + // (b) Mutate a command object field — name. The event and the cache + // currently share this command object reference. + eventFirst.name = 'hijacked'; + + // (c) Mutate a nested hints array on the first command. + eventFirst.hints.length = 0; + + // (a) Mutate the outer array of the event. + eventCommands.length = 0; + eventCommands.push({ + name: 'mutated', + description: 'consumer-injected', + hints: ['$ARGUMENTS'], + }); + + // Trigger a retry that hangs, then reject with a transient error. + // The transient error path surfaces the cached value via state. + transport.retryRemoteCommands?.(); + await Promise.resolve(); + rejectSecond?.(new Error('transient timeout')); + await Promise.resolve(); + await Promise.resolve(); + + // The error state must still carry the original validated catalog, + // not the consumer-mutated data. If the cache were corrupted by + // the event mutations, the state would carry the mutated values. + expect(commandStates.at(-1)?.commands).toEqual(PARSED_COMMAND_CATALOG); + transport.destroy(); + }); + + it('isolates RemoteCommandState.commands from later mutation of the outer array, command fields, and hints', async () => { + const connection = createConnection(); + let rejectSecond: ((error: Error) => void) | undefined; + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + commandRequest += 1; + if (commandRequest === 1) return Promise.resolve(COMMAND_WIRE_CATALOG); + return new Promise((_resolve, reject) => { + rejectSecond = reject; + }); + }); + const commandStates: RemoteCommandState[] = []; + const { transport } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Grab the commands array from the last published state. + const stateCommands = commandStates.at(-1)!.commands; + expect(stateCommands).toEqual(PARSED_COMMAND_CATALOG); + const stateFirst = stateCommands[0]; + expect(stateFirst).toBeDefined(); + + // (b) Mutate a command object field — name. The state and the cache + // currently share this command object reference. + stateFirst.name = 'hijacked'; + + // (c) Mutate a nested hints array on the first command. + stateFirst.hints.length = 0; + + // (a) Mutate the outer array. + stateCommands.length = 0; + stateCommands.push({ + name: 'mutated', + description: 'consumer-injected', + hints: ['$ARGUMENTS'], + }); + + // Trigger a retry that hangs, then reject with a transient error. + // The transient error path surfaces the cached value via state. + transport.retryRemoteCommands?.(); + await Promise.resolve(); + rejectSecond?.(new Error('transient timeout')); + await Promise.resolve(); + await Promise.resolve(); + + expect(commandStates.at(-1)?.commands).toEqual(PARSED_COMMAND_CATALOG); + transport.destroy(); + }); +}); + +describe('CliLiveTransport createSession', () => { + it('rejects when no owner is known', async () => { + const { transport } = createTransportWithSinks(); + await expect(transport.createSession?.()).rejects.toThrow( + 'Remote session has no connected owner' + ); + transport.destroy(); + }); + + it('sends create_session via sendCommand against the current session and owner and returns the branded id', async () => { + const connection = createConnection(); + jest + .mocked(connection.sendCommand) + .mockResolvedValue({ protocolVersion: 1, sessionID: NEW_KILO_SESSION_ID }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Clear discovery calls so we can assert exactly the create_session call. + jest.mocked(userWebConnection.sendCommand).mockClear(); + const result = await transport.createSession?.(); + expect(result).toBe(NEW_KILO_SESSION_ID); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'create_session', + { protocolVersion: 1 }, + 'owner' + ); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('rejects a malformed response without retrying or changing owner', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockResolvedValue({ invalid: true }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + await expect(transport.createSession?.()).rejects.toThrow('Invalid create_session response'); + // No second create_session call — never auto-retry. + expect( + jest + .mocked(userWebConnection.sendCommand) + .mock.calls.filter(([, command]) => command === 'create_session') + ).toHaveLength(1); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + expect(transport.canSend?.()).toBe(true); + transport.destroy(); + }); + + it('propagates a CLI_UPGRADE_REQUIRED error with the actionable message', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockRejectedValue( + new UserWebCommandError({ + code: 'CLI_UPGRADE_REQUIRED', + message: 'Creating remote sessions from mobile requires a newer Kilo CLI.', + }) + ); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + await expect(transport.createSession?.()).rejects.toThrow( + 'Creating remote sessions from mobile requires a newer Kilo CLI.' + ); + expect( + jest + .mocked(userWebConnection.sendCommand) + .mock.calls.filter(([, command]) => command === 'create_session') + ).toHaveLength(1); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('clears the owner on a SESSION_OWNER_CHANGED error and re-throws', async () => { + const connection = createConnection(); + const commandStates: RemoteCommandState[] = []; + const { transport, userWebConnection } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => commandStates.push(state), + }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + jest + .mocked(userWebConnection.sendCommand) + .mockRejectedValue( + new UserWebCommandError({ code: 'SESSION_OWNER_CHANGED', message: 'Session owner changed' }) + ); + + await expect(transport.createSession?.()).rejects.toMatchObject({ + code: 'SESSION_OWNER_CHANGED', + }); + expect(commandStates.at(-1)?.ownerConnectionId).toBeNull(); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('uses the owner snapshot at call time when the owner changes mid-flight', async () => { + const connection = createConnection(); + let resolveCreate: ((value: { protocolVersion: 1; sessionID: string }) => void) | undefined; + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection, 'owner-a'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + jest.mocked(userWebConnection.sendCommand).mockImplementation((_sessionId, command) => { + if (command !== 'create_session') return Promise.resolve({ ok: true }); + return new Promise(resolve => { + resolveCreate = resolve; + }); + }); + const createPromise = transport.createSession?.(); + await Promise.resolve(); + // Owner rotates mid-flight. + connection.emitSystem({ + event: 'sessions.list', + data: { + sessions: [ + { id: KILO_SESSION_ID, status: 'active', title: 'Tracked', connectionId: 'owner-b' }, + ], + }, + }); + await Promise.resolve(); + await Promise.resolve(); + + resolveCreate?.({ protocolVersion: 1, sessionID: ROTATED_KILO_SESSION_ID }); + await expect(createPromise).resolves.toBe(ROTATED_KILO_SESSION_ID); + // The snapshot we used was 'owner-a' even though the current owner is now 'owner-b'. + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'create_session', + { protocolVersion: 1 }, + 'owner-a' + ); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('does not retry a network failure', async () => { + const connection = createConnection(); + jest + .mocked(connection.sendCommand) + .mockRejectedValue(new Error('Connection lost during reconnect')); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + await expect(transport.createSession?.()).rejects.toThrow('Connection lost during reconnect'); + expect( + jest + .mocked(userWebConnection.sendCommand) + .mock.calls.filter(([, command]) => command === 'create_session') + ).toHaveLength(1); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + transport.destroy(); + }); +}); + +describe('CliLiveTransport exitCli', () => { + const EXIT_COMMAND_CATALOG = { + protocolVersion: 1, + commands: [{ name: 'exit', description: 'Exit the CLI', hints: [] }], + }; + + async function connectWithCommandCatalog( + commandCatalog: unknown = EXIT_COMMAND_CATALOG, + exitResult: unknown = {} + ) { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(commandCatalog); + if (command === 'exit_cli') return Promise.resolve(exitResult); + return Promise.resolve({ ok: true }); + }); + const fixture = createTransportWithSinks({ connection }); + fixture.transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + return { connection, ...fixture }; + } + + it('sends exit_cli exactly once against the current session and owner', async () => { + const { transport, userWebConnection } = await connectWithCommandCatalog(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).resolves.toBeUndefined(); + + expect(userWebConnection.sendCommand).toHaveBeenCalledTimes(1); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'exit_cli', + { protocolVersion: 1 }, + 'owner' + ); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('uses an internal command-state snapshot when consumers mutate published state', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.resolve(EXIT_COMMAND_CATALOG); + return Promise.resolve({}); + }); + const { transport, userWebConnection } = createTransportWithSinks({ + connection, + onRemoteCommandStateChange: state => { + if (state.refresh === 'idle' && state.commands.length > 0) { + state.commands[0].name = 'mutated'; + state.commands.length = 0; + } + }, + }); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).resolves.toBeUndefined(); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'exit_cli', + { protocolVersion: 1 }, + 'owner' + ); + transport.destroy(); + }); + + it('permits exit_cli while a same-owner refresh retains the canonical catalog', async () => { + const connection = createConnection(); + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') { + commandRequest += 1; + return commandRequest === 1 ? Promise.resolve(EXIT_COMMAND_CATALOG) : new Promise(() => {}); + } + return Promise.resolve({}); + }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + connection.emitReconnect(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).resolves.toBeUndefined(); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'exit_cli', + { protocolVersion: 1 }, + 'owner' + ); + transport.destroy(); + }); + + it('permits exit_cli after a transient same-owner refresh error retains the canonical catalog', async () => { + const connection = createConnection(); + let commandRequest = 0; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') { + commandRequest += 1; + return commandRequest === 1 + ? Promise.resolve(EXIT_COMMAND_CATALOG) + : Promise.reject(new Error('catalog timed out')); + } + return Promise.resolve({}); + }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + connection.emitReconnect(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).resolves.toBeUndefined(); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'exit_cli', + { protocolVersion: 1 }, + 'owner' + ); + transport.destroy(); + }); + + it.each([null, [], { extra: true }])( + 'rejects malformed result %p without retrying', + async result => { + const { transport, userWebConnection } = await connectWithCommandCatalog( + EXIT_COMMAND_CATALOG, + result + ); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow('Invalid exit_cli response'); + expect(userWebConnection.sendCommand).toHaveBeenCalledTimes(1); + transport.destroy(); + } + ); + + it('does not retry a network failure', async () => { + const { transport, userWebConnection } = await connectWithCommandCatalog(); + jest + .mocked(userWebConnection.sendCommand) + .mockRejectedValueOnce(new Error('Connection lost before CLI acknowledgement')); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow( + 'Connection lost before CLI acknowledgement' + ); + expect(userWebConnection.sendCommand).toHaveBeenCalledTimes(1); + transport.destroy(); + }); + + it('uses the owner snapshot at call time', async () => { + const { connection, transport, userWebConnection } = await connectWithCommandCatalog(); + let resolveExit: ((result: {}) => void) | undefined; + jest.mocked(userWebConnection.sendCommand).mockClear(); + jest.mocked(userWebConnection.sendCommand).mockImplementation((_sessionId, command) => { + if (command !== 'exit_cli') return Promise.resolve({}); + return new Promise(resolve => { + resolveExit = resolve; + }); + }); + + const exitPromise = transport.exitCli?.(); + connection.emitSystem({ + event: 'sessions.list', + data: { + sessions: [ + { id: KILO_SESSION_ID, status: 'active', title: 'Tracked', connectionId: 'owner-b' }, + ], + }, + }); + resolveExit?.({}); + + await expect(exitPromise).resolves.toBeUndefined(); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + KILO_SESSION_ID, + 'exit_cli', + { protocolVersion: 1 }, + 'owner' + ); + transport.destroy(); + }); + + it.each([ + { label: 'empty catalog', catalog: { protocolVersion: 1, commands: [] } }, + { + label: 'non-canonical exit command', + catalog: { + protocolVersion: 1, + commands: [{ name: 'exit', description: 'Close', hints: [] }], + }, + }, + ])('rejects unavailable for $label', async ({ catalog }) => { + const { transport, userWebConnection } = await connectWithCommandCatalog(catalog); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow( + 'Remote CLI exit is unavailable for the current session' + ); + expect(userWebConnection.sendCommand).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('rejects unavailable while the command catalog is loading', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return new Promise(() => {}); + return Promise.resolve({}); + }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow( + 'Remote CLI exit is unavailable for the current session' + ); + expect(userWebConnection.sendCommand).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('rejects unavailable after command catalog error', async () => { + const connection = createConnection(); + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') return Promise.reject(new Error('catalog unavailable')); + return Promise.resolve({}); + }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow( + 'Remote CLI exit is unavailable for the current session' + ); + expect(userWebConnection.sendCommand).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('rejects unavailable after owner disconnect', async () => { + const { connection, transport, userWebConnection } = await connectWithCommandCatalog(); + connection.emitSystem({ event: 'cli.disconnected', data: { connectionId: 'owner' } }); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow( + 'Remote CLI exit is unavailable for the current session' + ); + expect(userWebConnection.sendCommand).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('rejects unavailable while an owner replacement has an empty catalog', async () => { + const { connection, transport, userWebConnection } = await connectWithCommandCatalog(); + connection.emitSystem({ + event: 'sessions.list', + data: { + sessions: [ + { id: KILO_SESSION_ID, status: 'active', title: 'Tracked', connectionId: 'owner-b' }, + ], + }, + }); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow( + 'Remote CLI exit is unavailable for the current session' + ); + expect(userWebConnection.sendCommand).not.toHaveBeenCalled(); + transport.destroy(); + }); + + it('rejects with the exact upgrade-required message', async () => { + const connection = createConnection(); + const upgradeMessage = + 'Remote slash commands require a newer Kilo CLI. Update Kilo CLI and reconnect.'; + jest.mocked(connection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') return Promise.resolve(WIRE_CATALOG); + if (command === 'list_commands') { + return Promise.reject( + new UserWebCommandError({ code: 'CLI_UPGRADE_REQUIRED', message: upgradeMessage }) + ); + } + return Promise.resolve({}); + }); + const { transport, userWebConnection } = createTransportWithSinks({ connection }); + transport.connect(); + emitOwner(connection); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(transport.exitCli?.()).rejects.toThrow(upgradeMessage); expect(userWebConnection.sendCommand).not.toHaveBeenCalled(); transport.destroy(); }); diff --git a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts index 85b54edb1e..09c0100982 100644 --- a/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts +++ b/apps/web/src/lib/cloud-agent-sdk/cli-live-transport.ts @@ -3,12 +3,16 @@ * one remote CLI session into normalized transport events and commands. */ import { normalizeCliEvent, isChatEvent } from './normalizer'; +import { parseRemoteCommandCatalog, type RemoteCommandState } from './remote-command-catalog'; +import { parseCreateSessionResponse } from './create-session'; +import { parseExitCliResponse } from './exit-cli'; import { cliConnectionDataSchema, heartbeatDataSchema, remoteModelCatalogV1Schema, sessionsListDataSchema, } from './schemas'; +import type { SlashCommandInfo } from './schemas'; import type { RemoteModelState } from './remote-model-catalog'; import type { TransportFactory, TransportSendInput, TransportSink } from './transport'; import type { @@ -46,6 +50,7 @@ type CliLiveTransportConfig = { onInitialPageLoaded?: (page: SessionSnapshotPage) => void; onError?: (message: string) => void; onRemoteModelStateChange?: (state: RemoteModelState) => void; + onRemoteCommandStateChange?: (state: RemoteCommandState) => void; onCapabilityChange?: () => void; }; @@ -53,6 +58,23 @@ type CliLiveTransportConfig = { // the session store's persistence lag behind the live stream; bump if holes // still appear after long backgrounding. const RECONNECT_RESYNC_DELAY_MS = 5000; +const REMOTE_CLI_EXIT_UNAVAILABLE = 'Remote CLI exit is unavailable for the current session'; + +/** + * Deep-copy a list of validated remote slash commands. + * + * Creates a new outer array, new command objects, and new hints arrays + * so each emitted snapshot (event, internal cache, state callback) is + * independent. Optional string/boolean fields are spread verbatim; the + * schema has already validated them. Avoids `JSON.parse(JSON.stringify(...))` + * because the shape is known and bounded. + */ +export function deepCopyCommands(commands: SlashCommandInfo[]): SlashCommandInfo[] { + return commands.map(command => ({ + ...command, + hints: command.hints.slice(), + })); +} function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactory { return (sink: TransportSink) => { @@ -63,31 +85,103 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor let lastForwardedHeartbeatStatus: string | null = null; let catalogRequestGeneration = 0; let catalogRequestInFlight: { ownerConnectionId: string; generation: number } | null = null; + // Command catalog discovery runs on its own generation so it stays + // independent of model discovery: a model refresh that completes or + // fails must not drop an in-flight command catalog request. + let commandCatalogRequestGeneration = 0; + let commandCatalogRequestInFlight: { + ownerConnectionId: string; + generation: number; + } | null = null; let remoteModelState: RemoteModelState = { ownerConnectionId: null, protocol: 'unknown', refresh: 'idle', }; - + let remoteCommandState: RemoteCommandState = { + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }; + // Last successfully parsed remote command catalog, deep-copied on + // every publish so later mutation of consumer-held arrays, command + // objects, or nested hints cannot corrupt prior state history. + let lastValidCommands: SlashCommandInfo[] = []; function publishRemoteModelState(next: RemoteModelState): void { remoteModelState = next; config.onRemoteModelStateChange?.(next); } + function publishRemoteCommandState(next: RemoteCommandState): void { + remoteCommandState = { ...next, commands: deepCopyCommands(next.commands) }; + config.onRemoteCommandStateChange?.(next); + } + + function canExitCli(): boolean { + return ( + remoteCommandState.ownerConnectionId !== null && + remoteCommandState.commands.some( + command => + command.name === 'exit' && + command.description === 'Exit the CLI' && + command.hints.length === 0 && + command.agent === undefined && + command.model === undefined && + command.source === undefined && + command.subtask === undefined + ) + ); + } + + function publishCommands(commands: SlashCommandInfo[]): void { + // Deep copy the array, every command object, and every hints array + // so the event payload, the internal cache, and every subsequent + // state callback each hold an independent snapshot. + const snapshotted = deepCopyCommands(commands); + lastValidCommands = snapshotted; + sink.onServiceEvent({ type: 'commands.available', commands: deepCopyCommands(snapshotted) }); + } + + function snapshotCommands(): SlashCommandInfo[] { + return deepCopyCommands(lastValidCommands); + } + function setOwnerConnectionId(nextOwnerConnectionId: string | null): void { if (ownerConnectionId === nextOwnerConnectionId) return; + const previousOwnerConnectionId = ownerConnectionId; ownerConnectionId = nextOwnerConnectionId; catalogRequestGeneration += 1; catalogRequestInFlight = null; + commandCatalogRequestGeneration += 1; + commandCatalogRequestInFlight = null; publishRemoteModelState({ ownerConnectionId: nextOwnerConnectionId, protocol: 'unknown', refresh: nextOwnerConnectionId ? 'loading' : 'idle', }); + // Owner replacement clears the command cache; emit an empty catalog + // event and the matching idle state so consumers can re-render. + if (previousOwnerConnectionId) { + publishCommands([]); + publishRemoteCommandState({ + ownerConnectionId: nextOwnerConnectionId, + refresh: 'idle', + commands: snapshotCommands(), + }); + } else { + publishRemoteCommandState({ + ownerConnectionId: nextOwnerConnectionId, + refresh: 'idle', + commands: snapshotCommands(), + }); + } config.onCapabilityChange?.(); - if (nextOwnerConnectionId) discoverModels(nextOwnerConnectionId); + if (nextOwnerConnectionId) { + discoverModels(nextOwnerConnectionId); + discoverCommands(nextOwnerConnectionId); + } } function handleCatalogFailure( @@ -194,6 +288,146 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor }); } + function handleCommandCatalogFailure( + error: unknown, + expectedOwnerConnectionId: string, + expectedGeneration: number, + expectedRequestGeneration: number, + clearCatalog: boolean + ): void { + if ( + expectedGeneration !== generation || + expectedRequestGeneration !== commandCatalogRequestGeneration || + ownerConnectionId !== expectedOwnerConnectionId + ) { + return; + } + + if (error instanceof UserWebCommandError && error.code === 'SESSION_OWNER_CHANGED') { + setOwnerConnectionId(null); + return; + } + + // Exact relay `CLI_UPGRADE_REQUIRED` is non-fatal: clear the catalog + // and surface actionable copy through the command state so the chat + // composer can prompt the user to upgrade. Never populate the global + // session error atom for this. + if (error instanceof UserWebCommandError && error.code === 'CLI_UPGRADE_REQUIRED') { + publishCommands([]); + publishRemoteCommandState({ + ownerConnectionId: expectedOwnerConnectionId, + refresh: 'upgrade-required', + commands: snapshotCommands(), + message: error.message, + }); + return; + } + + // Legacy CLI without `list_commands` support: treat as "no commands + // available" rather than a failure so the composer can hide suggestions. + if (error instanceof Error && error.message.includes('unknown command')) { + publishCommands([]); + publishRemoteCommandState({ + ownerConnectionId: expectedOwnerConnectionId, + refresh: 'idle', + commands: snapshotCommands(), + }); + return; + } + + const message = error instanceof Error ? error.message : 'Failed to discover remote commands'; + if (clearCatalog) { + publishCommands([]); + publishRemoteCommandState({ + ownerConnectionId: expectedOwnerConnectionId, + refresh: 'error', + commands: snapshotCommands(), + message, + }); + return; + } + // Transient failure on a same-owner refresh: keep the previously + // published catalog and report the error through state without + // populating the fatal session error atom. + publishRemoteCommandState({ + ownerConnectionId: expectedOwnerConnectionId, + refresh: 'error', + commands: snapshotCommands(), + message, + }); + } + + function discoverCommands(expectedOwnerConnectionId: string): void { + if (commandCatalogRequestInFlight?.ownerConnectionId === expectedOwnerConnectionId) return; + + commandCatalogRequestGeneration += 1; + const expectedRequestGeneration = commandCatalogRequestGeneration; + const expectedGeneration = generation; + commandCatalogRequestInFlight = { + ownerConnectionId: expectedOwnerConnectionId, + generation: expectedRequestGeneration, + }; + publishRemoteCommandState({ + ownerConnectionId: expectedOwnerConnectionId, + refresh: 'loading', + commands: snapshotCommands(), + }); + + void config.userWebConnection + .sendCommand( + config.kiloSessionId, + 'list_commands', + { protocolVersion: 1 }, + expectedOwnerConnectionId + ) + .then( + result => { + if ( + expectedGeneration !== generation || + expectedRequestGeneration !== commandCatalogRequestGeneration || + ownerConnectionId !== expectedOwnerConnectionId + ) { + return; + } + + const parsed = parseRemoteCommandCatalog(result); + if (!parsed.ok) { + handleCommandCatalogFailure( + new Error('Invalid remote command catalog'), + expectedOwnerConnectionId, + expectedGeneration, + expectedRequestGeneration, + true + ); + return; + } + + publishCommands(parsed.commands); + publishRemoteCommandState({ + ownerConnectionId: expectedOwnerConnectionId, + refresh: 'idle', + commands: snapshotCommands(), + }); + }, + error => + handleCommandCatalogFailure( + error, + expectedOwnerConnectionId, + expectedGeneration, + expectedRequestGeneration, + // A relay `CATALOG_TOO_LARGE` reports the response is over + // the 512 KiB size cap; clear the prior catalog so the + // composer can present a clean state. + error instanceof UserWebCommandError && error.code === 'CATALOG_TOO_LARGE' + ) + ) + .finally(() => { + if (commandCatalogRequestInFlight?.generation === expectedRequestGeneration) { + commandCatalogRequestInFlight = null; + } + }); + } + function replayPage(page: SessionSnapshotPage): void { sink.onServiceEvent({ type: 'session.created', info: page.info }); @@ -379,11 +613,18 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor lastForwardedHeartbeatStatus = null; catalogRequestGeneration += 1; catalogRequestInFlight = null; + commandCatalogRequestGeneration += 1; + commandCatalogRequestInFlight = null; publishRemoteModelState({ ownerConnectionId: null, protocol: 'unknown', refresh: 'idle', }); + publishRemoteCommandState({ + ownerConnectionId: null, + refresh: 'idle', + commands: snapshotCommands(), + }); config.onCapabilityChange?.(); let resyncTimer: ReturnType | null = null; @@ -517,7 +758,10 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor resyncTimer = null; replayCurrentSnapshot(false); }, RECONNECT_RESYNC_DELAY_MS); - if (ownerConnectionId) discoverModels(ownerConnectionId); + if (ownerConnectionId) { + discoverModels(ownerConnectionId); + discoverCommands(ownerConnectionId); + } }); const releaseSubscription = config.userWebConnection.subscribeToCliSession( config.kiloSessionId @@ -538,11 +782,69 @@ function createCliLiveTransport(config: CliLiveTransportConfig): TransportFactor retryRemoteModels: () => { if (ownerConnectionId) discoverModels(ownerConnectionId); }, + retryRemoteCommands: () => { + // Mirrors `retryRemoteModels`: only acts when an owner is known. + // `discoverCommands` itself deduplicates in-flight requests per + // owner, so duplicate retry calls collapse safely. + if (ownerConnectionId) discoverCommands(ownerConnectionId); + }, + createSession: async () => { + // `create_session` is session-scoped: it must include the current Kilo + // sessionId so the CLI can select the workspace, and it must be fenced + // to the owner we currently trust. Reuse `sendCommand` so the owner is + // snapshotted before the await and SESSION_OWNER_CHANGED is handled. + // Never auto-retry — a transient network failure is a hard reject so + // the caller can surface a retryable error. + const result = await sendCommand('create_session', { protocolVersion: 1 }); + const parsed = parseCreateSessionResponse(result); + if (!parsed.ok) { + throw new Error('Invalid create_session response'); + } + return parsed.kiloSessionId; + }, + exitCli: async () => { + if (remoteCommandState.refresh === 'upgrade-required') { + throw new Error( + remoteCommandState.message ?? + 'Remote slash commands require a newer Kilo CLI. Update Kilo CLI and reconnect.' + ); + } + if (!canExitCli()) { + throw new Error(REMOTE_CLI_EXIT_UNAVAILABLE); + } + + const result = await sendCommand('exit_cli', { protocolVersion: 1 }); + if (!parseExitCliResponse(result).ok) { + throw new Error('Invalid exit_cli response'); + } + }, send: async (input: TransportSendInput) => { if (input.payload.type === 'command') { - return Promise.reject( - new Error('Slash commands are not supported on the CLI live transport yet') - ); + const remoteModel = getRemoteModelFields(input); + return sendCommand('send_command', { + protocolVersion: 1, + command: input.payload.command, + arguments: input.payload.arguments, + ...(input.messageId ? { messageID: input.messageId } : {}), + ...(remoteModel.kind === 'none' + ? {} + : { + // `send_command` requires a structured model: never emit a + // bare string. Legacy CLI overrides arrive as a bare + // model string; map them to the kilo provider and strip + // any `kilo/` prefix so the wire modelID matches what + // `dispatchedKilocodeModelId` would emit for the + // equivalent prompt wire. + model: + remoteModel.kind === 'structured' + ? remoteModel.model + : { + providerID: 'kilo', + modelID: remoteModel.model.replace(/^kilo\//, ''), + }, + ...(remoteModel.variant ? { variant: remoteModel.variant } : {}), + }), + }); } const payload = input.payload; const remoteModel = getRemoteModelFields(input); diff --git a/apps/web/src/lib/cloud-agent-sdk/create-session.test.ts b/apps/web/src/lib/cloud-agent-sdk/create-session.test.ts new file mode 100644 index 0000000000..bcb3219395 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-sdk/create-session.test.ts @@ -0,0 +1,147 @@ +import { createSessionResponseV1Schema, parseCreateSessionResponse } from './create-session'; + +const VALID_SESSION_ID = 'ses_12345678901234567890123456'; + +describe('createSessionResponseV1Schema', () => { + it('accepts a minimal valid v1 envelope', () => { + const result = createSessionResponseV1Schema.safeParse({ + protocolVersion: 1, + sessionID: VALID_SESSION_ID, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + } + }); + + it('rejects a wrong protocol version', () => { + expect( + createSessionResponseV1Schema.safeParse({ protocolVersion: 2, sessionID: VALID_SESSION_ID }) + .success + ).toBe(false); + }); + + it('rejects a missing sessionID', () => { + expect(createSessionResponseV1Schema.safeParse({ protocolVersion: 1 }).success).toBe(false); + }); + + it('rejects an empty sessionID', () => { + expect( + createSessionResponseV1Schema.safeParse({ protocolVersion: 1, sessionID: '' }).success + ).toBe(false); + }); + + it('accepts a real generated-form KiloSessionId (hex timestamp + base62)', () => { + const generatedLike = 'ses_0123456789ab0123456789abcd'; + expect( + createSessionResponseV1Schema.safeParse({ protocolVersion: 1, sessionID: generatedLike }) + .success + ).toBe(true); + }); + + it('rejects a sessionID that is one character too short', () => { + expect( + createSessionResponseV1Schema.safeParse({ + protocolVersion: 1, + sessionID: 'ses_1234567890123456789012345', + }).success + ).toBe(false); + }); + + it('rejects a sessionID that is one character too long', () => { + expect( + createSessionResponseV1Schema.safeParse({ + protocolVersion: 1, + sessionID: 'ses_123456789012345678901234567', + }).success + ).toBe(false); + }); + + it('rejects a sessionID missing the ses_ prefix', () => { + expect( + createSessionResponseV1Schema.safeParse({ + protocolVersion: 1, + sessionID: '12345678901234567890123456', + }).success + ).toBe(false); + }); + + it('rejects a sessionID with a trailing underscore instead of ses_', () => { + expect( + createSessionResponseV1Schema.safeParse({ + protocolVersion: 1, + sessionID: 'se_12345678901234567890123456', + }).success + ).toBe(false); + }); + + it('rejects a non-string sessionID', () => { + expect( + createSessionResponseV1Schema.safeParse({ protocolVersion: 1, sessionID: 123 }).success + ).toBe(false); + }); + + it('rejects extra fields', () => { + expect( + createSessionResponseV1Schema.safeParse({ + protocolVersion: 1, + sessionID: VALID_SESSION_ID, + extra: true, + }).success + ).toBe(false); + }); + + it('rejects non-objects', () => { + expect(createSessionResponseV1Schema.safeParse(null).success).toBe(false); + expect(createSessionResponseV1Schema.safeParse(VALID_SESSION_ID).success).toBe(false); + expect(createSessionResponseV1Schema.safeParse(1).success).toBe(false); + }); +}); + +describe('parseCreateSessionResponse', () => { + it('returns the branded KiloSessionId for a valid envelope', () => { + const result = parseCreateSessionResponse({ protocolVersion: 1, sessionID: VALID_SESSION_ID }); + expect(result).toEqual({ ok: true, kiloSessionId: VALID_SESSION_ID }); + }); + + it('rejects an envelope with a wrong protocol version', () => { + const result = parseCreateSessionResponse({ protocolVersion: 2, sessionID: VALID_SESSION_ID }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects a missing sessionID', () => { + const result = parseCreateSessionResponse({ protocolVersion: 1 }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects a non-string sessionID', () => { + const result = parseCreateSessionResponse({ protocolVersion: 1, sessionID: 42 }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects an empty sessionID', () => { + const result = parseCreateSessionResponse({ protocolVersion: 1, sessionID: '' }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects a sessionID that is not a KiloSessionId', () => { + const result = parseCreateSessionResponse({ protocolVersion: 1, sessionID: 'ses_abc' }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects extra fields', () => { + const result = parseCreateSessionResponse({ + protocolVersion: 1, + sessionID: VALID_SESSION_ID, + sneaky: 'value', + }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects null, undefined, and primitives', () => { + expect(parseCreateSessionResponse(null)).toEqual({ ok: false, reason: 'invalid' }); + expect(parseCreateSessionResponse(undefined)).toEqual({ ok: false, reason: 'invalid' }); + expect(parseCreateSessionResponse(VALID_SESSION_ID)).toEqual({ ok: false, reason: 'invalid' }); + expect(parseCreateSessionResponse(1)).toEqual({ ok: false, reason: 'invalid' }); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/create-session.ts b/apps/web/src/lib/cloud-agent-sdk/create-session.ts new file mode 100644 index 0000000000..b90a4b7110 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-sdk/create-session.ts @@ -0,0 +1,37 @@ +/** + * Create-session response parser. + * + * `create_session` is a session-scoped viewer command sent on the user-web + * socket. Its success body is the strict `protocolVersion: 1` envelope + * (see `createSessionResponseV1Schema`). Anything outside that shape — extra + * fields, missing fields, the wrong protocol version, or a non-string + * `sessionID` — is rejected; the relay is the source of truth for this + * envelope and any drift must fail closed. + * + * The parser returns a `KiloSessionId`-branded `sessionID` so downstream + * consumers cannot accidentally pass a cloud-agent session ID into a + * session-scoped transport. + */ +import { createSessionResponseV1Schema, type CreateSessionResponseV1 } from './schemas'; +import type { KiloSessionId } from './types'; + +export { createSessionResponseV1Schema } from './schemas'; +export type { CreateSessionResponseV1 } from './schemas'; + +export type CreateSessionParseResult = + | { ok: true; kiloSessionId: KiloSessionId } + | { ok: false; reason: 'invalid' }; + +/** + * Parse a raw `create_session` response into a branded `KiloSessionId`. + * + * Returns `{ ok: true, kiloSessionId }` for a well-formed v1 envelope, or + * `{ ok: false, reason: 'invalid' }` for any other input. Callers can use + * the structured result to distinguish a malformed/oversized payload from a + * transport-level failure. + */ +export function parseCreateSessionResponse(raw: unknown): CreateSessionParseResult { + const parsed = createSessionResponseV1Schema.safeParse(raw); + if (!parsed.success) return { ok: false, reason: 'invalid' }; + return { ok: true, kiloSessionId: parsed.data.sessionID }; +} diff --git a/apps/web/src/lib/cloud-agent-sdk/exit-cli.test.ts b/apps/web/src/lib/cloud-agent-sdk/exit-cli.test.ts new file mode 100644 index 0000000000..b168bbb1c0 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-sdk/exit-cli.test.ts @@ -0,0 +1,11 @@ +import { parseExitCliResponse } from './exit-cli'; + +describe('parseExitCliResponse', () => { + it('accepts only a plain empty object', () => { + expect(parseExitCliResponse({})).toEqual({ ok: true }); + }); + + it.each([null, [], { extra: true }, undefined, 'ok', 1])('rejects %p', value => { + expect(parseExitCliResponse(value)).toEqual({ ok: false, reason: 'invalid' }); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/exit-cli.ts b/apps/web/src/lib/cloud-agent-sdk/exit-cli.ts new file mode 100644 index 0000000000..5c3b98f43f --- /dev/null +++ b/apps/web/src/lib/cloud-agent-sdk/exit-cli.ts @@ -0,0 +1,11 @@ +import * as z from 'zod'; + +const exitCliResponseSchema = z.object({}).strict(); + +export type ExitCliParseResult = { ok: true } | { ok: false; reason: 'invalid' }; + +export function parseExitCliResponse(raw: unknown): ExitCliParseResult { + return exitCliResponseSchema.safeParse(raw).success + ? { ok: true } + : { ok: false, reason: 'invalid' }; +} diff --git a/apps/web/src/lib/cloud-agent-sdk/index.ts b/apps/web/src/lib/cloud-agent-sdk/index.ts index 3d57d07021..101f13e635 100644 --- a/apps/web/src/lib/cloud-agent-sdk/index.ts +++ b/apps/web/src/lib/cloud-agent-sdk/index.ts @@ -18,7 +18,7 @@ export type { PrepareInput, } from './session-manager'; -export { createCloudAgentSession } from './session'; +export { createCloudAgentSession, REMOTE_CLI_EXIT_NOT_SUPPORTED } from './session'; export type { CloudAgentSession, CloudAgentSessionAcceptSuggestionInput, diff --git a/apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.test.ts b/apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.test.ts new file mode 100644 index 0000000000..2f12301c13 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.test.ts @@ -0,0 +1,258 @@ +import { parseRemoteCommandCatalog, remoteCommandCatalogV1Schema } from './remote-command-catalog'; + +describe('remote command catalog schema', () => { + it('accepts a strict v1 catalog and excludes skill commands', () => { + expect( + remoteCommandCatalogV1Schema.parse({ + protocolVersion: 1, + commands: [ + { + name: 'review', + description: 'Review changes', + source: 'command', + hints: ['$ARGUMENTS'], + }, + { + name: 'hidden-skill', + description: 'Not part of the remote surface', + source: 'skill', + hints: [], + }, + { + name: 'compact', + description: 'compact the current session context', + hints: [], + }, + ], + }) + ).toEqual({ + protocolVersion: 1, + commands: [ + { + name: 'review', + description: 'Review changes', + source: 'command', + hints: ['$ARGUMENTS'], + }, + { + name: 'compact', + description: 'compact the current session context', + hints: [], + }, + ], + }); + }); + + it('rejects unsupported protocols and unknown wire fields', () => { + const command = { + name: 'review', + description: 'Review changes', + agent: 'code', + model: 'anthropic/claude-sonnet-4', + source: 'command' as const, + hints: ['$ARGUMENTS'], + }; + const catalog = (commands: unknown[]) => ({ protocolVersion: 1, commands }); + + expect( + remoteCommandCatalogV1Schema.safeParse({ ...catalog([command]), protocolVersion: 2 }).success + ).toBe(false); + expect( + remoteCommandCatalogV1Schema.safeParse({ ...catalog([command]), extra: true }).success + ).toBe(false); + expect( + remoteCommandCatalogV1Schema.safeParse( + catalog([{ ...command, template: 'private implementation detail' }]) + ).success + ).toBe(false); + }); + + it('rejects more than 256 commands', () => { + const command = { + name: 'review', + description: 'Review changes', + source: 'command' as const, + hints: ['$ARGUMENTS'], + }; + expect( + remoteCommandCatalogV1Schema.safeParse({ + protocolVersion: 1, + commands: Array.from({ length: 257 }, () => command), + }).success + ).toBe(false); + }); + + it('rejects strings longer than 2,000 characters', () => { + const command = { + name: 'review', + description: 'Review changes', + agent: 'code', + model: 'anthropic/claude-sonnet-4', + source: 'command' as const, + hints: ['$ARGUMENTS'], + }; + const catalog = (commands: unknown[]) => ({ protocolVersion: 1, commands }); + + for (const field of ['name', 'description', 'agent', 'model'] as const) { + expect( + remoteCommandCatalogV1Schema.safeParse( + catalog([{ ...command, [field]: 'x'.repeat(2_001) }]) + ).success + ).toBe(false); + } + + expect( + remoteCommandCatalogV1Schema.safeParse(catalog([{ ...command, hints: ['x'.repeat(2_001)] }])) + .success + ).toBe(false); + }); + + it('rejects more than 32 hints per command', () => { + const command = { + name: 'review', + description: 'Review changes', + source: 'command' as const, + hints: Array.from({ length: 33 }, () => 'hint'), + }; + expect( + remoteCommandCatalogV1Schema.safeParse({ + protocolVersion: 1, + commands: [command], + }).success + ).toBe(false); + }); + + it('measures the 512 KiB serialized bound in UTF-8 bytes', () => { + const command = { + name: 'review', + description: 'Review changes', + source: 'command' as const, + hints: ['$ARGUMENTS'], + }; + const multibyteCatalog = { + protocolVersion: 1, + commands: Array.from({ length: 256 }, () => ({ + ...command, + description: 'é'.repeat(1_000), + })), + }; + const serialized = JSON.stringify(multibyteCatalog); + expect(serialized.length).toBeLessThan(512 * 1024); + expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(512 * 1024); + expect(remoteCommandCatalogV1Schema.safeParse(multibyteCatalog).success).toBe(false); + }); + + it('normalizes an omitted `hints` key to an empty array', () => { + // Older CLI versions serialize commands without a `hints` key at all. + // The wire shape must accept that and emit `hints: []` so SlashCommandInfo + // stays structurally satisfied without fail-closing the whole catalog. + const parsed = remoteCommandCatalogV1Schema.parse({ + protocolVersion: 1, + commands: [ + { name: 'review', description: 'Review changes', source: 'command' }, + { name: 'compact', description: 'compact the current session context' }, + ], + }); + expect(parsed).toEqual({ + protocolVersion: 1, + commands: [ + { name: 'review', description: 'Review changes', source: 'command', hints: [] }, + { name: 'compact', description: 'compact the current session context', hints: [] }, + ], + }); + }); + + it('preserves explicit hints values when provided', () => { + const parsed = remoteCommandCatalogV1Schema.parse({ + protocolVersion: 1, + commands: [ + { name: 'review', description: 'Review changes', source: 'command', hints: ['$ARGUMENTS'] }, + ], + }); + expect(parsed.commands[0]?.hints).toEqual(['$ARGUMENTS']); + }); + + it('still rejects malformed hints (non-string entry, over-length string, over cap)', () => { + const base = { + name: 'review', + description: 'Review changes', + source: 'command' as const, + }; + expect( + remoteCommandCatalogV1Schema.safeParse({ + protocolVersion: 1, + commands: [{ ...base, hints: [123] }], + }).success + ).toBe(false); + expect( + remoteCommandCatalogV1Schema.safeParse({ + protocolVersion: 1, + commands: [{ ...base, hints: ['x'.repeat(2_001)] }], + }).success + ).toBe(false); + expect( + remoteCommandCatalogV1Schema.safeParse({ + protocolVersion: 1, + commands: [{ ...base, hints: Array.from({ length: 33 }, () => 'hint') }], + }).success + ).toBe(false); + }); + + it('still rejects commands carrying unknown keys after relaxing hints', () => { + expect( + remoteCommandCatalogV1Schema.safeParse({ + protocolVersion: 1, + commands: [ + { + name: 'review', + description: 'Review changes', + source: 'command', + hints: ['$ARGUMENTS'], + template: 'private implementation detail', + }, + ], + }).success + ).toBe(false); + }); +}); + +describe('parseRemoteCommandCatalog', () => { + it('rejects a catalog whose two command entries share the same name', () => { + const result = parseRemoteCommandCatalog({ + protocolVersion: 1, + commands: [ + { name: 'review', description: 'first', source: 'command', hints: [] }, + { name: 'review', description: 'second', source: 'command', hints: [] }, + ], + }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('rejects duplicates even when one entry is a skill-sourced command', () => { + // Filtering happens only after untrusted catalog validation, so a + // `command` entry whose name collides with a `skill` entry is still + // ambiguous and the whole catalog must be rejected. + const result = parseRemoteCommandCatalog({ + protocolVersion: 1, + commands: [ + { name: 'review', description: 'visible', source: 'command', hints: [] }, + { name: 'review', description: 'hidden', source: 'skill', hints: [] }, + ], + }); + expect(result).toEqual({ ok: false, reason: 'invalid' }); + }); +}); + +describe('RemoteCommandState', () => { + it('always carries a `commands` array (empty when none discovered)', () => { + // The state is a public surface; consumers must be able to read + // `state.commands` unconditionally instead of tracking the cache + // separately. + const state = { + ownerConnectionId: null, + refresh: 'idle' as const, + commands: [] as never[], + }; + expect(Array.isArray(state.commands)).toBe(true); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.ts b/apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.ts new file mode 100644 index 0000000000..91bc24bb98 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-sdk/remote-command-catalog.ts @@ -0,0 +1,64 @@ +/** + * Remote CLI command catalog — strict, bounded parser owned by the SDK. + * + * The wire format is protocol v1. The CLI may send at most 256 commands, + * 32 hints each, 2,000 characters per string, and a serialized payload of + * 512 KiB measured in UTF-8 bytes. Skill-sourced commands are filtered + * defensively; the resulting catalog is the existing `SlashCommandInfo` + * shape consumed by the chat composer. + */ +import type { SlashCommandInfo } from './schemas'; +import { remoteCommandCatalogV1Schema } from './schemas'; + +export { + REMOTE_COMMAND_CATALOG_MAX_SERIALIZED_BYTES, + REMOTE_COMMAND_MAX_COMMANDS, + REMOTE_COMMAND_MAX_HINTS, + REMOTE_COMMAND_MAX_STRING_LENGTH, + remoteCommandCatalogV1Schema, +} from './schemas'; +export type { RemoteCommandCatalogV1 } from './schemas'; + +export type RemoteCommandParseResult = + | { ok: true; commands: SlashCommandInfo[] } + | { ok: false; reason: 'invalid' }; + +/** + * Non-fatal command-discovery state surfaced to the chat composer. + * + * `commands` is always present: it carries the last known valid catalog so + * consumers can render suggestions without tracking the cache separately. + * Successful discovery replaces it with the new catalog; transient same-owner + * failures, loading, and idle states keep the prior valid commands. Owner + * replacement/disconnect and malformed/oversized/upgrade-required failures + * clear it to `[]`. + * + * `refresh: 'error'` indicates a transient failure that retained the prior + * catalog; `refresh: 'upgrade-required'` indicates a relay-reported + * `CLI_UPGRADE_REQUIRED` that requires the user to upgrade the CLI; the + * `message` field carries the actionable copy in both cases. + */ +export type RemoteCommandState = { + ownerConnectionId: string | null; + refresh: 'idle' | 'loading' | 'error' | 'upgrade-required'; + commands: SlashCommandInfo[]; + message?: string; +}; + +/** + * Parse a remote command catalog response from the CLI. + * + * Returns the trimmed `SlashCommandInfo[]` for the chat composer, or a + * structured `invalid` result so callers can distinguish a malformed or + * oversized payload from a transport-level failure. + * + * The remote schema is `.strict()` and outputs a shape that is already + * structurally identical to `SlashCommandInfo` (the transform only filters + * skill-sourced entries and re-emits the rest verbatim), so no per-entry + * re-parse is needed. + */ +export function parseRemoteCommandCatalog(raw: unknown): RemoteCommandParseResult { + const parsed = remoteCommandCatalogV1Schema.safeParse(raw); + if (!parsed.success) return { ok: false, reason: 'invalid' }; + return { ok: true, commands: parsed.data.commands }; +} diff --git a/apps/web/src/lib/cloud-agent-sdk/schemas.ts b/apps/web/src/lib/cloud-agent-sdk/schemas.ts index f2b6d12bfa..4931287f6c 100644 --- a/apps/web/src/lib/cloud-agent-sdk/schemas.ts +++ b/apps/web/src/lib/cloud-agent-sdk/schemas.ts @@ -1,5 +1,6 @@ import * as z from 'zod'; import { sortRemoteModelCatalogProviders } from './remote-model-order'; +import type { KiloSessionId } from './types'; // --------------------------------------------------------------------------- // Wire-level envelope @@ -333,6 +334,71 @@ export const remoteModelCatalogV1Schema = remoteModelCatalogWireV1Schema.transfo }); export type RemoteModelCatalogV1 = z.output; +// --------------------------------------------------------------------------- +// Remote CLI command catalog +// --------------------------------------------------------------------------- + +export const REMOTE_COMMAND_MAX_COMMANDS = 256; +export const REMOTE_COMMAND_MAX_STRING_LENGTH = 2_000; +export const REMOTE_COMMAND_MAX_HINTS = 32; +export const REMOTE_COMMAND_CATALOG_MAX_SERIALIZED_BYTES = 512 * 1024; + +const remoteCommandStringSchema = z.string().max(REMOTE_COMMAND_MAX_STRING_LENGTH); +const remoteSlashCommandInfoSchema = z + .object({ + name: remoteCommandStringSchema.min(1), + description: remoteCommandStringSchema.optional(), + agent: remoteCommandStringSchema.optional(), + model: remoteCommandStringSchema.optional(), + source: z.enum(['command', 'mcp', 'skill']).optional(), + // `hints` is optional on the wire: older CLI versions omit it entirely. + // SlashCommandInfo requires a non-nullable array, so missing entries + // normalize to an empty array instead of fail-closing the catalog. + hints: z.array(remoteCommandStringSchema).max(REMOTE_COMMAND_MAX_HINTS).optional().default([]), + subtask: z.boolean().optional(), + }) + .strict(); + +export const remoteCommandCatalogV1Schema = z + .object({ + protocolVersion: z.literal(1), + commands: z.array(remoteSlashCommandInfoSchema).max(REMOTE_COMMAND_MAX_COMMANDS), + }) + .strict() + .superRefine((catalog, context) => { + const seenNames = new Set(); + for (const [commandIndex, command] of catalog.commands.entries()) { + if (seenNames.has(command.name)) { + context.addIssue({ + code: 'custom', + message: 'Command name must be unique', + path: ['commands', commandIndex, 'name'], + }); + continue; + } + seenNames.add(command.name); + } + const serializedBytes = new TextEncoder().encode(JSON.stringify(catalog)).byteLength; + if (serializedBytes > REMOTE_COMMAND_CATALOG_MAX_SERIALIZED_BYTES) { + context.addIssue({ + code: 'custom', + message: `Catalog cannot exceed ${REMOTE_COMMAND_CATALOG_MAX_SERIALIZED_BYTES} serialized bytes`, + }); + } + }) + .transform(catalog => ({ + protocolVersion: 1 as const, + commands: catalog.commands + .filter(command => command.source !== 'skill') + // SlashCommandInfo requires a `hints` array; remote commands with no + // hints still have an empty array after the strict shape parse above. + .map(command => ({ + ...command, + hints: command.hints, + })), + })); +export type RemoteCommandCatalogV1 = z.output; + export const userWebCommandErrorDataSchema = z .object({ source: z.literal('relay'), @@ -402,6 +468,32 @@ export const cliConnectionDataSchema = z.object({ }); export type CliConnectionData = z.infer; +export const kiloSessionIdSchema = z + .string() + .startsWith('ses_') + .length(30) + .transform(id => id as KiloSessionId); +export type KiloSessionIdInput = z.input; + +/** + * Strict create-session response (protocol v1). + * + * `create_session` is a session-scoped viewer command (the current Kilo + * sessionId is sent on the wire so the CLI can select the workspace; an + * optional owner connectionId fences the request to the active CLI). Its only + * valid success body is exactly this shape. Anything else — extra fields, + * missing fields, the wrong protocol version, or an invalid `sessionID` — is + * rejected; the relay is the source of truth for this envelope and any drift + * should fail closed. + */ +export const createSessionResponseV1Schema = z + .object({ + protocolVersion: z.literal(1), + sessionID: kiloSessionIdSchema, + }) + .strict(); +export type CreateSessionResponseV1 = z.infer; + // --------------------------------------------------------------------------- // V2 session system events // --------------------------------------------------------------------------- diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts index 3b0188789e..84a27bca21 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.test.ts @@ -1,4 +1,4 @@ -import { createStore } from 'jotai'; +import { createStore, atom } from 'jotai'; import { cliModelLabel, createSessionManager, @@ -7,7 +7,20 @@ import { type FetchedSessionData, type StoredMessage, } from './session-manager'; -import { createCloudAgentSession } from './session'; +import { + createCloudAgentSession, + REMOTE_CLI_EXIT_NOT_SUPPORTED, + REMOTE_SESSION_CREATION_NOT_SUPPORTED, +} from './session'; +import type { + CloudAgentSession, + CloudAgentSessionSendInput, + CloudAgentSessionAnswerInput, + CloudAgentSessionRejectInput, + CloudAgentSessionRespondToPermissionInput, + CloudAgentSessionAcceptSuggestionInput, + CloudAgentSessionDismissSuggestionInput, +} from './session'; import type { JotaiSessionStorage } from './storage/jotai'; import type { AssistantMessage, UserMessage } from '@/types/opencode.gen'; import { kiloId, cloudAgentId, stubUserMessage, stubTextPart, makeSnapshot } from './test-helpers'; @@ -21,12 +34,38 @@ import type { SessionSnapshotPageOutcome, } from './types'; import type { RemoteModelState } from './remote-model-catalog'; +import type { RemoteCommandState } from './remote-command-catalog'; import type { NormalizedEvent } from './normalizer'; // --------------------------------------------------------------------------- // Mock createCloudAgentSession — prevents real WebSocket connections // --------------------------------------------------------------------------- +type MockSession = Omit< + jest.Mocked, + | 'state' + | 'storage' + | 'send' + | 'interrupt' + | 'answer' + | 'reject' + | 'respondToPermission' + | 'acceptSuggestion' + | 'dismissSuggestion' + | 'exitRemoteCli' +> & { + state: jest.Mocked; + storage: JotaiSessionStorage | null; + send: jest.Mock, [CloudAgentSessionSendInput]>; + interrupt: jest.Mock, []>; + answer: jest.Mock, [CloudAgentSessionAnswerInput]>; + reject: jest.Mock, [CloudAgentSessionRejectInput]>; + respondToPermission: jest.Mock, [CloudAgentSessionRespondToPermissionInput]>; + acceptSuggestion: jest.Mock, [CloudAgentSessionAcceptSuggestionInput]>; + dismissSuggestion: jest.Mock, [CloudAgentSessionDismissSuggestionInput]>; + exitRemoteCli: jest.Mock, []>; +}; + const mockSession = { connect: jest.fn(), disconnect: jest.fn(), @@ -39,6 +78,9 @@ const mockSession = { acceptSuggestion: jest.fn(), dismissSuggestion: jest.fn(), retryRemoteModels: jest.fn(), + retryRemoteCommands: jest.fn(), + createRemoteSession: jest.fn(() => Promise.resolve(kiloId('ses_12345678901234567890123456'))), + exitRemoteCli: jest.fn(() => Promise.resolve()), canSend: true, canInterrupt: true, state: { @@ -57,7 +99,7 @@ const mockSession = { getPendingMessages: jest.fn, []>(() => new Map()), }, storage: null as JotaiSessionStorage | null, -}; +} as unknown as MockSession; const mockSessionCallbacks: { onSessionCreated?: (info: SessionInfo) => void; @@ -72,6 +114,7 @@ const mockSessionCallbacks: { onSuggestionResolved?: (...args: unknown[]) => void; onResolved?: (resolved: ResolvedSession) => void; onRemoteModelStateChange?: (state: RemoteModelState) => void; + onRemoteCommandStateChange?: (state: RemoteCommandState) => void; onTransportCapabilityChange?: () => void; onEvent?: (event: NormalizedEvent) => void; onMessageQueued?: (messageId: string) => void; @@ -86,6 +129,9 @@ const mockSessionCallbacks: { let latestStorage: JotaiSessionStorage | null = null; jest.mock('./session', () => ({ + REMOTE_CLI_EXIT_NOT_SUPPORTED: 'Remote CLI exit is not supported for the current session', + REMOTE_SESSION_CREATION_NOT_SUPPORTED: + 'Remote session creation is not supported for the current session', createCloudAgentSession: jest.fn( (sessionConfig: { kiloSessionId: string; @@ -102,6 +148,7 @@ jest.mock('./session', () => ({ onSuggestionResolved?: (...args: unknown[]) => void; onResolved?: (resolved: ResolvedSession) => void; onRemoteModelStateChange?: (state: RemoteModelState) => void; + onRemoteCommandStateChange?: (state: RemoteCommandState) => void; onTransportCapabilityChange?: () => void; onEvent?: (event: NormalizedEvent) => void; onMessageQueued?: (messageId: string) => void; @@ -171,6 +218,7 @@ jest.mock('./session', () => ({ mockSessionCallbacks.onSuggestionResolved = sessionConfig.onSuggestionResolved; mockSessionCallbacks.onResolved = sessionConfig.onResolved; mockSessionCallbacks.onRemoteModelStateChange = sessionConfig.onRemoteModelStateChange; + mockSessionCallbacks.onRemoteCommandStateChange = sessionConfig.onRemoteCommandStateChange; mockSessionCallbacks.onTransportCapabilityChange = sessionConfig.onTransportCapabilityChange; mockSessionCallbacks.onEvent = sessionConfig.onEvent; mockSessionCallbacks.onMessageQueued = sessionConfig.onMessageQueued; @@ -343,6 +391,8 @@ describe('createSessionManager', () => { mockSession.destroy.mockClear(); mockSession.send.mockClear(); mockSession.interrupt.mockClear(); + mockSession.exitRemoteCli.mockClear(); + mockSession.exitRemoteCli.mockResolvedValue(); mockSession.respondToPermission.mockClear(); mockSession.canSend = true; mockSession.canInterrupt = true; @@ -2939,38 +2989,344 @@ describe('createSessionManager', () => { }); // ------------------------------------------------------------------------- - // delivery failure indicator + // createRemoteSession // ------------------------------------------------------------------------- - describe('delivery failure status indicator', () => { - it('exhausted-retry failure sets an error indicator and leaves failedPrompt null', async () => { + describe('createRemoteSession', () => { + it('returns a branded KiloSessionId and leaves current session/atoms unchanged', async () => { const config = createMockConfig(); const mgr = createSessionManager(config); + const newId = kiloId('ses_99999999999999999999999999'); + mockSession.createRemoteSession.mockResolvedValue(newId); await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); - const onMessageFailed = mockSessionCallbacks.onMessageFailed; - if (!onMessageFailed) { - throw new Error('Expected onMessageFailed to be plumbed through'); - } - onMessageFailed('m1', { - status: 'failed', - error: 'flush failed', - reason: 'exhausted', - attempts: 5, - }); + const beforeSessionId = atomValue(config.store, mgr.atoms.sessionId); + const result = await mgr.createRemoteSession(); - const indicator = atomValue<{ type: string; message: string } | null>( - config.store, - mgr.atoms.statusIndicator + expect(result).toBe(newId); + expect(mockSession.createRemoteSession).toHaveBeenCalledTimes(1); + expect(atomValue(config.store, mgr.atoms.sessionId)).toBe(beforeSessionId); + }); + + it('rejects before traffic with a stable error when there is no active session', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await expect(mgr.createRemoteSession()).rejects.toThrow( + REMOTE_SESSION_CREATION_NOT_SUPPORTED ); - expect(indicator).toEqual( - expect.objectContaining({ type: 'error', message: 'Message failed to deliver' }) + expect(mockSession.createRemoteSession).not.toHaveBeenCalled(); + }); + + it('rejects before traffic with a stable error for a non-remote session', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + // default mock resolves to cloud-agent + await expect(mgr.createRemoteSession()).rejects.toThrow( + REMOTE_SESSION_CREATION_NOT_SUPPORTED ); - expect(atomValue(config.store, mgr.atoms.failedPrompt)).toBeNull(); + expect(mockSession.createRemoteSession).not.toHaveBeenCalled(); + }); + + it('rejects before traffic with a stable error when the transport lacks createSession capability', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + mockSession.createRemoteSession.mockRejectedValue( + new Error(REMOTE_SESSION_CREATION_NOT_SUPPORTED) + ); + await expect(mgr.createRemoteSession()).rejects.toThrow( + REMOTE_SESSION_CREATION_NOT_SUPPORTED + ); + }); + }); + + describe('exitRemoteCli', () => { + it('forwards only for the active remote session', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + + await expect(mgr.exitRemoteCli()).resolves.toBeUndefined(); + expect(mockSession.exitRemoteCli).toHaveBeenCalledTimes(1); + }); + + it('rejects before forwarding when there is no active session', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await expect(mgr.exitRemoteCli()).rejects.toThrow(REMOTE_CLI_EXIT_NOT_SUPPORTED); + expect(mockSession.exitRemoteCli).not.toHaveBeenCalled(); + }); + + it('rejects before forwarding for a non-remote session', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + + await expect(mgr.exitRemoteCli()).rejects.toThrow(REMOTE_CLI_EXIT_NOT_SUPPORTED); + expect(mockSession.exitRemoteCli).not.toHaveBeenCalled(); + }); + + it('propagates the session unsupported error when transport capability is absent', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + mockSession.exitRemoteCli.mockRejectedValue(new Error(REMOTE_CLI_EXIT_NOT_SUPPORTED)); + + await expect(mgr.exitRemoteCli()).rejects.toThrow(REMOTE_CLI_EXIT_NOT_SUPPORTED); + }); + }); + + // ------------------------------------------------------------------------- + // retryRemoteCommands + // ------------------------------------------------------------------------- + + describe('retryRemoteCommands', () => { + it('delegates to the active session when a remote session is resolved', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + mgr.retryRemoteCommands(); + expect(mockSession.retryRemoteCommands).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when there is no active session', () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + expect(() => mgr.retryRemoteCommands()).not.toThrow(); + expect(mockSession.retryRemoteCommands).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // remote command state atom + // ------------------------------------------------------------------------- + + describe('remote command state atom', () => { + it('starts empty after resolving to a remote session', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual({ + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }); + }); + + it('updates when the remote command state callback fires', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + const nextState: RemoteCommandState = { + ownerConnectionId: 'owner', + refresh: 'idle', + commands: [{ name: 'review', description: 'Review changes', hints: [] }], + }; + mockSessionCallbacks.onRemoteCommandStateChange?.(nextState); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual(nextState); + }); + }); + + // ------------------------------------------------------------------------- + // switchSession clears remote command state immediately + // ------------------------------------------------------------------------- + + describe('switchSession remote command state clearing', () => { + it('clears availableCommands and remoteCommandState before the new fetch resolves', async () => { + let resolveFetch: (val: FetchedSessionData) => void; + const slowFetch = new Promise(resolve => { + resolveFetch = resolve; + }); + const config = createMockConfig({ + fetchSession: jest + .fn() + .mockResolvedValueOnce(defaultFetchedSession) + .mockReturnValueOnce(slowFetch), + }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + mockSessionCallbacks.onRemoteCommandStateChange?.({ + ownerConnectionId: 'owner-a', + refresh: 'idle', + commands: [{ name: 'review', hints: [] }], + }); + mockSessionCallbacks.onEvent?.({ + type: 'commands.available', + commands: [{ name: 'review', hints: [] }], + }); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual( + expect.objectContaining({ + ownerConnectionId: 'owner-a', + commands: [{ name: 'review', hints: [] }], + }) + ); + expect(atomValue(config.store, mgr.atoms.availableCommands)).toHaveLength(1); + + const switchPromise = mgr.switchSession(kiloId('ses-2')); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual({ + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }); + expect(atomValue(config.store, mgr.atoms.availableCommands)).toHaveLength(0); + + resolveFetch!(defaultFetchedSession); + await switchPromise; }); + }); - it('interrupted queued failure sets an error indicator with the interrupted wording', async () => { + // ------------------------------------------------------------------------- + // generation gating for remote command callbacks + // ------------------------------------------------------------------------- + + describe('generation gating for remote command callbacks', () => { + it('ignores late callbacks from a previous session after a new switch begins', async () => { + let resolveFetch: (val: FetchedSessionData) => void; + const slowFetch = new Promise(resolve => { + resolveFetch = resolve; + }); + const config = createMockConfig({ + fetchSession: jest + .fn() + .mockResolvedValueOnce(defaultFetchedSession) + .mockReturnValueOnce(slowFetch), + }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + const firstCallbacks = { ...mockSessionCallbacks }; + firstCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + firstCallbacks.onRemoteCommandStateChange?.({ + ownerConnectionId: 'owner-a', + refresh: 'idle', + commands: [{ name: 'review', hints: [] }], + }); + firstCallbacks.onEvent?.({ + type: 'commands.available', + commands: [{ name: 'review', hints: [] }], + }); + + const switchPromise = mgr.switchSession(kiloId('ses-2')); + firstCallbacks.onRemoteCommandStateChange?.({ + ownerConnectionId: 'stale-owner', + refresh: 'idle', + commands: [{ name: 'stale', hints: [] }], + }); + firstCallbacks.onEvent?.({ + type: 'commands.available', + commands: [{ name: 'stale', hints: [] }], + }); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual({ + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }); + expect(atomValue(config.store, mgr.atoms.availableCommands)).toHaveLength(0); + + resolveFetch!(defaultFetchedSession); + await switchPromise; + + // New session callbacks can still update state. + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-2') }); + mockSessionCallbacks.onRemoteCommandStateChange?.({ + ownerConnectionId: 'owner-b', + refresh: 'idle', + commands: [{ name: 'new', hints: [] }], + }); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual({ + ownerConnectionId: 'owner-b', + refresh: 'idle', + commands: [{ name: 'new', hints: [] }], + }); + }); + }); + + // ------------------------------------------------------------------------- + // destroy + late callback suppression + // ------------------------------------------------------------------------- + + describe('destroy', () => { + it('clears remote command state and availableCommands and ignores late callbacks', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + await mgr.switchSession(kiloId('ses-1')); + const firstCallbacks = { ...mockSessionCallbacks }; + mockSessionCallbacks.onResolved?.({ type: 'remote', kiloSessionId: kiloId('ses-1') }); + mockSessionCallbacks.onRemoteCommandStateChange?.({ + ownerConnectionId: 'owner-a', + refresh: 'idle', + commands: [{ name: 'review', hints: [] }], + }); + mockSessionCallbacks.onEvent?.({ + type: 'commands.available', + commands: [{ name: 'review', hints: [] }], + }); + expect( + atomValue(config.store, mgr.atoms.remoteCommandState).commands + ).toHaveLength(1); + expect(atomValue(config.store, mgr.atoms.availableCommands)).toHaveLength(1); + + mgr.destroy(); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual({ + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }); + expect(atomValue(config.store, mgr.atoms.availableCommands)).toHaveLength(0); + + firstCallbacks.onRemoteCommandStateChange?.({ + ownerConnectionId: 'stale', + refresh: 'idle', + commands: [{ name: 'stale', hints: [] }], + }); + firstCallbacks.onEvent?.({ + type: 'commands.available', + commands: [{ name: 'stale', hints: [] }], + }); + expect(atomValue(config.store, mgr.atoms.remoteCommandState)).toEqual({ + ownerConnectionId: null, + refresh: 'idle', + commands: [], + }); + expect(atomValue(config.store, mgr.atoms.availableCommands)).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // clearAllAtoms behavior + // ------------------------------------------------------------------------- + + describe('clearAllAtoms', () => { + it('resets session atoms without touching unrelated store atoms', () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + const externalAtom = atom(42); + config.store.set(externalAtom, 99); + config.store.set(mgr.atoms.chatUI, { shouldAutoScroll: false }); + + mgr.destroy(); + + expect(atomValue(config.store, externalAtom)).toBe(99); + expect(atomValue(config.store, mgr.atoms.chatUI)).toEqual({ shouldAutoScroll: true }); + }); + }); + // ------------------------------------------------------------------------- + // delivery failure status indicator + // ------------------------------------------------------------------------- + + describe('delivery failure status indicator', () => { + it('exhausted-retry failure sets an error indicator and leaves failedPrompt null', async () => { const config = createMockConfig(); const mgr = createSessionManager(config); diff --git a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts index d18a469f8d..8443e8b9b4 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-manager.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-manager.ts @@ -9,9 +9,14 @@ import type { RemoteModelOverride, RemoteModelState, } from './remote-model-catalog'; +import type { RemoteCommandState } from './remote-command-catalog'; import { atom } from 'jotai'; import type { Atom, WritableAtom } from 'jotai'; -import { createCloudAgentSession } from './session'; +import { + createCloudAgentSession, + REMOTE_CLI_EXIT_NOT_SUPPORTED, + REMOTE_SESSION_CREATION_NOT_SUPPORTED, +} from './session'; import type { CloudAgentSession } from './session'; import { createChatProcessor } from './chat-processor'; import { createJotaiStorage } from './storage/jotai'; @@ -103,6 +108,12 @@ const EMPTY_REMOTE_MODEL_STATE = { refresh: 'idle', } satisfies RemoteModelState; +const EMPTY_REMOTE_COMMAND_STATE = { + ownerConnectionId: null, + refresh: 'idle', + commands: [], +} satisfies RemoteCommandState; + type AssociatedPrData = { url: string; number: number; @@ -198,6 +209,7 @@ type SessionManagerAtoms = { supportsAttachments: W; activeSessionType: W; remoteModelState: W; + remoteCommandState: W; observedModel: W; remoteModelOverride: W; canSend: W; @@ -260,6 +272,9 @@ type SessionManager = { }): Promise; setRemoteModelOverride(override: RemoteModelOverride | null): void; retryRemoteModels(): void; + retryRemoteCommands(): void; + createRemoteSession(): Promise; + exitRemoteCli(): Promise; interrupt(): Promise; answerQuestion(requestId: string, answers: string[][]): Promise; rejectQuestion(requestId: string): Promise; @@ -392,6 +407,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { const supportsAttachmentsAtom = atom(false); const activeSessionTypeAtom = atom(null); const remoteModelStateAtom = atom(EMPTY_REMOTE_MODEL_STATE); + const remoteCommandStateAtom = atom(EMPTY_REMOTE_COMMAND_STATE); const observedModelAtom = atom(null); const remoteModelOverrideAtom = atom(null); const canSendAtom = atom(false); @@ -528,6 +544,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { store.set(supportsAttachmentsAtom, false); store.set(activeSessionTypeAtom, null); store.set(remoteModelStateAtom, EMPTY_REMOTE_MODEL_STATE); + store.set(remoteCommandStateAtom, EMPTY_REMOTE_COMMAND_STATE); store.set(observedModelAtom, null); observedModelSource = null; remoteHistoryReplaying = true; @@ -1080,10 +1097,16 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { updateCapabilityAtoms(session); }, onRemoteModelStateChange: handleRemoteModelStateChange, + onRemoteCommandStateChange: state => { + if (expectedGeneration !== switchGeneration) return; + store.set(remoteCommandStateAtom, state); + }, onTransportCapabilityChange: () => { + if (expectedGeneration !== switchGeneration) return; if (currentSession === session) updateCapabilityAtoms(session); }, onReplayComplete: () => { + if (expectedGeneration !== switchGeneration) return; remoteHistoryReplaying = false; }, @@ -1104,6 +1127,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { setIndicator({ type: 'error', message, timestamp: Date.now() }); }, onEvent: event => { + if (expectedGeneration !== switchGeneration) return; if (event.type === 'commands.available') { // Replace the catalog wholesale. The DO sends the full list on // every connect, so we never need to merge incrementally. @@ -1336,6 +1360,24 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { currentSession?.retryRemoteModels(); } + function retryRemoteCommands(): void { + currentSession?.retryRemoteCommands(); + } + + async function createRemoteSession(): Promise { + if (!currentSession || activeSessionType !== 'remote') { + throw new Error(REMOTE_SESSION_CREATION_NOT_SUPPORTED); + } + return currentSession.createRemoteSession(); + } + + async function exitRemoteCli(): Promise { + if (!currentSession || activeSessionType !== 'remote') { + throw new Error(REMOTE_CLI_EXIT_NOT_SUPPORTED); + } + return currentSession.exitRemoteCli(); + } + function destroy(): void { childSessionHydrationGeneration += 1; childSessionHydrationRequests.clear(); @@ -1360,6 +1402,9 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { send, setRemoteModelOverride, retryRemoteModels, + retryRemoteCommands, + createRemoteSession, + exitRemoteCli, interrupt, answerQuestion, rejectQuestion, @@ -1379,6 +1424,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { supportsAttachments: supportsAttachmentsAtom, activeSessionType: activeSessionTypeAtom, remoteModelState: remoteModelStateAtom, + remoteCommandState: remoteCommandStateAtom, observedModel: observedModelAtom, remoteModelOverride: remoteModelOverrideAtom, canSend: canSendAtom, diff --git a/apps/web/src/lib/cloud-agent-sdk/session-routing.test.ts b/apps/web/src/lib/cloud-agent-sdk/session-routing.test.ts index e9db81a700..b35e9ecb86 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-routing.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-routing.test.ts @@ -128,6 +128,7 @@ describe('session transport routing', () => { destroy: jest.fn(), subscribeToCliSession: jest.fn(() => release), sendCommand: jest.fn(() => Promise.resolve()), + sendCommandToConnection: jest.fn(() => Promise.resolve()), onCliEvent: jest.fn(() => jest.fn()), onSystemEvent: jest.fn(() => jest.fn()), onReconnect: jest.fn(() => jest.fn()), @@ -215,6 +216,7 @@ describe('session transport routing', () => { destroy: jest.fn(), subscribeToCliSession: jest.fn(() => subscribeRelease), sendCommand: jest.fn(() => Promise.resolve()), + sendCommandToConnection: jest.fn(() => Promise.resolve()), onCliEvent: jest.fn(() => jest.fn()), onSystemEvent: jest.fn((listener: (event: { event: string; data: unknown }) => void) => { systemListeners.add(listener); diff --git a/apps/web/src/lib/cloud-agent-sdk/session-transport.test.ts b/apps/web/src/lib/cloud-agent-sdk/session-transport.test.ts index 6426dfe37f..df57f4651c 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session-transport.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session-transport.test.ts @@ -1,4 +1,5 @@ -import { createCloudAgentSession, type CloudAgentSession } from './session'; +import { createCloudAgentSession, REMOTE_SESSION_CREATION_NOT_SUPPORTED } from './session'; +import type { CloudAgentSession } from './session'; import type { CloudAgentApi } from './transport'; import type { KiloSessionId } from './types'; import type { UserWebSystemEvent } from './user-web-connection'; @@ -104,6 +105,7 @@ function createUserWebConnection() { : { ok: true } ) ), + sendCommandToConnection: jest.fn(), onCliEvent: jest.fn(() => jest.fn()), onSystemEvent: jest.fn((listener: (event: UserWebSystemEvent) => void) => { systemListener = listener; @@ -260,6 +262,18 @@ describe('session transport delegation (cloud agent)', () => { session.destroy(); }); + + it('session.createRemoteSession() rejects for cloud-agent sessions', async () => { + const api = createMockApi(); + const session = createCloudAgentResolvedSession(api); + + await connectSession(session); + await expect(session.createRemoteSession()).rejects.toThrow( + REMOTE_SESSION_CREATION_NOT_SUPPORTED + ); + + session.destroy(); + }); }); describe('commands throw before transport is connected', () => { diff --git a/apps/web/src/lib/cloud-agent-sdk/session.test.ts b/apps/web/src/lib/cloud-agent-sdk/session.test.ts index a8fd86615e..54ad04a8b8 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session.test.ts @@ -5,9 +5,10 @@ * we wire the same components directly — mirroring session.ts's event routing logic. */ import { createTestSession, kiloId } from './test-helpers'; -import { createCloudAgentSession } from './session'; +import { createCloudAgentSession, REMOTE_CLI_EXIT_NOT_SUPPORTED } from './session'; import type { RemoteModelState } from './remote-model-catalog'; -import type { UserWebSystemEvent } from './user-web-connection'; +import type { KiloSessionId } from './types'; +import { UserWebCommandError, type UserWebSystemEvent } from './user-web-connection'; import { createEventHelpers, sessionInfo, @@ -382,6 +383,7 @@ describe('remote session transport state', () => { truncated: false, }) ), + sendCommandToConnection: jest.fn(), onCliEvent: jest.fn(() => jest.fn()), onSystemEvent: jest.fn((listener: (event: UserWebSystemEvent) => void) => { systemListener = listener; @@ -430,3 +432,231 @@ describe('remote session transport state', () => { session.destroy(); }); }); + +describe('remote session create and retry commands', () => { + const REMOTE_CREATED_SESSION_ID = 'ses_12345678901234567890123456'; + + function createRemoteSessionFixture() { + let systemListener: ((event: UserWebSystemEvent) => void) | undefined; + const userWebConnection = { + connect: jest.fn(), + disconnect: jest.fn(), + destroy: jest.fn(), + subscribeToCliSession: jest.fn(() => jest.fn()), + sendCommand: jest.fn, [string, string, unknown, string?]>(() => + Promise.resolve({ + all: [], + default: {}, + connected: [], + failed: [], + protocolVersion: 1, + truncated: false, + }) + ), + sendCommandToConnection: jest.fn(), + onCliEvent: jest.fn(() => jest.fn()), + onSystemEvent: jest.fn((listener: (event: UserWebSystemEvent) => void) => { + systemListener = listener; + return jest.fn(); + }), + onReconnect: jest.fn(() => jest.fn()), + onSessionEvent: jest.fn(() => jest.fn()), + }; + const kiloSessionId = kiloId('ses-remote'); + const session = createCloudAgentSession({ + kiloSessionId, + resolveSession: () => Promise.resolve({ type: 'remote', kiloSessionId }), + transport: { userWebConnection }, + }); + + return { userWebConnection, session, systemListener: () => systemListener }; + } + + function emitOwner( + systemListener: ((event: UserWebSystemEvent) => void) | undefined, + kiloSessionId: KiloSessionId + ): void { + systemListener?.({ + event: 'sessions.list', + data: { + sessions: [{ id: kiloSessionId, status: 'active', title: 'Remote', connectionId: 'owner' }], + }, + }); + } + + it('createRemoteSession returns the KiloSessionId from the remote transport', async () => { + const { userWebConnection, session, systemListener } = createRemoteSessionFixture(); + + session.connect(); + await Promise.resolve(); + emitOwner(systemListener(), kiloId('ses-remote')); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + jest + .mocked(userWebConnection.sendCommand) + .mockResolvedValue({ protocolVersion: 1, sessionID: REMOTE_CREATED_SESSION_ID }); + + const result = await session.createRemoteSession(); + expect(result).toBe(REMOTE_CREATED_SESSION_ID); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + kiloId('ses-remote'), + 'create_session', + { protocolVersion: 1 }, + 'owner' + ); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + session.destroy(); + }); + + it('createRemoteSession propagates an actionable UserWebCommandError', async () => { + const { userWebConnection, session, systemListener } = createRemoteSessionFixture(); + + session.connect(); + await Promise.resolve(); + emitOwner(systemListener(), kiloId('ses-remote')); + await Promise.resolve(); + await Promise.resolve(); + + jest.mocked(userWebConnection.sendCommand).mockClear(); + jest.mocked(userWebConnection.sendCommand).mockRejectedValue( + new UserWebCommandError({ + code: 'CLI_UPGRADE_REQUIRED', + message: 'Creating remote sessions from mobile requires a newer Kilo CLI.', + }) + ); + + await expect(session.createRemoteSession()).rejects.toEqual( + expect.objectContaining({ + name: 'UserWebCommandError', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Creating remote sessions from mobile requires a newer Kilo CLI.', + }) + ); + expect(userWebConnection.sendCommandToConnection).not.toHaveBeenCalled(); + session.destroy(); + }); + + it('retryRemoteCommands delegates to the remote transport', async () => { + const { userWebConnection, session, systemListener } = createRemoteSessionFixture(); + let commandRequest = 0; + jest.mocked(userWebConnection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') { + return Promise.resolve({ + all: [], + default: {}, + connected: [], + failed: [], + protocolVersion: 1, + truncated: false, + }); + } + commandRequest += 1; + return commandRequest === 1 + ? Promise.resolve({ protocolVersion: 1, commands: [] }) + : Promise.resolve({ protocolVersion: 1, commands: [{ name: 'review', hints: [] }] }); + }); + + session.connect(); + await Promise.resolve(); + emitOwner(systemListener(), kiloId('ses-remote')); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const initialListCommands = jest + .mocked(userWebConnection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands').length; + + session.retryRemoteCommands(); + await Promise.resolve(); + await Promise.resolve(); + + expect( + jest + .mocked(userWebConnection.sendCommand) + .mock.calls.filter(([, command]) => command === 'list_commands') + ).toHaveLength(initialListCommands + 1); + session.destroy(); + }); + + it('exitRemoteCli forwards through the remote transport', async () => { + const { userWebConnection, session, systemListener } = createRemoteSessionFixture(); + jest.mocked(userWebConnection.sendCommand).mockImplementation((_sessionId, command) => { + if (command === 'list_models') { + return Promise.resolve({ + all: [], + default: {}, + connected: [], + failed: [], + protocolVersion: 1, + truncated: false, + }); + } + if (command === 'list_commands') { + return Promise.resolve({ + protocolVersion: 1, + commands: [{ name: 'exit', description: 'Exit the CLI', hints: [] }], + }); + } + return Promise.resolve({}); + }); + + session.connect(); + await Promise.resolve(); + emitOwner(systemListener(), kiloId('ses-remote')); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + jest.mocked(userWebConnection.sendCommand).mockClear(); + + await expect(session.exitRemoteCli()).resolves.toBeUndefined(); + expect(userWebConnection.sendCommand).toHaveBeenCalledWith( + kiloId('ses-remote'), + 'exit_cli', + { protocolVersion: 1 }, + 'owner' + ); + session.destroy(); + }); + + it.each(['cloud-agent', 'read-only'] as const)( + 'exitRemoteCli rejects with the stable unsupported error for %s sessions', + async type => { + const kiloSessionId = kiloId('ses-unsupported'); + const session = createCloudAgentSession({ + kiloSessionId, + resolveSession: () => + Promise.resolve( + type === 'cloud-agent' + ? { + type, + kiloSessionId, + cloudAgentSessionId: 'agent-unsupported' as never, + } + : { type, kiloSessionId } + ), + websocketBaseUrl: 'ws://example.test', + transport: { + getTicket: () => 'ticket', + api: { + send: jest.fn(), + interrupt: jest.fn(), + answer: jest.fn(), + reject: jest.fn(), + respondToPermission: jest.fn(), + }, + fetchSnapshot: () => Promise.resolve({ info: { id: kiloSessionId }, messages: [] }), + }, + }); + + session.connect(); + await Promise.resolve(); + await Promise.resolve(); + + await expect(session.exitRemoteCli()).rejects.toThrow(REMOTE_CLI_EXIT_NOT_SUPPORTED); + session.destroy(); + } + ); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/session.ts b/apps/web/src/lib/cloud-agent-sdk/session.ts index a29d386ed8..13f0b196e1 100644 --- a/apps/web/src/lib/cloud-agent-sdk/session.ts +++ b/apps/web/src/lib/cloud-agent-sdk/session.ts @@ -11,6 +11,7 @@ import type { Images } from '@/lib/images-schema'; import type { NormalizedEvent } from './normalizer'; import type { SuggestionAction } from './types'; import type { RemoteModelOverride, RemoteModelState } from './remote-model-catalog'; +import type { RemoteCommandState } from './remote-command-catalog'; import { createChatProcessor } from './chat-processor'; import { heartbeatDataSchema, sessionsListDataSchema } from './schemas'; import { createServiceState } from './service-state'; @@ -41,6 +42,10 @@ import type { SessionSnapshotPageOutcome, } from './types'; +const REMOTE_SESSION_CREATION_NOT_SUPPORTED = + 'Remote session creation is not supported for the current session'; +const REMOTE_CLI_EXIT_NOT_SUPPORTED = 'Remote CLI exit is not supported for the current session'; + type CloudAgentSessionConfig = { kiloSessionId: KiloSessionId; resolveSession: (kiloSessionId: KiloSessionId) => Promise; @@ -68,6 +73,7 @@ type CloudAgentSessionConfig = { onBranchChanged?: (branch: string) => void; onResolved?: (resolved: ResolvedSession) => void; onRemoteModelStateChange?: (state: RemoteModelState) => void; + onRemoteCommandStateChange?: (state: RemoteCommandState) => void; onTransportCapabilityChange?: () => void; onSessionCreated?: (info: SessionInfo) => void; onSessionUpdated?: (info: SessionInfo) => void; @@ -161,6 +167,9 @@ type CloudAgentSession = { payload: CloudAgentSessionDismissSuggestionInput ) => unknown | Promise; retryRemoteModels: () => void; + retryRemoteCommands: () => void; + createRemoteSession: () => Promise; + exitRemoteCli: () => Promise; // Capability checks canSend: boolean; @@ -277,6 +286,7 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes onInitialPageLoaded: config.transport.onInitialPageLoaded, onError: config.onError, onRemoteModelStateChange: config.onRemoteModelStateChange, + onRemoteCommandStateChange: config.onRemoteCommandStateChange, onCapabilityChange: config.onTransportCapabilityChange, }); } @@ -443,6 +453,21 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes retryRemoteModels() { transport?.retryRemoteModels?.(); }, + retryRemoteCommands() { + transport?.retryRemoteCommands?.(); + }, + createRemoteSession: async () => { + if (!transport?.createSession) { + throw new Error(REMOTE_SESSION_CREATION_NOT_SUPPORTED); + } + return transport.createSession(); + }, + exitRemoteCli: async () => { + if (!transport?.exitCli) { + throw new Error(REMOTE_CLI_EXIT_NOT_SUPPORTED); + } + return transport.exitCli(); + }, get canSend() { return transport?.send !== undefined && (transport.canSend?.() ?? true); }, @@ -475,7 +500,11 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes }; } -export { createCloudAgentSession }; +export { + createCloudAgentSession, + REMOTE_CLI_EXIT_NOT_SUPPORTED, + REMOTE_SESSION_CREATION_NOT_SUPPORTED, +}; export type { CloudAgentSession, CloudAgentSessionAcceptSuggestionInput, diff --git a/apps/web/src/lib/cloud-agent-sdk/transport.ts b/apps/web/src/lib/cloud-agent-sdk/transport.ts index 0ed8a21454..f4e99896ae 100644 --- a/apps/web/src/lib/cloud-agent-sdk/transport.ts +++ b/apps/web/src/lib/cloud-agent-sdk/transport.ts @@ -7,7 +7,7 @@ import type { ChatEvent, ServiceEvent } from './normalizer'; import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import type { Images } from '@/lib/images-schema'; -import type { CloudAgentSessionId } from './types'; +import type { CloudAgentSessionId, KiloSessionId } from './types'; import type { ModelRef, RemoteModelOverride } from './remote-model-catalog'; type CloudAgentStreamTicket = { @@ -79,6 +79,19 @@ type Transport = { send?: (payload: TransportSendInput) => Promise; canSend?: () => boolean; retryRemoteModels?: () => void; + /** Re-discover the remote slash command catalog for the current owner. No-op when no owner is known or a request is already in flight. */ + retryRemoteCommands?: () => void; + /** + * Ask the currently connected CLI owner to create a new remote session and + * return its branded `KiloSessionId`. Session-scoped: the current Kilo + * sessionId is sent so the CLI can select the workspace, and an expected owner + * connectionId fences the request to the active CLI. Implementations must not + * auto-retry: a network failure is a hard reject so the caller can surface a + * retryable error. The caller does NOT switch the active session as a side + * effect. + */ + createSession?: () => Promise; + exitCli?: () => Promise; interrupt?: () => Promise; answer?: (payload: { requestId: string; answers: string[][] }) => Promise; reject?: (payload: { requestId: string }) => Promise; diff --git a/apps/web/src/lib/cloud-agent-sdk/user-web-connection.test.ts b/apps/web/src/lib/cloud-agent-sdk/user-web-connection.test.ts index 3ab0ec5bad..d826e9b040 100644 --- a/apps/web/src/lib/cloud-agent-sdk/user-web-connection.test.ts +++ b/apps/web/src/lib/cloud-agent-sdk/user-web-connection.test.ts @@ -1461,3 +1461,173 @@ describe('createUserWebConnection', () => { await expect(promise).rejects.toThrow('Connection destroyed'); }); }); + +describe('createUserWebConnection sendCommandToConnection', () => { + it('sends a connection-scoped wire frame with connectionId and no sessionId', async () => { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + client.connect(); + open(); + + const promise = client.sendCommandToConnection({ + command: 'runtime_status', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + await Promise.resolve(); + + expect(sockets[0].send).toHaveBeenCalledWith( + JSON.stringify({ + type: 'command', + id: 'uuid-2', + command: 'runtime_status', + connectionId: 'cli-owner-1', + data: { protocolVersion: 1 }, + }) + ); + expect(JSON.parse(jest.mocked(sockets[0].send).mock.calls[0][0] as string)).not.toHaveProperty( + 'sessionId' + ); + + inbound({ + type: 'response', + id: 'uuid-2', + result: { ok: true }, + }); + await expect(promise).resolves.toEqual({ ok: true }); + client.destroy(); + }); + + it('correlates response by command id even when no sessionId is on the wire', async () => { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + client.connect(); + open(); + + const promise = client.sendCommandToConnection({ + command: 'runtime_status', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + await Promise.resolve(); + + // An unrelated response with a different id should not settle ours. + inbound({ type: 'response', id: 'uuid-other', result: { unrelated: true } }); + await Promise.resolve(); + // Promise is still pending — we can only check via race against a timeout. + let settled = false; + void promise.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + inbound({ + type: 'response', + id: 'uuid-2', + result: { ok: true }, + }); + await expect(promise).resolves.toEqual({ ok: true }); + client.destroy(); + }); + + it('times out a connection-scoped command that never gets a response', async () => { + jest.useFakeTimers(); + try { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + client.connect(); + open(); + + const promise = client.sendCommandToConnection({ + command: 'runtime_status', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + // Wait for the send to dispatch. + await jest.advanceTimersByTimeAsync(0); + + jest.advanceTimersByTime(30_000); + await expect(promise).rejects.toThrow('Command timed out'); + client.destroy(); + } finally { + jest.useRealTimers(); + } + }); + + it('rejects a connection-scoped command on auth-close refresh', async () => { + const getAuthToken = jest.fn().mockReturnValueOnce('old-token').mockResolvedValue('new-token'); + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken }); + const release = client.subscribeToCliSession('ses-1'); + open(); + inbound({ type: 'system', event: 'sessions.list', data: { sessions: [] } }); + + const promise = client.sendCommandToConnection({ + command: 'runtime_status', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + await Promise.resolve(); + sockets[0].onclose?.({ code: 4001 } as CloseEvent); + await Promise.resolve(); + await Promise.resolve(); + + await expect(promise).rejects.toThrow('Connection lost during reconnect'); + release(); + client.destroy(); + }); + + it('returns structured UserWebCommandError for relay errors on a connection-scoped command', async () => { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + client.connect(); + open(); + + const promise = client.sendCommandToConnection({ + command: 'runtime_status', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + await Promise.resolve(); + + inbound({ + type: 'response', + id: 'uuid-2', + error: { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Creating remote sessions from mobile requires a newer Kilo CLI.', + }, + }); + + await expect(promise).rejects.toEqual( + expect.objectContaining({ + name: 'UserWebCommandError', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Creating remote sessions from mobile requires a newer Kilo CLI.', + }) + ); + await expect(promise).rejects.toBeInstanceOf(UserWebCommandError); + client.destroy(); + }); + + it('does not subscribe to a CLI session as a side effect of sendCommandToConnection', async () => { + const client = createUserWebConnection({ websocketUrl: WS_URL, getAuthToken: () => 'token' }); + client.connect(); + open(); + + const promise = client.sendCommandToConnection({ + command: 'runtime_status', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + await Promise.resolve(); + + const sentFrames = sockets[0].send.mock.calls.map(call => JSON.parse(call[0] as string)); + expect(sentFrames).not.toContainEqual(expect.objectContaining({ type: 'subscribe' })); + + inbound({ + type: 'response', + id: 'uuid-2', + result: { ok: true }, + }); + await promise; + client.destroy(); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-sdk/user-web-connection.ts b/apps/web/src/lib/cloud-agent-sdk/user-web-connection.ts index b57d7ebe53..46e3ccf9d0 100644 --- a/apps/web/src/lib/cloud-agent-sdk/user-web-connection.ts +++ b/apps/web/src/lib/cloud-agent-sdk/user-web-connection.ts @@ -44,6 +44,12 @@ type UserWebConnectionConfig = { lifecycleHooks?: ConnectionLifecycleHooks; }; +type SendCommandToConnectionInput = { + command: string; + data: unknown; + expectedConnectionId: string; +}; + type UserWebConnection = { /** New connection owners use this lease; optional on injected legacy clients until they migrate. */ retain?: () => () => void; @@ -59,6 +65,14 @@ type UserWebConnection = { data: unknown, expectedOwnerConnectionId?: string ) => Promise; + /** + * Send a viewer command that is scoped to a specific CLI connection and has + * no associated session (e.g. a connection-scoped runtime probe). The wire + * frame includes `connectionId` and omits `sessionId`. Shares the existing + * correlated command lifecycle: timeout, open/disconnect handling, and + * structured `UserWebCommandError` errors. + */ + sendCommandToConnection: (input: SendCommandToConnectionInput) => Promise; onCliEvent: (sessionId: string, listener: (event: CliEvent) => void) => () => void; onSystemEvent: (listener: (event: SystemEvent) => void) => () => void; onReconnect: (listener: () => void) => () => void; @@ -455,6 +469,76 @@ function createUserWebConnection( retainConnection(); } + type RawCommandWire = { + command: string; + data: unknown; + connectionId?: string; + sessionId?: string; + }; + + /** + * Shared private sender for both `sendCommand` and `sendCommandToConnection`. + * Owns the command-scoped connection retain, the pending-commands map + * entry, and the 30s timeout — so the two public entry points only differ in + * the wire shape (`sessionId` present vs omitted). `connectionId` is always + * serialized; the relay tolerates it without a `sessionId`. + */ + function sendRawCommand(wire: RawCommandWire): Promise { + const hasOwnerLifetime = retainCount > commandRetainCount; + const releaseCommandLifetime = hasOwnerLifetime ? null : retainConnection(); + if (releaseCommandLifetime) commandRetainCount += 1; + let commandLifetimeReleased = false; + const releaseLifetime = () => { + if (commandLifetimeReleased) return; + commandLifetimeReleased = true; + if (releaseCommandLifetime) { + commandRetainCount -= 1; + releaseCommandLifetime(); + } + }; + + return new Promise((resolve, reject) => { + const resolveCommand = (value: unknown) => { + releaseLifetime(); + resolve(value); + }; + const rejectCommand = (reason: Error) => { + releaseLifetime(); + reject(reason); + }; + void waitForOpen().then( + ws => { + if (destroyed || !hasLifetime() || ws.readyState !== WebSocket.OPEN) { + rejectCommand( + new Error(destroyed ? 'Connection destroyed' : 'Connection disconnected') + ); + return; + } + + const id = cloudAgentSdkRuntime.randomUUID(); + const timer = setTimeout(() => { + pendingCommands.delete(id); + rejectCommand(new Error('Command timed out')); + }, COMMAND_TIMEOUT_MS); + pendingCommands.set(id, { resolve: resolveCommand, reject: rejectCommand, timer }); + ws.send( + JSON.stringify({ + type: 'command', + id, + command: wire.command, + ...(wire.sessionId ? { sessionId: wire.sessionId } : {}), + ...(wire.connectionId ? { connectionId: wire.connectionId } : {}), + data: wire.data, + }) + ); + }, + reason => { + rejectCommand(reason instanceof Error ? reason : new Error('WebSocket is not connected')); + } + ); + }); + } + return { retain: retainConnection, connect, @@ -498,60 +582,21 @@ function createUserWebConnection( }; }, sendCommand(sessionId, command, data, expectedOwnerConnectionId) { - const hasOwnerLifetime = retainCount > commandRetainCount; - const releaseCommandLifetime = hasOwnerLifetime ? null : retainConnection(); - if (releaseCommandLifetime) commandRetainCount += 1; - let commandLifetimeReleased = false; - const releaseLifetime = () => { - if (commandLifetimeReleased) return; - commandLifetimeReleased = true; - if (releaseCommandLifetime) { - commandRetainCount -= 1; - releaseCommandLifetime(); - } - }; - - return new Promise((resolve, reject) => { - const resolveCommand = (value: unknown) => { - releaseLifetime(); - resolve(value); - }; - const rejectCommand = (reason: Error) => { - releaseLifetime(); - reject(reason); - }; - void waitForOpen().then( - ws => { - if (destroyed || !hasLifetime() || ws.readyState !== WebSocket.OPEN) { - rejectCommand( - new Error(destroyed ? 'Connection destroyed' : 'Connection disconnected') - ); - return; - } - - const id = cloudAgentSdkRuntime.randomUUID(); - const timer = setTimeout(() => { - pendingCommands.delete(id); - rejectCommand(new Error('Command timed out')); - }, COMMAND_TIMEOUT_MS); - pendingCommands.set(id, { resolve: resolveCommand, reject: rejectCommand, timer }); - ws.send( - JSON.stringify({ - type: 'command', - id, - command, - sessionId, - connectionId: expectedOwnerConnectionId, - data, - }) - ); - }, - reason => { - rejectCommand( - reason instanceof Error ? reason : new Error('WebSocket is not connected') - ); - } - ); + return sendRawCommand({ + command, + // `expectedOwnerConnectionId` undefined still flows through `connectionId` + // as a top-level field on the wire (the relay tolerates a `connectionId` + // without a `sessionId`), so reuse the shared sender. + ...(expectedOwnerConnectionId ? { connectionId: expectedOwnerConnectionId } : {}), + sessionId, + data, + }); + }, + sendCommandToConnection(input) { + return sendRawCommand({ + command: input.command, + connectionId: input.expectedConnectionId, + data: input.data, }); }, onCliEvent(sessionId, listener) { @@ -585,6 +630,7 @@ function createUserWebConnection( export { createUserWebConnection, UserWebCommandError }; export type { + SendCommandToConnectionInput, UserWebConnection, UserWebConnectionConfig, UserWebSessionEventName, diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index 07e2719828..e660d6555d 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -410,7 +410,7 @@ describe('UserConnectionDO', () => { // Verify via command routing: command to s2 should fail (no owner) const webWs2 = addWebSocket(mockCtx, 'web-2'); - sendCommand(doInstance, webWs2, { id: 'cmd-1', command: 'test', sessionId: 's2' }); + sendCommand(doInstance, webWs2, { id: 'cmd-1', command: 'send_message', sessionId: 's2' }); const resp = parseSent(webWs2); expect(resp).toMatchObject({ type: 'response', @@ -925,7 +925,7 @@ describe('UserConnectionDO', () => { // Session no longer routable const web2 = addWebSocket(mockCtx, 'web-2'); - sendCommand(doInstance, web2, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, web2, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); expect(parseSent(web2)).toMatchObject({ type: 'response', error: 'Session owner not found' }); }); @@ -937,7 +937,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); // Send command from web - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); webWs.send.mockClear(); // CLI disconnects @@ -1017,7 +1017,11 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, []); // Send command routed by connectionId (no sessionId) - sendCommand(doInstance, webWs, { id: 'cmd-conn', command: 'test', connectionId: 'cli-1' }); + sendCommand(doInstance, webWs, { + id: 'cmd-conn', + command: 'send_message', + connectionId: 'cli-1', + }); webWs.send.mockClear(); // CLI disconnects before responding @@ -1043,7 +1047,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, []); // Send command with no sessionId or connectionId (fallback routing) - sendCommand(doInstance, webWs, { id: 'cmd-fallback', command: 'test' }); + sendCommand(doInstance, webWs, { id: 'cmd-fallback', command: 'send_message' }); webWs.send.mockClear(); mockCtx.removeSocket(cliWs); @@ -1103,7 +1107,7 @@ describe('UserConnectionDO', () => { webWs.send.mockClear(); // Web sends a command targeting s1 — should route to cli2 (the replacement) - sendCommand(doInstance, webWs, { id: 'cmd-new', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-new', command: 'send_message', sessionId: 's1' }); expect(cli2.send).toHaveBeenCalled(); const correlationId = getCorrelationId(cli2); @@ -1138,7 +1142,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cli1, [makeSession('s1')]); // Web sends a command that gets forwarded to cli1 - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); webWs.send.mockClear(); // CLI2 connects with the same connectionId (reconnect) @@ -1215,7 +1219,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); cliWs.send.mockClear(); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); const correlationId = getCorrelationId(cliWs); mockCtx.removeSocket(webWs); @@ -1265,7 +1269,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); cliWs.send.mockClear(); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); const correlationId = getCorrelationId(cliWs); webWs.send.mockClear(); @@ -1287,7 +1291,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); cliWs.send.mockClear(); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); const correlationId = getCorrelationId(cliWs); webWs.send.mockClear(); @@ -1342,7 +1346,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, targetCli, [makeSession('s1')]); sendHeartbeat(doInstance, otherCli, []); targetCli.send.mockClear(); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); const correlationId = getCorrelationId(targetCli); webWs.send.mockClear(); @@ -1429,7 +1433,7 @@ describe('UserConnectionDO', () => { const webWs = addWebSocket(mockCtx, 'web-1'); sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); ctx.storage.setAlarm.mockClear(); vi.mocked(Date.now).mockReturnValue(now + 20_000); @@ -1447,7 +1451,7 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); cliWs.send.mockClear(); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); const correlationId = getCorrelationId(cliWs); vi.mocked(Date.now).mockReturnValue(now + 34_000); @@ -1480,14 +1484,14 @@ describe('UserConnectionDO', () => { for (let index = 0; index < 128; index++) { sendCommand(doInstance, webWs, { id: `cmd-${index}`, - command: 'test', + command: 'send_message', sessionId: 's1', }); } sendCommand(doInstance, webWs, { id: 'cmd-over-cap', - command: 'test', + command: 'send_message', sessionId: 's1', }); @@ -1595,7 +1599,7 @@ describe('UserConnectionDO', () => { sendCommand(doInstance, webWs, { id: 'cmd-1', - command: 'test', + command: 'send_message', sessionId: 'unknown-session', }); @@ -1652,7 +1656,7 @@ describe('UserConnectionDO', () => { sendCommand(doInstance, webWs, { id: 'cmd-1', - command: 'test', + command: 'send_message', connectionId: 'cli-2', }); @@ -1670,10 +1674,10 @@ describe('UserConnectionDO', () => { cliWs.send.mockClear(); // Both web sockets send commands with the same id - sendCommand(doInstance, web1, { id: 'dup-id', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, web1, { id: 'dup-id', command: 'send_message', sessionId: 's1' }); const corr1 = getCorrelationId(cliWs, 0); - sendCommand(doInstance, web2, { id: 'dup-id', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, web2, { id: 'dup-id', command: 'send_message', sessionId: 's1' }); const corr2 = getCorrelationId(cliWs, 1); expect(corr1).not.toBe(corr2); @@ -1696,11 +1700,883 @@ describe('UserConnectionDO', () => { sendHeartbeat(doInstance, cliWs, []); cliWs.send.mockClear(); - sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'test' }); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'send_message' }); expect(cliWs.send).toHaveBeenCalledTimes(1); }); }); + // ------------------------------------------------------------------------- + // Command allowlist + // ------------------------------------------------------------------------- + + describe('command allowlist', () => { + const ALLOWED = [ + 'send_message', + 'interrupt', + 'question_reply', + 'question_reject', + 'permission_respond', + 'suggestion_accept', + 'suggestion_dismiss', + 'list_models', + 'list_commands', + 'send_command', + 'create_session', + 'exit_cli', + ]; + + it('forwards every allowed viewer command to the owning CLI', () => { + for (const command of ALLOWED) { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command, + sessionId: 's1', + data: command === 'exit_cli' ? { protocolVersion: 1 } : { hello: 'world' }, + }); + + expect(cliWs.send).toHaveBeenCalledTimes(1); + const sent = parseSent(cliWs) as Record; + expect(sent).toMatchObject({ type: 'command', command, sessionId: 's1' }); + expect(typeof sent.id).toBe('string'); + expect(sent.id).not.toBe('cmd-1'); + } + }); + + it('rejects a non-allowlisted command with structured COMMAND_NOT_ALLOWED', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'eval', sessionId: 's1' }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'COMMAND_NOT_ALLOWED', + message: 'Command is not allowed', + }, + }); + expect(cliWs.send).not.toHaveBeenCalled(); + }); + + it('rejects a non-allowlisted command even when targeting a known session owner via connectionId', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'shell', + sessionId: 's1', + connectionId: 'cli-1', + }); + + // No owner-fencing error — allowlist runs first. + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'COMMAND_NOT_ALLOWED', + message: 'Command is not allowed', + }, + }); + expect(cliWs.send).not.toHaveBeenCalled(); + }); + + it('rejects a non-allowlisted command with an unknown session before owner resolution', () => { + const { doInstance, mockCtx } = setup(); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'eval', + sessionId: 'unknown-session', + }); + + // COMMAND_NOT_ALLOWED wins over "Session owner not found". + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'COMMAND_NOT_ALLOWED', + message: 'Command is not allowed', + }, + }); + }); + + it('does not allocate a pending entry or forward a disallowed command', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'eval', sessionId: 's1' }); + const sent = parseSent(webWs) as Record; + expect(sent).toMatchObject({ type: 'response', id: 'cmd-1' }); + expect(sent.id).toBe('cmd-1'); + + // The CLI must not have received any command envelope, and the DO must + // not have allocated a pending slot, so a follow-up CLI response for a + // fabricated correlation id is a no-op. + expect(cliWs.send).not.toHaveBeenCalled(); + sendCliResponse(doInstance, cliWs, { id: 'fabricated', result: 'noop' }); + expect(webWs.send).toHaveBeenCalledTimes(1); + }); + + it('still rejects an owner-fenced allowed command with SESSION_OWNER_CHANGED', () => { + const { doInstance, mockCtx } = setup(); + const currentOwner = addCliSocket(mockCtx, 'cli-1'); + const staleOwner = addCliSocket(mockCtx, 'cli-2'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, currentOwner, [makeSession('s1')]); + sendHeartbeat(doInstance, staleOwner, []); + currentOwner.send.mockClear(); + staleOwner.send.mockClear(); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'send_message', + sessionId: 's1', + connectionId: 'cli-2', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'SESSION_OWNER_CHANGED', + message: 'Session owner changed', + }, + }); + expect(currentOwner.send).not.toHaveBeenCalled(); + expect(staleOwner.send).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // list_commands dedupe and size cap + // ------------------------------------------------------------------------- + + describe('list_commands dedupe and size cap', () => { + it('rejects a duplicate in-flight list_commands request for the same viewer session and owner', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + sendCommand(doInstance, webWs, { + id: 'cmd-2', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-2', + error: { + source: 'relay', + code: 'CATALOG_REQUEST_PENDING', + message: 'Model catalog request already pending', + }, + }); + expect(allSent(cliWs).filter(message => message.type === 'command')).toHaveLength(1); + }); + + it('treats list_models and list_commands as distinct for dedupe purposes', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_models', + sessionId: 's1', + connectionId: 'cli-1', + }); + sendCommand(doInstance, webWs, { + id: 'cmd-2', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + + expect(allSent(cliWs).filter(message => message.type === 'command')).toHaveLength(2); + }); + + it('accepts a list_commands result at exactly 512 KiB', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + const result = createResultWithSerializedBytes(MAX_CATALOG_RESULT_BYTES); + + sendCliResponse(doInstance, cliWs, { id: correlationId, result }); + + expect(parseSent(webWs)).toEqual({ type: 'response', id: 'cmd-1', result }); + }); + + it('rejects a list_commands result one byte over 512 KiB', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + result: createResultWithSerializedBytes(MAX_CATALOG_RESULT_BYTES + 1), + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'CATALOG_TOO_LARGE', + message: 'Model catalog response is too large', + }, + }); + }); + + it('rejects a multibyte list_commands result over 512 KiB', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + result: createUtf8OversizedResult(), + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'CATALOG_TOO_LARGE', + message: 'Model catalog response is too large', + }, + }); + }); + }); + + // ------------------------------------------------------------------------- + // Old CLI upgrade-required mapping + // ------------------------------------------------------------------------- + + describe('old CLI upgrade-required mapping', () => { + it('maps "unknown command: list_commands" to CLI_UPGRADE_REQUIRED with slash message', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: list_commands', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Remote slash commands require a newer Kilo CLI. Update Kilo CLI and reconnect.', + }, + }); + }); + + it('maps "unknown command: send_command" to CLI_UPGRADE_REQUIRED with slash message', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'send_command', + sessionId: 's1', + connectionId: 'cli-1', + data: { command: 'init' }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: send_command', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Remote slash commands require a newer Kilo CLI. Update Kilo CLI and reconnect.', + }, + }); + }); + + it('maps "unknown command: exit_cli" to CLI_UPGRADE_REQUIRED with slash message', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-1', + data: { protocolVersion: 1 }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: exit_cli', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Remote slash commands require a newer Kilo CLI. Update Kilo CLI and reconnect.', + }, + }); + }); + + it('maps "unknown command: create_session" to CLI_UPGRADE_REQUIRED with create_session message', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, []); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'create_session', + connectionId: 'cli-1', + data: { title: 'New session' }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: create_session', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: + 'Creating remote sessions from mobile requires a newer Kilo CLI. Update Kilo CLI and reconnect.', + }, + }); + }); + + it('preserves "unknown command: list_models" because list_models is not in the upgrade-required set', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { id: 'cmd-1', command: 'list_models', sessionId: 's1' }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: list_models', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: 'unknown command: list_models', + }); + }); + + it('preserves an unrelated CLI string error for send_command', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'send_command', + sessionId: 's1', + data: { command: 'init' }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'session not ready', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: 'session not ready', + }); + }); + + it('does not match a longer error that merely starts with "unknown command: list_commands"', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'list_commands', + sessionId: 's1', + connectionId: 'cli-1', + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: list_commands: try again', + }); + + // Exact-match only — do not misclassify longer error strings. + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: 'unknown command: list_commands: try again', + }); + }); + + it('preserves a longer exit_cli unknown-command error', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-1', + data: { protocolVersion: 1 }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + sendCliResponse(doInstance, cliWs, { + id: correlationId, + error: 'unknown command: exit_cli: session not ready', + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: 'unknown command: exit_cli: session not ready', + }); + }); + }); + + // ------------------------------------------------------------------------- + // send_command / create_session negative coverage + // (These operations are not catalog reads, so they must NOT be deduped + // and must NOT be subject to the 512 KiB catalog response cap.) + // ------------------------------------------------------------------------- + + describe('send_command / create_session negative coverage', () => { + it('forwards two in-flight same-owner/same-session send_command requests without deduping', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'send_command', + sessionId: 's1', + connectionId: 'cli-1', + data: { command: 'init' }, + }); + sendCommand(doInstance, webWs, { + id: 'cmd-2', + command: 'send_command', + sessionId: 's1', + connectionId: 'cli-1', + data: { command: 'plan' }, + }); + + const cliCommands = allSent(cliWs).filter(message => message.type === 'command'); + expect(cliCommands).toHaveLength(2); + expect(cliCommands[0]).toMatchObject({ + type: 'command', + command: 'send_command', + sessionId: 's1', + data: { command: 'init' }, + }); + expect(cliCommands[1]).toMatchObject({ + type: 'command', + command: 'send_command', + sessionId: 's1', + data: { command: 'plan' }, + }); + expect(cliCommands[0].id).not.toBe(cliCommands[1].id); + expect(webWs.send).not.toHaveBeenCalled(); + }); + + it('forwards two in-flight create_session requests without deduping', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, []); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'create_session', + connectionId: 'cli-1', + data: { title: 'First session' }, + }); + sendCommand(doInstance, webWs, { + id: 'cmd-2', + command: 'create_session', + connectionId: 'cli-1', + data: { title: 'Second session' }, + }); + + const cliCommands = allSent(cliWs).filter(message => message.type === 'command'); + expect(cliCommands).toHaveLength(2); + expect(cliCommands[0]).toMatchObject({ + type: 'command', + command: 'create_session', + data: { title: 'First session' }, + }); + expect(cliCommands[1]).toMatchObject({ + type: 'command', + command: 'create_session', + data: { title: 'Second session' }, + }); + expect(cliCommands[0].id).not.toBe(cliCommands[1].id); + expect(webWs.send).not.toHaveBeenCalled(); + }); + + it('relays a send_command result over 512 KiB unchanged', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'send_command', + sessionId: 's1', + connectionId: 'cli-1', + data: { command: 'init' }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + const result = createResultWithSerializedBytes(MAX_CATALOG_RESULT_BYTES + 1); + sendCliResponse(doInstance, cliWs, { id: correlationId, result }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + result, + }); + }); + + it('relays a create_session result over 512 KiB unchanged', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, []); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'create_session', + connectionId: 'cli-1', + data: { title: 'Big session' }, + }); + const correlationId = getCorrelationId(cliWs); + webWs.send.mockClear(); + + const result = createResultWithSerializedBytes(MAX_CATALOG_RESULT_BYTES + 1); + sendCliResponse(doInstance, cliWs, { id: correlationId, result }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + result, + }); + }); + }); + + // ------------------------------------------------------------------------- + // exit_cli routing and relay policy + // ------------------------------------------------------------------------- + + describe('exit_cli routing and relay policy', () => { + it.each([ + { label: 'missing sessionId', input: { data: { protocolVersion: 1 } } }, + { label: 'missing data', input: { sessionId: 's1' } }, + { label: 'wrong protocol version', input: { sessionId: 's1', data: { protocolVersion: 2 } } }, + { + label: 'extra data field', + input: { sessionId: 's1', data: { protocolVersion: 1, extra: true } }, + }, + { label: 'null data', input: { sessionId: 's1', data: null } }, + { label: 'array data', input: { sessionId: 's1', data: [{ protocolVersion: 1 }] } }, + { label: 'primitive data', input: { sessionId: 's1', data: 'protocolVersion=1' } }, + ])('rejects $label before routing or pending allocation', ({ input }) => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + ...input, + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'INVALID_COMMAND', + message: 'Invalid command', + }, + }); + expect(cliWs.send).not.toHaveBeenCalled(); + expect(Reflect.get(doInstance, 'pendingCommands')).toEqual(new Map()); + }); + + it('routes exit_cli to the selected session owner with its data unchanged', () => { + const { doInstance, mockCtx } = setup(); + const selectedOwner = addCliSocket(mockCtx, 'cli-1'); + const otherCli = addCliSocket(mockCtx, 'cli-2'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, selectedOwner, [makeSession('s1')]); + sendHeartbeat(doInstance, otherCli, [makeSession('s2')]); + selectedOwner.send.mockClear(); + otherCli.send.mockClear(); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-1', + data: { protocolVersion: 1 }, + }); + + expect(allSent(selectedOwner).filter(message => message.type === 'command')).toEqual([ + expect.objectContaining({ + type: 'command', + command: 'exit_cli', + sessionId: 's1', + data: { protocolVersion: 1 }, + }), + ]); + expect(otherCli.send).not.toHaveBeenCalled(); + }); + + it('rejects exit_cli when the selected owner snapshot is stale', () => { + const { doInstance, mockCtx } = setup(); + const currentOwner = addCliSocket(mockCtx, 'cli-1'); + const staleOwner = addCliSocket(mockCtx, 'cli-2'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, currentOwner, [makeSession('s1')]); + sendHeartbeat(doInstance, staleOwner, []); + currentOwner.send.mockClear(); + staleOwner.send.mockClear(); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-2', + data: { protocolVersion: 1 }, + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: { + source: 'relay', + code: 'SESSION_OWNER_CHANGED', + message: 'Session owner changed', + }, + }); + expect(currentOwner.send).not.toHaveBeenCalled(); + expect(staleOwner.send).not.toHaveBeenCalled(); + }); + + it('rejects exit_cli when the session has no owner', () => { + const { doInstance, mockCtx } = setup(); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + data: { protocolVersion: 1 }, + }); + + expect(parseSent(webWs)).toEqual({ + type: 'response', + id: 'cmd-1', + error: 'Session owner not found', + }); + }); + + it('does not dedupe concurrent exit_cli requests', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-1', + data: { protocolVersion: 1 }, + }); + sendCommand(doInstance, webWs, { + id: 'cmd-2', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-1', + data: { protocolVersion: 1 }, + }); + + const commands = allSent(cliWs).filter(message => message.type === 'command'); + expect(commands).toHaveLength(2); + expect(commands[0].id).not.toBe(commands[1].id); + expect(webWs.send).not.toHaveBeenCalled(); + }); + + it('relays an exit_cli result over 512 KiB unchanged', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + + sendHeartbeat(doInstance, cliWs, [makeSession('s1')]); + cliWs.send.mockClear(); + sendCommand(doInstance, webWs, { + id: 'cmd-1', + command: 'exit_cli', + sessionId: 's1', + connectionId: 'cli-1', + data: { protocolVersion: 1 }, + }); + const correlationId = getCorrelationId(cliWs); + const result = createResultWithSerializedBytes(MAX_CATALOG_RESULT_BYTES + 1); + + sendCliResponse(doInstance, cliWs, { id: correlationId, result }); + + expect(parseSent(webWs)).toEqual({ type: 'response', id: 'cmd-1', result }); + }); + }); + // ------------------------------------------------------------------------- // CLI event forwarding // ------------------------------------------------------------------------- @@ -1943,14 +2819,14 @@ describe('UserConnectionDO', () => { // Verify state was reconstructed by routing a command const web2 = addWebSocket(mockCtx, 'web-2'); - sendCommand(doInstance, web2, { id: 'cmd-1', command: 'test', sessionId: 's1' }); + sendCommand(doInstance, web2, { id: 'cmd-1', command: 'send_message', sessionId: 's1' }); // Should route to cli-1 (not "Session owner not found") const cliWs = mockCtx.sockets.find(s => s._tags.includes('cli')); expect(cliWs?.send).toHaveBeenCalled(); const cliMsgs = allSent(cliWs!); const cmdMsg = cliMsgs.find((m: Record) => m.type === 'command'); - expect(cmdMsg).toMatchObject({ type: 'command', command: 'test' }); + expect(cmdMsg).toMatchObject({ type: 'command', command: 'send_message' }); }); it('reconstructs webSubscriptions from web attachments', () => { diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index b6855f19a7..b132a3ff0e 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -37,6 +37,36 @@ type WSAttachment = export const MAX_CATALOG_RESULT_BYTES = 512 * 1024; +// Viewer command allowlist. Anything outside this set is rejected by the relay +// before owner resolution, pending allocation, or CLI forwarding. +export const ALLOWED_VIEWER_COMMANDS: ReadonlySet = new Set([ + 'send_message', + 'interrupt', + 'question_reply', + 'question_reject', + 'permission_respond', + 'suggestion_accept', + 'suggestion_dismiss', + 'list_models', + 'list_commands', + 'send_command', + 'create_session', + 'exit_cli', +]); + +// In-flight dedupe and 512 KiB response cap apply to these catalog-style reads. +const CATALOG_DEDUPE_COMMANDS: ReadonlySet = new Set(['list_models', 'list_commands']); + +// Operations that older CLIs reject with a precise "unknown command: " +// string. Only these commands get mapped to a structured CLI_UPGRADE_REQUIRED +// response; any other CLI error is preserved verbatim. +const CLI_UPGRADE_REQUIRED_COMMANDS: ReadonlySet = new Set([ + 'list_commands', + 'send_command', + 'create_session', + 'exit_cli', +]); + const SESSION_OWNER_CHANGED_ERROR = { source: 'relay', code: 'SESSION_OWNER_CHANGED', @@ -67,6 +97,31 @@ const COMMAND_EXPIRED_ERROR = { message: 'Command expired', }; +const COMMAND_NOT_ALLOWED_ERROR = { + source: 'relay', + code: 'COMMAND_NOT_ALLOWED', + message: 'Command is not allowed', +}; + +const INVALID_COMMAND_ERROR = { + source: 'relay', + code: 'INVALID_COMMAND', + message: 'Invalid command', +}; + +const CLI_UPGRADE_REQUIRED_SLASH_ERROR = { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: 'Remote slash commands require a newer Kilo CLI. Update Kilo CLI and reconnect.', +}; + +const CLI_UPGRADE_REQUIRED_CREATE_SESSION_ERROR = { + source: 'relay', + code: 'CLI_UPGRADE_REQUIRED', + message: + 'Creating remote sessions from mobile requires a newer Kilo CLI. Update Kilo CLI and reconnect.', +}; + const CLI_COMMAND_ERROR = { source: 'cli', message: 'Command failed', @@ -468,7 +523,7 @@ export class UserConnectionDO extends DurableObject { if (!entry || entry.targetCliWs !== respondingWs) return; this.pendingCommands.delete(id); - if (entry.command === 'list_models' && result !== undefined) { + if (CATALOG_DEDUPE_COMMANDS.has(entry.command) && result !== undefined) { const serializedResult = JSON.stringify(result); const resultBytes = new TextEncoder().encode(serializedResult).byteLength; if (resultBytes > MAX_CATALOG_RESULT_BYTES) { @@ -481,13 +536,39 @@ export class UserConnectionDO extends DurableObject { } } + let structuredError: { source: string; code: string; message: string } | null = null; + let stringError: string | null = null; + let sanitizeAsFailed = false; + + if (typeof error === 'string' && CLI_UPGRADE_REQUIRED_COMMANDS.has(entry.command)) { + if (error === `unknown command: ${entry.command}`) { + structuredError = + entry.command === 'create_session' + ? CLI_UPGRADE_REQUIRED_CREATE_SESSION_ERROR + : CLI_UPGRADE_REQUIRED_SLASH_ERROR; + } else { + stringError = error; + } + } else if (typeof error === 'string') { + stringError = error; + } else if (error !== undefined) { + // Arbitrary non-string CLI error (e.g. a CLI-shaped structured object): + // sanitize to a generic CLI failure so the relay's own error policy + // remains the source of truth. + sanitizeAsFailed = true; + } + this.sendToWeb(entry.ws, { type: 'response', id: entry.originalId, ...(result !== undefined ? { result } : {}), - ...(error !== undefined - ? { error: typeof error === 'string' ? error : CLI_COMMAND_ERROR } - : {}), + ...(structuredError !== null + ? { error: structuredError } + : stringError !== null + ? { error: stringError } + : sanitizeAsFailed + ? { error: CLI_COMMAND_ERROR } + : {}), }); } @@ -598,6 +679,35 @@ export class UserConnectionDO extends DurableObject { const now = Date.now(); this.expirePendingCommands(now); + // Reject anything outside the viewer command allowlist before we touch + // ownership, allocate a pending slot, or forward to the CLI. + if (!ALLOWED_VIEWER_COMMANDS.has(msg.command)) { + this.sendToWeb(ws, { + type: 'response', + id: msg.id, + error: COMMAND_NOT_ALLOWED_ERROR, + }); + return; + } + + if ( + msg.command === 'exit_cli' && + (!msg.sessionId || + typeof msg.data !== 'object' || + msg.data === null || + Array.isArray(msg.data) || + Object.keys(msg.data).length !== 1 || + !Object.hasOwn(msg.data, 'protocolVersion') || + Reflect.get(msg.data, 'protocolVersion') !== 1) + ) { + this.sendToWeb(ws, { + type: 'response', + id: msg.id, + error: INVALID_COMMAND_ERROR, + }); + return; + } + // Find target CLI let targetCli: WebSocket | undefined; @@ -633,11 +743,11 @@ export class UserConnectionDO extends DurableObject { const targetConnectionId = targetAttachment.connectionId; if ( - msg.command === 'list_models' && + CATALOG_DEDUPE_COMMANDS.has(msg.command) && [...this.pendingCommands.values()].some( entry => entry.ws === ws && - entry.command === 'list_models' && + entry.command === msg.command && entry.sessionId === msg.sessionId && entry.targetConnectionId === targetConnectionId )