Skip to content
Open
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
161 changes: 159 additions & 2 deletions src/__tests__/main/ipc/handlers/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ vi.mock('../../../../main/utils/execFile', () => ({
// Mock fs
vi.mock('fs', () => ({
existsSync: vi.fn(),
promises: {
readdir: vi.fn(),
readFile: vi.fn(),
},
}));

// Mock ssh-command-builder for remote model discovery tests
Expand Down Expand Up @@ -1075,7 +1079,23 @@ describe('agents IPC handlers', () => {
expect(execFileNoThrow).toHaveBeenCalledWith('/custom/claude', expect.any(Array), '/test');
});

it('should return null for non-Claude Code agents', async () => {
it('should return null for unsupported agents', async () => {
const mockAgent = {
id: 'codex',
available: true,
path: '/usr/bin/codex',
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

const handler = handlers.get('agents:discoverSlashCommands');
const result = await handler!({} as any, 'codex', '/test');

expect(result).toBeNull();
expect(execFileNoThrow).not.toHaveBeenCalled();
});

it('should return built-in commands for opencode', async () => {
const mockAgent = {
id: 'opencode',
available: true,
Expand All @@ -1084,13 +1104,150 @@ describe('agents IPC handlers', () => {

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

// All disk reads return ENOENT (no custom commands)
const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.mocked(fs.promises.readdir).mockRejectedValue(enoent);
vi.mocked(fs.promises.readFile).mockRejectedValue(enoent);

const handler = handlers.get('agents:discoverSlashCommands');
const result = await handler!({} as any, 'opencode', '/test');

expect(result).toBeNull();
expect(result).toEqual(
expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models'])
);
expect(execFileNoThrow).not.toHaveBeenCalled();
});

it('should discover opencode commands from project .opencode/commands/*.md', async () => {
const mockAgent = {
id: 'opencode',
available: true,
path: '/usr/bin/opencode',
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
// Project commands dir has custom .md files
vi.mocked(fs.promises.readdir).mockImplementation(async (dir) => {
if (String(dir).includes('/test/.opencode/commands')) {
return ['deploy.md', 'lint.md', 'README.txt'] as any;
}
throw enoent;
});
vi.mocked(fs.promises.readFile).mockRejectedValue(enoent);

const handler = handlers.get('agents:discoverSlashCommands');
const result = await handler!({} as any, 'opencode', '/test');

expect(result).toContain('deploy');
expect(result).toContain('lint');
// Non-.md files should be ignored
expect(result).not.toContain('README.txt');
// Built-ins should still be present
expect(result).toContain('init');
});

it('should discover opencode commands from opencode.json config', async () => {
const mockAgent = {
id: 'opencode',
available: true,
path: '/usr/bin/opencode',
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.mocked(fs.promises.readdir).mockRejectedValue(enoent);
vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => {
if (String(filePath).includes('/test/opencode.json')) {
return JSON.stringify({ command: { 'my-cmd': { description: 'test' } } });
}
throw enoent;
});

const handler = handlers.get('agents:discoverSlashCommands');
const result = await handler!({} as any, 'opencode', '/test');

expect(result).toContain('my-cmd');
expect(result).toContain('init');
});

it('should ignore array values in opencode.json command property', async () => {
const mockAgent = {
id: 'opencode',
available: true,
path: '/usr/bin/opencode',
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.mocked(fs.promises.readdir).mockRejectedValue(enoent);
vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => {
if (String(filePath).includes('/test/opencode.json')) {
return JSON.stringify({ command: ['not', 'an', 'object'] });
}
throw enoent;
});

const handler = handlers.get('agents:discoverSlashCommands');
const result = await handler!({} as any, 'opencode', '/test');

// Should only have built-in commands (array config ignored)
expect(result).toEqual(
expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models'])
);
expect(result).not.toContain('not');
});

it('should gracefully handle malformed opencode.json and still return built-in commands', async () => {
const mockAgent = {
id: 'opencode',
available: true,
path: '/usr/bin/opencode',
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
vi.mocked(fs.promises.readdir).mockRejectedValue(enoent);
vi.mocked(fs.promises.readFile).mockImplementation(async (filePath) => {
if (String(filePath).includes('/test/opencode.json')) {
return '{ invalid json, }';
}
throw enoent;
});

const handler = handlers.get('agents:discoverSlashCommands');
const result = await handler!({} as any, 'opencode', '/test');

// Malformed JSON should be skipped gracefully — built-ins still present
expect(result).toEqual(
expect.arrayContaining(['init', 'review', 'undo', 'redo', 'share', 'help', 'models'])
);
});

it('should rethrow non-ENOENT errors for opencode discovery', async () => {
const mockAgent = {
id: 'opencode',
available: true,
path: '/usr/bin/opencode',
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);

// Permission error (not ENOENT)
const permError = Object.assign(new Error('EACCES'), { code: 'EACCES' });
vi.mocked(fs.promises.readdir).mockRejectedValue(permError);
vi.mocked(fs.promises.readFile).mockRejectedValue(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
);

const handler = handlers.get('agents:discoverSlashCommands');
await expect(handler!({} as any, 'opencode', '/test')).rejects.toThrow('EACCES');
});

it('should return null when agent is not available', async () => {
mockAgentDetector.getAgent.mockResolvedValue({ id: 'claude-code', available: false });

Expand Down
2 changes: 1 addition & 1 deletion src/main/agents/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export const AGENT_CAPABILITIES: Record<string, AgentCapabilities> = {
supportsSessionId: true, // sessionID in JSON output (camelCase) - Verified
supportsImageInput: true, // -f, --file flag documented - Documented
supportsImageInputOnResume: true, // -f flag works with --session flag - Documented
supportsSlashCommands: false, // Not investigated
supportsSlashCommands: true, // Built-in + custom commands via .opencode/commands/ and opencode.json
supportsSessionStorage: true, // ~/.local/share/opencode/storage/ (JSON files) - Verified
supportsCostTracking: true, // part.cost in step_finish events - Verified
supportsUsageStats: true, // part.tokens in step_finish events - Verified
Expand Down
84 changes: 83 additions & 1 deletion src/main/ipc/handlers/agents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ipcMain } from 'electron';
import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { AgentDetector, AGENT_DEFINITIONS, getAgentCapabilities } from '../../agents';
import { execFileNoThrow } from '../../utils/execFile';
import { logger } from '../../utils/logger';
Expand All @@ -27,6 +29,82 @@ const handlerOpts = (
operation,
});

// OpenCode built-in slash commands (always available)
const OPENCODE_BUILTIN_COMMANDS = ['init', 'review', 'undo', 'redo', 'share', 'help', 'models'];

/**
* Discover OpenCode slash commands by reading from disk.
*
* OpenCode commands come from three sources:
* 1. Built-in commands (init, review, undo, redo, share, help, models)
* 2. Project-local custom commands: .opencode/commands/*.md
* 3. Global custom commands: $XDG_CONFIG_HOME/opencode/commands/*.md
* 4. Config-based commands: opencode.json "command" property
*
* Unlike Claude Code (which emits commands via init event), OpenCode commands
* are statically defined on disk and can be discovered without spawning the agent.
*/
async function discoverOpenCodeSlashCommands(cwd: string): Promise<string[]> {
const commands = new Set<string>(OPENCODE_BUILTIN_COMMANDS);
const globalConfigBase = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');

// Helper: read .md filenames from a commands directory
const addCommandsFromDir = async (dir: string) => {
try {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (file.endsWith('.md')) {
commands.add(file.replace(/\.md$/, ''));
}
}
} catch (error: any) {
if (error?.code === 'ENOENT') {
logger.debug(`OpenCode commands directory not found: ${dir}`, LOG_CONTEXT);
} else {
throw error;
}
}
};

// Helper: read command names from an opencode.json config file
const addCommandsFromConfig = async (configPath: string) => {
let content: string;
try {
content = await fs.promises.readFile(configPath, 'utf-8');
} catch (error: any) {
if (error?.code === 'ENOENT') {
logger.debug(`OpenCode config not found: ${configPath}`, LOG_CONTEXT);
return;
}
throw error;
}
let config: any;
try {
config = JSON.parse(content);
} catch {
logger.warn(`OpenCode config has invalid JSON, skipping: ${configPath}`, LOG_CONTEXT);
return;
}
if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) {
for (const name of Object.keys(config.command)) {
commands.add(name);
}
}
Comment on lines +70 to +92
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON parse errors silently kill all command discovery

JSON.parse(content) throws a SyntaxError for malformed JSON. A SyntaxError has no .code property, so error?.code === 'ENOENT' evaluates to false and the error is re-thrown. Since both addCommandsFromConfig calls run inside Promise.all, a single malformed opencode.json (e.g. a stray trailing comma or an unquoted key) causes the entire discoverOpenCodeSlashCommands to reject — wiping out the built-in commands that were already collected into commands.

The practical impact: a user with any typo in their opencode.json will see zero slash-command autocomplete instead of the seven built-in commands they should always get.

	const addCommandsFromConfig = async (configPath: string) => {
		try {
			const content = await fs.promises.readFile(configPath, 'utf-8');
			let config: any;
			try {
				config = JSON.parse(content);
			} catch {
				logger.warn(`OpenCode config has invalid JSON, skipping: ${configPath}`, LOG_CONTEXT);
				return;
			}
			if (config.command && typeof config.command === 'object' && !Array.isArray(config.command)) {
				for (const name of Object.keys(config.command)) {
					commands.add(name);
				}
			}
		} catch (error: any) {
			if (error?.code === 'ENOENT') {
				logger.debug(`OpenCode config not found: ${configPath}`, LOG_CONTEXT);
			} else {
				throw error;
			}
		}
	};

The same pattern applies to both the project-level and global opencode.json paths read on lines 92–93.

};

// Read all four sources concurrently
await Promise.all([
addCommandsFromDir(path.join(cwd, '.opencode', 'commands')),
addCommandsFromDir(path.join(globalConfigBase, 'opencode', 'commands')),
addCommandsFromConfig(path.join(cwd, 'opencode.json')),
addCommandsFromConfig(path.join(globalConfigBase, 'opencode', 'opencode.json')),
]);

const commandList = Array.from(commands);
logger.info(`Discovered ${commandList.length} OpenCode slash commands`, LOG_CONTEXT);
return commandList;
}

/**
* Interface for agent configuration store data
*/
Expand Down Expand Up @@ -850,7 +928,11 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
return null;
}

// Only Claude Code supports slash command discovery via init message
// Agent-specific discovery paths
if (agentId === 'opencode') {
return discoverOpenCodeSlashCommands(cwd);
}

if (agentId !== 'claude-code') {
logger.debug(`Agent ${agentId} does not support slash command discovery`, LOG_CONTEXT);
return null;
Expand Down
44 changes: 37 additions & 7 deletions src/renderer/constants/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,46 @@ export const CLAUDE_BUILTIN_COMMANDS: Record<string, string> = {
};

/**
* Get description for Claude Code slash commands
* Built-in commands have known descriptions, custom ones use a generic description
* Built-in OpenCode slash commands with their descriptions
*/
export function getSlashCommandDescription(cmd: string): string {
export const OPENCODE_BUILTIN_COMMANDS: Record<string, string> = {
init: 'Create or update AGENTS.md for the project',
review: 'Review changes (commit, branch, or PR)',
undo: 'Revert changes made by OpenCode',
redo: 'Restore previously undone changes',
share: 'Create a shareable link to the conversation',
help: 'List available commands',
models: 'Switch models interactively',
};

/**
* Agent-specific built-in command maps, keyed by agent ID
*/
const AGENT_BUILTIN_COMMANDS: Record<string, Record<string, string>> = {
'claude-code': CLAUDE_BUILTIN_COMMANDS,
opencode: OPENCODE_BUILTIN_COMMANDS,
};

/**
* Get description for agent slash commands.
* Checks all known agent built-in command maps, then falls back to generic description.
*/
export function getSlashCommandDescription(cmd: string, agentId?: string): string {
// Remove leading slash if present
const cmdName = cmd.startsWith('/') ? cmd.slice(1) : cmd;

// Check for built-in command
if (CLAUDE_BUILTIN_COMMANDS[cmdName]) {
return CLAUDE_BUILTIN_COMMANDS[cmdName];
// If a specific agent is provided, check that agent's commands first
if (agentId && AGENT_BUILTIN_COMMANDS[agentId]?.[cmdName]) {
return AGENT_BUILTIN_COMMANDS[agentId][cmdName];
}

// Check all agent command maps only when no specific agent was requested
if (!agentId) {
for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) {
if (commands[cmdName]) {
return commands[cmdName];
}
}
}

// For plugin commands (e.g., "plugin-name:command"), use the full name as description hint
Expand All @@ -108,5 +138,5 @@ export function getSlashCommandDescription(cmd: string): string {
}

// Generic description for unknown commands
return 'Claude Code command';
return 'Agent command';
}
9 changes: 4 additions & 5 deletions src/renderer/hooks/agent/useAgentListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,14 +980,13 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void {
(sessionId: string, slashCommands: string[]) => {
const actualSessionId = parseSessionId(sessionId).baseSessionId;

const commands = slashCommands.map((cmd) => ({
command: cmd.startsWith('/') ? cmd : `/${cmd}`,
description: getSlashCommandDescription(cmd),
}));

setSessions((prev) =>
prev.map((s) => {
if (s.id !== actualSessionId) return s;
const commands = slashCommands.map((cmd) => ({
command: cmd.startsWith('/') ? cmd : `/${cmd}`,
description: getSlashCommandDescription(cmd, s.toolType),
}));
return { ...s, agentCommands: commands };
})
);
Expand Down
Loading