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
99 changes: 99 additions & 0 deletions src/__tests__/unit/core/slash-command-modules.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { describe, expect, it, vi } from 'vitest';
import { createSlashCommandRegistry } from '../../../core/commands/slash/registry.js';
import { createCoreSlashCommandModules } from '../../../core/commands/slash/modules/core-command-modules.js';
import { resolveSessionReference } from '../../../core/commands/slash/modules/session/session-commands.js';
import type { ChatSession } from '../../../core/chat/types.js';
import type { SlashCommandExecutionContext } from '../../../core/commands/slash/modules/context.js';

function testSession(overrides: Partial<ChatSession> & Pick<ChatSession, 'id' | 'name'>): ChatSession {
return {
history: [],
messages: [],
turns: [],
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
...overrides,
};
}

function createContext(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCommandExecutionContext {
let activeModel = 'gpt-5.4';
let driftEnabled = false;
const sessions = [
testSession({ id: 'session-a', name: 'Alpha' }),
testSession({ id: 'session-b', name: 'Beta', updatedAt: '2024-01-02' }),
];

return {
model: {
Expand All @@ -29,6 +46,17 @@ function createContext(overrides: Partial<SlashCommandExecutionContext> = {}): S
driftEnabled = enabled;
},
},
session: {
all: () => sessions,
recent: () => [...sessions].reverse(),
recentListMessage: () => ['1. Beta (session-b)', '2. Alpha (session-a)'],
create: (name) => testSession({ id: 'session-new', name: name ?? 'New session' }),
switch: vi.fn(),
rename: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
summarize: (session) => `Summary for ${session.id}`,
},
...overrides,
};
}
Expand Down Expand Up @@ -101,6 +129,77 @@ describe('core slash command modules', () => {
{ command: '/auth login openai', description: 'sign in with OpenAI ChatGPT/Codex OAuth' },
{ command: '/compact', description: 'compact earlier session history for the next run' },
{ command: '/drift', description: 'show CyberLoop semantic drift detection status' },
{ command: '/session switch <id>', description: 'switch to another session' },
]));
});

it('routes session commands through host ports', async () => {
const clear = vi.fn();
const switchSession = vi.fn();
const rename = vi.fn();
const remove = vi.fn();
const context = createContext({
session: {
...createContext().session,
clear,
switch: switchSession,
rename,
remove,
},
});

await expect(registry.run(context, '/clear')).resolves.toMatchObject({
kind: 'message',
message: 'Cleared the current chat transcript.',
});
expect(clear).toHaveBeenCalledTimes(1);

await expect(registry.run(context, '/session switch session-a')).resolves.toMatchObject({
kind: 'message',
sessionId: 'session-a',
message: 'Switched to session-a (Alpha).\nSummary for session-a',
});
expect(switchSession).toHaveBeenCalledWith('session-a');

await expect(registry.run(context, '/session continue 1')).resolves.toMatchObject({
kind: 'continue',
sessionId: 'session-b',
message: 'Switched to session-b (Beta).\nContinuing from that session transcript.',
});

await expect(registry.run(context, '/session rename Focus')).resolves.toMatchObject({
kind: 'message',
message: 'Renamed current session to Focus.',
});
expect(rename).toHaveBeenCalledWith('Focus');

await expect(registry.run(context, '/session close 2')).resolves.toMatchObject({
kind: 'message',
message: 'Closed session-a (Alpha).',
});
expect(remove).toHaveBeenCalledWith('session-a');
});

it('leaves required-argument session commands unmatched without an argument', () => {
expect(registry.find('/session switch')).toBeUndefined();
expect(registry.find('/session continue')).toBeUndefined();
expect(registry.find('/session rename')).toBeUndefined();
expect(registry.find('/session close')).toBeUndefined();
expect(registry.find('/session new')?.command.id).toBe('session.new');
});

it('resolves session references by exact id before recent-session index', () => {
const sessions = [
testSession({ id: '1', name: 'Literal One' }),
testSession({ id: 'session-b', name: 'Beta' }),
];
const recentSessions = [
testSession({ id: 'recent-1', name: 'Recent One' }),
sessions[1]!,
];

expect(resolveSessionReference({ sessions, recentSessions, value: '1' })?.id).toBe('1');
expect(resolveSessionReference({ sessions, recentSessions, value: '2' })?.id).toBe('session-b');
expect(resolveSessionReference({ sessions, recentSessions, value: 'missing' })).toBeUndefined();
});
});
12 changes: 12 additions & 0 deletions src/cli/chat/adapters/slash-command-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatAuthStatusMessage, loginProviderWithOAuth, logoutProvider } from '../../auth.js';
import { summarizeSession } from '../state/storage.js';
import type { SlashCommandExecutionContext } from '../../../core/commands/slash/modules/context.js';
import type { LocalCommandArgs } from '../state/local-commands.js';

Expand All @@ -25,5 +26,16 @@ export function createTuiSlashCommandContext(args: LocalCommandArgs): SlashComma
status: () => ({ enabled: args.driftEnabled, error: args.driftError }),
setEnabled: args.setDriftEnabled,
},
session: {
all: () => args.sessions,
recent: () => args.recentSessions,
recentListMessage: () => args.listRecentSessionsMessage,
create: args.createSession,
switch: args.switchSession,
rename: args.renameSession,
remove: args.removeSession,
clear: args.clearConversation,
summarize: summarizeSession,
},
};
}
90 changes: 0 additions & 90 deletions src/cli/chat/state/local-commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { ChatSession, LocalCommandResult } from './types.js';
import { summarizeSession } from './storage.js';
import type { OpenAiOAuthCredential } from '../../../core/auth/openai-oauth.js';
import type { ProviderCredentialSource } from '../utils/runtime.js';
import { createFileHeartbeatTaskStore } from '../../../core/runtime/heartbeat-task-store.js';
Expand Down Expand Up @@ -44,21 +43,12 @@ type PrefixCommandHandler = (args: LocalCommandArgs, value: string) => Promise<L
const CORE_COMMAND_REGISTRY = createSlashCommandRegistry(createCoreSlashCommandModules());
const LOCAL_COMMAND_HINTS: LocalCommandHint[] = [
{ command: '/help', description: 'show available local commands' },
{ command: '/continue', description: 'resume from the current transcript' },
{ command: '/clear', description: 'reset the current session transcript' },
{ command: '/debug tui-snapshot', description: 'save the latest rendered TUI frame for inspection' },
{ command: '/heartbeat tasks', description: 'list heartbeat tasks' },
{ command: '/heartbeat task <id>', description: 'show one heartbeat task' },
{ command: '/heartbeat runs [task]', description: 'list recent heartbeat runs' },
{ command: '/heartbeat run <task> [run-id|latest]', description: 'show one heartbeat run' },
{ command: '/heartbeat continue <task> [run-id|latest]', description: 'continue in chat from a heartbeat run summary' },
{ command: '/session list', description: 'list local chat sessions' },
{ command: '/session choose [query]', description: 'pick a recent session with filtering' },
{ command: '/session new [name]', description: 'create and switch to a new session' },
{ command: '/session switch <id>', description: 'switch to another session' },
{ command: '/session continue <id>', description: 'switch to a session and resume it' },
{ command: '/session rename <name>', description: 'rename the current session' },
{ command: '/session close <id>', description: 'remove a saved session' },
{ command: '!<command>', description: 'run a shell command directly in chat using the current policy' },
];
const HELP_HINTS: LocalCommandHint[] = [
Expand All @@ -81,33 +71,19 @@ const HELP_MESSAGE = [

const EXACT_COMMANDS = new Map<string, ExactCommandHandler>([
['/help', () => messageResult(HELP_MESSAGE)],
['/clear', (args) => {
args.clearConversation();
return messageResult('Cleared the current chat transcript.');
}],
['/debug tui-snapshot', async (args) =>
messageResult(
args.saveTuiSnapshot ? await args.saveTuiSnapshot() : 'TUI snapshots are not available in this runtime.',
)],
['/heartbeat tasks', (args) => listHeartbeatTasksMessage(args)],
['/heartbeat runs', (args) => listHeartbeatRunsMessage(args, '')],
['/continue', () => ({ handled: true, kind: 'continue' })],
['/session list', (args) =>
messageResult(args.sessions.length > 0 ? args.listRecentSessionsMessage.join('\n') : 'No sessions available.'),
],
['/session choose', () => messageResult('Use /session choose <query> to filter recent sessions, then use arrows and Enter to choose one.')],
]);

const PREFIX_COMMANDS: Array<{ prefix: string; handle: PrefixCommandHandler }> = [
{ prefix: '/heartbeat task ', handle: handleHeartbeatTask },
{ prefix: '/heartbeat runs ', handle: handleHeartbeatRuns },
{ prefix: '/heartbeat run ', handle: handleHeartbeatRun },
{ prefix: '/heartbeat continue ', handle: handleHeartbeatContinue },
{ prefix: '/session new', handle: handleSessionNew },
{ prefix: '/session switch ', handle: handleSessionSwitch },
{ prefix: '/session continue ', handle: handleSessionContinue },
{ prefix: '/session rename ', handle: handleSessionRename },
{ prefix: '/session close ', handle: handleSessionClose },
];

export function isLikelyLocalCommand(prompt: string): boolean {
Expand Down Expand Up @@ -273,11 +249,6 @@ async function handleHeartbeatContinue(args: LocalCommandArgs, value: string): P
};
}

function handleSessionNew(args: LocalCommandArgs, value: string): LocalCommandResult {
const session = args.createSession(value || undefined);
return messageResult(`Created and switched to ${session.id} (${session.name}).`, session.id);
}

async function listHeartbeatTasksMessage(args: LocalCommandArgs): Promise<LocalCommandResult> {
const tasks = await heartbeatStore(args).listTasks();
if (!tasks.length) {
Expand Down Expand Up @@ -408,67 +379,6 @@ function formatInterval(intervalMs: number): string {
return `${intervalMs}ms`;
}

function handleSessionSwitch(args: LocalCommandArgs, value: string): LocalCommandResult {
const session = resolveSessionReference(args, value);
if (!session) {
return messageResult(`Unknown session: ${value}. Use /session list to inspect available sessions.`);
}

args.switchSession(session.id);
return messageResult(`Switched to ${session.id} (${session.name}).\n${summarizeSession(session)}`, session.id);
}

function handleSessionContinue(args: LocalCommandArgs, value: string): LocalCommandResult {
const session = resolveSessionReference(args, value);
if (!session) {
return messageResult(`Unknown session: ${value}.\nUse /session list to inspect available sessions.`);
}

return {
handled: true,
kind: 'continue',
sessionId: session.id,
message: `Switched to ${session.id} (${session.name}).\nContinuing from that session transcript.`,
};
}

function handleSessionRename(args: LocalCommandArgs, value: string): LocalCommandResult {
if (!value) {
return messageResult('Usage: /session rename <name>');
}

args.renameSession(value);
return messageResult(`Renamed current session to ${value}.`);
}

function handleSessionClose(args: LocalCommandArgs, value: string): LocalCommandResult {
const session = resolveSessionReference(args, value);
if (!session) {
return messageResult(`Unknown session: ${value}.\nUse /session list to inspect available sessions.`);
}

args.removeSession(session.id);
return messageResult(`Closed ${session.id} (${session.name}).`);
}

function findSession(args: LocalCommandArgs, id: string): ChatSession | undefined {
return args.sessions.find((candidate) => candidate.id === id);
}

function resolveSessionReference(args: LocalCommandArgs, value: string): ChatSession | undefined {
const directMatch = findSession(args, value);
if (directMatch) {
return directMatch;
}

const numericIndex = Number.parseInt(value, 10);
if (!Number.isFinite(numericIndex) || numericIndex <= 0) {
return undefined;
}

return args.recentSessions[numericIndex - 1];
}

function messageResult(message: string, sessionId?: string): LocalCommandResult {
return {
handled: true,
Expand Down
13 changes: 12 additions & 1 deletion src/core/commands/slash/modules/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LocalCommandResult } from '../../../chat/types.js';
import type { ChatSession, LocalCommandResult } from '../../../chat/types.js';
import type { LlmProvider } from '../../../llm/types.js';
import type { ProviderCredentialSource } from '../../../runtime/api-keys.js';

Expand All @@ -20,6 +20,17 @@ export type SlashCommandExecutionContext = {
status: () => { enabled: boolean; error?: string };
setEnabled: (enabled: boolean) => void;
};
session: {
all: () => ChatSession[];
recent: () => ChatSession[];
recentListMessage: () => string[];
create: (name?: string) => ChatSession;
switch: (id: string) => void;
rename: (name: string) => void;
remove: (id: string) => void;
clear: () => void;
summarize: (session: ChatSession) => string;
};
};

export type CoreSlashCommandResult = LocalCommandResult;
2 changes: 2 additions & 0 deletions src/core/commands/slash/modules/core-command-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createAuthSlashCommandModule } from './auth/auth-commands.js';
import { createCompactionSlashCommandModule } from './compaction/compaction-commands.js';
import { createDriftSlashCommandModule } from './drift/drift-commands.js';
import { createModelSlashCommandModule } from './model/model-commands.js';
import { createSessionSlashCommandModule } from './session/session-commands.js';

export function createCoreSlashCommandModules(): SlashCommandModule<
CoreSlashCommandResult,
Expand All @@ -14,5 +15,6 @@ export function createCoreSlashCommandModules(): SlashCommandModule<
createAuthSlashCommandModule(),
createCompactionSlashCommandModule(),
createDriftSlashCommandModule(),
createSessionSlashCommandModule(),
];
}
Loading
Loading