Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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,239 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { announcingToast } from '@/lib/a11y/announcing-toast';

import { exitRemoteSessionFromList } from './exit-remote-session-from-list';

vi.mock('@/lib/a11y/announcing-toast', () => ({
announcingToast: { success: vi.fn(), error: vi.fn() },
}));

type RetryAction = { label: string; onClick: () => void };

function createHarness() {
const confirm = vi.fn(async () => {
await Promise.resolve();
return true;
});
const sendExit = vi.fn(async () => {
await Promise.resolve();
});
const refreshActiveList = vi.fn(async () => {
await Promise.resolve();
});
const inFlight = { current: false };
return { confirm, sendExit, refreshActiveList, inFlight };
}

function captureRetryAction(): RetryAction | undefined {
const call = vi.mocked(announcingToast.error).mock.calls[0];
const options = call?.[1] as { action?: RetryAction } | undefined;
return options?.action;
}

describe('exitRemoteSessionFromList', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('cancels without sending, refreshing, or toasting', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
confirm.mockResolvedValue(false);

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(confirm).toHaveBeenCalledTimes(1);
expect(sendExit).not.toHaveBeenCalled();
expect(refreshActiveList).not.toHaveBeenCalled();
expect(announcingToast.success).not.toHaveBeenCalled();
expect(announcingToast.error).not.toHaveBeenCalled();
expect(inFlight.current).toBe(false);
});

it('sends, toasts success, refreshes, and releases the flag', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(confirm).toHaveBeenCalledTimes(1);
expect(sendExit).toHaveBeenCalledTimes(1);
expect(announcingToast.success).toHaveBeenCalledWith('Session exited');
expect(announcingToast.error).not.toHaveBeenCalled();
expect(refreshActiveList).toHaveBeenCalledTimes(1);
expect(inFlight.current).toBe(false);
});

it('swallows a refresh failure after a successful send without resending', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
refreshActiveList.mockRejectedValue(new Error('network down'));

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(sendExit).toHaveBeenCalledTimes(1);
expect(announcingToast.success).toHaveBeenCalledTimes(1);
expect(announcingToast.success).toHaveBeenCalledWith('Session exited');
expect(announcingToast.error).not.toHaveBeenCalled();
expect(inFlight.current).toBe(false);
});

it('shows a Try again toast on a retryable send failure without refreshing', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
sendExit.mockRejectedValue(new Error('Invalid exit_cli response'));

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(sendExit).toHaveBeenCalledTimes(1);
expect(announcingToast.error).toHaveBeenCalledWith('Invalid exit_cli response', {
action: { label: 'Try again', onClick: expect.any(Function) },
});
expect(announcingToast.success).not.toHaveBeenCalled();
expect(refreshActiveList).not.toHaveBeenCalled();
expect(inFlight.current).toBe(false);
});

it('resends from Try again without a second confirm and holds the flag in flight', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
const retryResolveRef = { resolve: undefined as (() => void) | undefined };
sendExit.mockImplementation(async () => {
await Promise.resolve();
if (sendExit.mock.calls.length === 1) {
throw new Error('connection reset');
}
await new Promise<void>(resolve => {
retryResolveRef.resolve = resolve;
});
});

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

const action = captureRetryAction();
if (!action) {
throw new Error('Expected retry action on the toast');
}

action.onClick();
await vi.waitFor(() => {
expect(sendExit).toHaveBeenCalledTimes(2);
});
expect(confirm).toHaveBeenCalledTimes(1);
expect(inFlight.current).toBe(true);

retryResolveRef.resolve?.();
await vi.waitFor(() => {
expect(inFlight.current).toBe(false);
});
expect(announcingToast.success).toHaveBeenCalledTimes(1);
});

it('ignores a second Try again tap while the retry send is in flight', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
const retryResolveRef = { resolve: undefined as (() => void) | undefined };
sendExit.mockImplementation(async () => {
await Promise.resolve();
if (sendExit.mock.calls.length === 1) {
throw new Error('connection reset');
}
await new Promise<void>(resolve => {
retryResolveRef.resolve = resolve;
});
});

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

const action = captureRetryAction();
if (!action) {
throw new Error('Expected retry action on the toast');
}

action.onClick();
await vi.waitFor(() => {
expect(sendExit).toHaveBeenCalledTimes(2);
});
expect(inFlight.current).toBe(true);

action.onClick();
expect(sendExit).toHaveBeenCalledTimes(2);
expect(inFlight.current).toBe(true);

retryResolveRef.resolve?.();
await vi.waitFor(() => {
expect(inFlight.current).toBe(false);
});
expect(sendExit).toHaveBeenCalledTimes(2);
expect(announcingToast.success).toHaveBeenCalledTimes(1);
});

it('shows a non-retryable message with no action and does not resend', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
sendExit.mockRejectedValue(
new Error('Remote session exit is not supported for the current session')
);

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(sendExit).toHaveBeenCalledTimes(1);
expect(announcingToast.error).toHaveBeenCalledWith(
'Remote session exit is not supported for the current session'
);
const options = vi.mocked(announcingToast.error).mock.calls[0]?.[1] as
| { action?: RetryAction }
| undefined;
expect(options?.action).toBeUndefined();
expect(announcingToast.success).not.toHaveBeenCalled();
expect(refreshActiveList).not.toHaveBeenCalled();
expect(inFlight.current).toBe(false);
});

