Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf3acaf
feat(session-ingest): route remote CLI controls
iscekic Jul 14, 2026
b9fc19b
feat(cloud-agent-sdk): support remote slash commands
iscekic Jul 14, 2026
bfa1aa9
feat(cloud-agent-sdk): create remote CLI sessions
iscekic Jul 14, 2026
9640558
fix(web): select commands by session type
iscekic Jul 14, 2026
8b325ad
feat(mobile): run remote CLI slash commands
iscekic Jul 14, 2026
408c40d
feat(mobile): open newly created CLI sessions
iscekic Jul 14, 2026
84fcb28
fix(cloud-agent-sdk): scope remote session creation
iscekic Jul 15, 2026
4d916a8
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 15, 2026
717716b
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 15, 2026
e59e881
fix(cloud-agent-sdk): accept omitted command hints
iscekic Jul 15, 2026
a85b238
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 15, 2026
6e61320
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 15, 2026
a5d97fd
chore: trigger latest-head review
iscekic Jul 15, 2026
49dc920
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 15, 2026
8ec6d16
chore: trigger latest-head review
iscekic Jul 15, 2026
a80b1d0
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 15, 2026
ba335db
chore: trigger latest-head review
iscekic Jul 15, 2026
04dbe5d
feat(mobile): support remote CLI exit
iscekic Jul 16, 2026
4f87a67
Merge remote-tracking branch 'origin/main' into feature/mobile-remote…
iscekic Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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> = {}): 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' });
}
);
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading