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,186 @@
import { describe, expect, it } from 'vitest';

import { type SlashCommandInfo } from '@kilocode/cloud-agent-sdk';
import { type RemoteCommandState } from '@kilocode/cloud-agent-sdk/remote-command-catalog';

import {
isGoalCommandDraft,
parseChatComposerSubmission,
} from '@/components/agents/chat-composer-slash-commands';

const COMPACT: SlashCommandInfo = { name: 'compact', description: 'Compact', hints: [] };
const GOAL: SlashCommandInfo = { name: 'goal', description: 'Goal', hints: [] };
const WITHOUT_GOAL: SlashCommandInfo[] = [COMPACT];
const WITH_GOAL: SlashCommandInfo[] = [COMPACT, GOAL];

function remoteState(overrides: Partial<RemoteCommandState> = {}): RemoteCommandState {
return {
ownerConnectionId: 'conn-1',
refresh: 'idle',
commands: WITH_GOAL,
...overrides,
};
}

describe('parseChatComposerSubmission — /goal compose mode', () => {
it('routes a bare /goal to goal-compose', () => {
expect(
parseChatComposerSubmission('/goal', WITH_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState(),
})
).toEqual({ type: 'goal-compose' });
});

it('routes /goal with only trailing whitespace to goal-compose', () => {
expect(
parseChatComposerSubmission('/goal ', WITH_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState(),
})
).toEqual({ type: 'goal-compose' });
});

it('forwards /goal <objective> as the goal command', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITH_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState(),
})
).toEqual({ type: 'command', command: 'goal', arguments: 'Ship it' });
});

it.each(['pause', 'resume', 'clear'])('forwards /goal %s as the goal command', argument => {
expect(
parseChatComposerSubmission(`/goal ${argument}`, WITH_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState(),
})
).toEqual({ type: 'command', command: 'goal', arguments: argument });
});

it('forwards /goal for a cloud-agent session whose catalog advertises it', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITH_GOAL, {
hasAttachments: false,
sessionType: 'cloud-agent',
remoteCommandState: null,
})
).toEqual({ type: 'command', command: 'goal', arguments: 'Ship it' });
});

it('rejects a goal command with attachments', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITH_GOAL, {
hasAttachments: true,
sessionType: 'remote',
remoteCommandState: remoteState(),
})
).toEqual({ type: 'attachment-error' });
});

it('rejects a bare /goal with attachments before entering compose mode', () => {
expect(
parseChatComposerSubmission('/goal', WITH_GOAL, {
hasAttachments: true,
sessionType: 'remote',
remoteCommandState: remoteState(),
})
).toEqual({ type: 'attachment-error' });
});
});

describe('isGoalCommandDraft', () => {
it.each(['/goal', '/goal ', '/goal Ship it', '/goal Ship it'])(
'keeps compose mode for the goal draft %j',
draft => {
expect(isGoalCommandDraft(draft)).toBe(true);
}
);

it.each(['', '/go', '/goalx', 'hello', '/ goals'])(
'drops compose mode for the non-goal draft %j',
draft => {
expect(isGoalCommandDraft(draft)).toBe(false);
}
);
});

describe('parseChatComposerSubmission — /goal fail-closed', () => {
it('returns upgrade-required (never a prompt) for a remote catalog without goal', () => {
const result = parseChatComposerSubmission('/goal Ship it', WITHOUT_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState({ commands: WITHOUT_GOAL }),
});
expect(result).toEqual({
type: 'upgrade-required',
message: 'Please upgrade your CLI to use this command.',
});
});

it('returns upgrade-required (never a prompt) for a cloud-agent catalog without goal', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITHOUT_GOAL, {
hasAttachments: false,
sessionType: 'cloud-agent',
remoteCommandState: null,
})
).toEqual({
type: 'upgrade-required',
message: 'Please upgrade your CLI to use this command.',
});
});

it('returns upgrade-required for a bare /goal when the remote catalog lacks goal', () => {
expect(
parseChatComposerSubmission('/goal', WITHOUT_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState({ commands: WITHOUT_GOAL }),
})
).toEqual({
type: 'upgrade-required',
message: 'Please upgrade your CLI to use this command.',
});
});

it('returns upgrade-required for a remote session requiring an upgrade', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITH_GOAL, {
hasAttachments: false,
sessionType: 'remote',
remoteCommandState: remoteState({
refresh: 'upgrade-required',
message: 'Please upgrade your CLI',
}),
})
).toEqual({ type: 'upgrade-required', message: 'Please upgrade your CLI' });
});
});

describe('parseChatComposerSubmission — non-interactive sessions keep /goal as a prompt', () => {
it('keeps /goal as a prompt for a read-only session', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITHOUT_GOAL, {
hasAttachments: false,
sessionType: 'read-only',
remoteCommandState: null,
})
).toEqual({ type: 'prompt', prompt: '/goal Ship it' });
});

it('keeps /goal as a prompt for an unresolved session', () => {
expect(
parseChatComposerSubmission('/goal Ship it', WITHOUT_GOAL, {
hasAttachments: false,
sessionType: null,
remoteCommandState: null,
})
).toEqual({ type: 'prompt', prompt: '/goal Ship it' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

const COMPACT: SlashCommandInfo = { name: 'compact', description: 'Compact', hints: [] };
const REVIEW: SlashCommandInfo = { name: 'review', description: 'Review', hints: [] };
const GOAL: SlashCommandInfo = { name: 'goal', description: 'Goal', hints: [] };
const SAMPLE_COMMANDS: SlashCommandInfo[] = [COMPACT, REVIEW];

function remoteState(overrides: Partial<RemoteCommandState> = {}): RemoteCommandState {
Expand Down Expand Up @@ -77,6 +78,11 @@ describe('createMobileSlashCommandList', () => {
expect(list).toBe(SAMPLE_COMMANDS);
});

it('does not strip a CLI-reported /goal from a remote catalog', () => {
const list = createMobileSlashCommandList('remote', [GOAL], remoteState({ commands: [GOAL] }));
expect(list.map(command => command.name)).toEqual(['goal', 'new']);
});

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([]);
Expand Down
43 changes: 43 additions & 0 deletions apps/mobile/src/components/agents/chat-composer-slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function getLocalClearSlashCommand(): SlashCommandInfo {
const NEW_COMMAND_NAME = 'new';
const EXIT_COMMAND_NAME = 'exit';
const CLEAR_COMMAND_NAME = 'clear';
const GOAL_COMMAND_NAME = 'goal';
const LOCAL_COMMAND_NAMES = new Set([
NEW_COMMAND_NAME,
EXIT_COMMAND_NAME,
Expand All @@ -63,12 +64,17 @@ const SLASH_FULL_PATTERN = /^\/([\w.-]+)(?:\s+([\s\S]*))?$/;
* `/clear` is intentionally gated — it needs both create_session and
* exit_cli and must surface the upgrade message instead of falling through
* as a prompt.
*
* `/goal ...` is included so that on a remote CLI that reports
* `refresh: 'upgrade-required'` the mobile composer returns the upgrade
* message instead of forwarding goal text as an ordinary prompt.
*/
const RESERVED_UPGRADE_REQUIRED_COMMANDS = new Set([
'compact',
NEW_COMMAND_NAME,
EXIT_COMMAND_NAME,
CLEAR_COMMAND_NAME,
GOAL_COMMAND_NAME,
]);

type ChatComposerParseContext = {
Expand All @@ -83,6 +89,7 @@ export type ChatComposerParseResult =
| { type: 'create-session' }
| { type: 'exit-session' }
| { type: 'restart-session' }
| { type: 'goal-compose' }
| { type: 'attachment-error' }
| { type: 'argument-error'; message: string }
| { type: 'upgrade-required'; message: string };
Expand Down Expand Up @@ -134,6 +141,16 @@ export function getSlashCommandCandidate(input: string): string | null {
return SLASH_PREFIX_PATTERN.test(input) ? input : null;
}

/**
* True while `input` is still the `/goal` compose draft (bare `/goal` or
* `/goal <objective>`). The composer uses this to keep its goal compose mode
* alive as the user types the objective and to drop it the moment the draft is
* no longer about the goal command.
*/
export function isGoalCommandDraft(input: string): boolean {
return /^\/goal(?:\s|$)/.test(input);
}

/**
* Return the catalog entries whose name starts with the prefix in `input`.
* Returns `[]` for anything that is not still a slash-name candidate.
Expand Down Expand Up @@ -269,6 +286,32 @@ export function parseChatComposerSubmission(
return { type: 'restart-session' };
}

if (
commandName === GOAL_COMMAND_NAME &&
(context.sessionType === 'remote' || context.sessionType === 'cloud-agent')
) {
// /goal is only supported when the session's catalog advertises it. Fail
// closed on a session that does not, so goal text is never sent as
// ordinary chat; the existing fail-closed copy is reused so no new i18n
// key is introduced. `null` and `read-only` sessions are excluded above
// and keep their existing prompt behavior.
if (!findCommand(commands, GOAL_COMMAND_NAME)) {
return {
type: 'upgrade-required',
message: i18n.t('agentChat.slashCommands.upgradeRequiredFallback'),
};
}
if (context.hasAttachments) {
return { type: 'attachment-error' };
}
if (argumentsText === '') {
// Bare `/goal` enters compose mode; selecting `/goal` from the
// suggestion list inserts `/goal ` and lands here.
return { type: 'goal-compose' };
}
return { type: 'command', command: GOAL_COMMAND_NAME, arguments: argumentsText };
}

if (commandName && findCommand(commands, commandName)) {
if (context.hasAttachments) {
return { type: 'attachment-error' };
Expand Down
Loading