it('falls back to Failed to exit session for a non-Error throw', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
class NotAnError {
message = 'opaque';
}
sendExit.mockImplementation(async () => {
await Promise.resolve();
// oxlint-disable-next-line typescript-eslint/only-throw-error
throw new NotAnError();
});

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(sendExit).toHaveBeenCalledTimes(1);
expect(announcingToast.error).toHaveBeenCalledWith('Failed to exit session', {
action: { label: 'Try again', onClick: expect.any(Function) },
});
});

it('returns without confirming when already in flight', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
inFlight.current = true;

await exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });

expect(confirm).not.toHaveBeenCalled();
expect(sendExit).not.toHaveBeenCalled();
expect(refreshActiveList).not.toHaveBeenCalled();
expect(announcingToast.success).not.toHaveBeenCalled();
expect(announcingToast.error).not.toHaveBeenCalled();
});

it('releases the flag after a pending send settles', async () => {
const { confirm, sendExit, refreshActiveList, inFlight } = createHarness();
const sendResolveRef = { resolve: undefined as (() => void) | undefined };
sendExit.mockImplementation(async () => {
await new Promise<void>(resolve => {
sendResolveRef.resolve = resolve;
});
});

const pending = exitRemoteSessionFromList({ confirm, sendExit, refreshActiveList, inFlight });
await vi.waitFor(() => {
expect(sendExit).toHaveBeenCalledTimes(1);
});
expect(inFlight.current).toBe(true);

sendResolveRef.resolve?.();
await pending;
expect(inFlight.current).toBe(false);
expect(announcingToast.success).toHaveBeenCalledTimes(1);
});
});
98 changes: 98 additions & 0 deletions apps/mobile/src/components/agents/exit-remote-session-from-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { announcingToast } from '@/lib/a11y/announcing-toast';

import { confirmRemoteSessionExit } from './remote-session-exit-confirmation';

const SESSION_EXITED_MESSAGE = 'Session exited';
/**
* Classifier literals copied from `exit-remote-session-with-feedback.ts`. They
* must stay in sync with that file (which pins them to the SDK source). The
* barrel import is not used here because the mobile test runner cannot resolve
* the SDK's transitive web-only `@/...` aliases.
*/
const REMOTE_SESSION_EXIT_NOT_SUPPORTED_MESSAGE =
'Remote session exit is not supported for the current session';
const REMOTE_SESSION_EXIT_UNAVAILABLE_MESSAGE =
'Remote session exit is unavailable for the current session';
const REMOTE_SESSION_EXIT_UPGRADE_PREFIX = 'Remote slash commands require a newer Kilo CLI';
const RETRY_TOAST_LABEL = 'Try again';
const FALLBACK_ERROR_MESSAGE = 'Failed to exit session';

const NON_RETRYABLE_EXIT_MESSAGES: ReadonlySet<string> = new Set([
REMOTE_SESSION_EXIT_NOT_SUPPORTED_MESSAGE,
REMOTE_SESSION_EXIT_UNAVAILABLE_MESSAGE,
]);

function isNonRetryableExitError(message: string): boolean {
if (NON_RETRYABLE_EXIT_MESSAGES.has(message)) {
return true;
}
return message.startsWith(REMOTE_SESSION_EXIT_UPGRADE_PREFIX);
}

type ExitRemoteSessionFromListInput = {
confirm: () => Promise<boolean>;
sendExit: () => Promise<void>;
refreshActiveList: () => Promise<void>;
inFlight: { current: boolean };
};

/**
* Exit a running session from the Active now list. Keeps history and never
* opens the session. The row passes `showRemoteSessionExitConfirmation` as
* `confirm`; this helper wraps `confirmRemoteSessionExit` once and owns the
* send/refresh/toast lifecycle. `inFlight` is a shared ref flag that blocks
* a second exit while one is in flight.
*/
export async function exitRemoteSessionFromList({
confirm,
sendExit,
refreshActiveList,
inFlight,
}: Readonly<ExitRemoteSessionFromListInput>): Promise<void> {
if (inFlight.current) {
return;
}

const runSend = async (): Promise<void> => {
if (inFlight.current) {
return;
}
inFlight.current = true;
try {
try {
await sendExit();
} catch (error) {
const message = error instanceof Error ? error.message : FALLBACK_ERROR_MESSAGE;
if (isNonRetryableExitError(message)) {
// Fail-closed: the SDK signalled "do not send". No CTA so the user
// sees the copy but cannot trigger another attempt.
announcingToast.error(message);
} else {
// Retryable transport / ACK failure. The retry action re-runs the
// send without a second confirm.
announcingToast.error(message, {
action: {
label: RETRY_TOAST_LABEL,
onClick: () => {
void runSend();
},
},
});
}
return;
}

announcingToast.success(SESSION_EXITED_MESSAGE);
try {
await refreshActiveList();
} catch {
// Swallow the refresh failure: the row already left the live set via
// the send. Do not resend `exit_cli`; the user can pull to refresh.
}
} finally {
inFlight.current = false;
}
};

await confirmRemoteSessionExit(confirm, runSend);
}
Loading