feat(opencode): slash command support (#671) (#753)

This commit is contained in:
SSU-WEI HUANG
2026-05-31 19:35:31 +08:00
committed by GitHub
parent 31dd4353d4
commit 5b797bb95d
17 changed files with 775 additions and 28 deletions
+15 -2
View File
@@ -585,9 +585,22 @@ export class ApiSessionClient extends EventEmitter {
})
}
emitMessagesConsumed(localIds: string[]): void {
emitMessagesConsumed(localIds: string[], options?: { clearQueuedThinkingGrace?: boolean }): void {
if (localIds.length === 0) return
this.socket.emit('messages-consumed', { sid: this.sessionId, localIds })
// `clearQueuedThinkingGrace` is an opt-in signal for the hub to drop
// the 15s queued-thinking grace immediately. Only synchronous handlers
// that will never call `onThinkingChange(true)` (slash commands handled
// inside `onUserMessage`) should set it — normal queue drains still
// need the grace so the spinner doesn't flicker between drain and
// backend.prompt start.
const payload: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean } = {
sid: this.sessionId,
localIds
}
if (options?.clearQueuedThinkingGrace) {
payload.clearQueuedThinkingGrace = true
}
this.socket.emit('messages-consumed', payload)
}
sendSessionDeath(reason?: SessionEndReason): void {
@@ -7,24 +7,32 @@ import { listSlashCommands } from './slashCommands'
describe('listSlashCommands', () => {
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalCodexHome = process.env.CODEX_HOME
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
let sandboxDir: string
let claudeConfigDir: string
let codexHome: string
let xdgConfigHome: string
let opencodeUserDir: string
let projectDir: string
beforeEach(async () => {
sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-slash-commands-'))
claudeConfigDir = join(sandboxDir, 'global-claude')
codexHome = join(sandboxDir, 'global-codex')
xdgConfigHome = join(sandboxDir, 'xdg-config')
opencodeUserDir = join(xdgConfigHome, 'opencode', 'command')
projectDir = join(sandboxDir, 'project')
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
process.env.CODEX_HOME = codexHome
process.env.XDG_CONFIG_HOME = xdgConfigHome
await mkdir(join(claudeConfigDir, 'commands'), { recursive: true })
await mkdir(join(codexHome, 'prompts'), { recursive: true })
await mkdir(opencodeUserDir, { recursive: true })
await mkdir(join(projectDir, '.claude', 'commands'), { recursive: true })
await mkdir(join(projectDir, '.codex', 'prompts'), { recursive: true })
await mkdir(join(projectDir, '.opencode', 'command'), { recursive: true })
})
afterEach(async () => {
@@ -38,6 +46,11 @@ describe('listSlashCommands', () => {
} else {
process.env.CODEX_HOME = originalCodexHome
}
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
}
await rm(sandboxDir, { recursive: true, force: true })
})
@@ -164,6 +177,64 @@ describe('listSlashCommands', () => {
})
})
it('exposes HAPI-supported OpenCode built-ins', async () => {
const commands = await listSlashCommands('opencode', projectDir)
const names = commands.map((command) => command.name)
expect(names).toEqual(expect.arrayContaining([
'help',
'status',
'plan',
'default',
'init',
]))
// Anything covered by composer buttons, plus aliases and unsupported
// placeholders, must stay out of the autocomplete menu — the resolver
// still accepts them when typed manually.
for (const hidden of ['model', 'reasoning', 'effort', 'permissions', 'permission', 'clear', 'compact']) {
expect(names).not.toContain(hidden)
}
})
it('loads OpenCode user and project commands', async () => {
await writeFile(
join(opencodeUserDir, 'global-opencode.md'),
['---', 'description: Global OpenCode prompt', '---', '', 'Global OpenCode body'].join('\n')
)
await writeFile(
join(projectDir, '.opencode', 'command', 'project-opencode.md'),
['---', 'description: Project OpenCode prompt', '---', '', 'Project OpenCode body'].join('\n')
)
const commands = await listSlashCommands('opencode', projectDir)
expect(commands.find(cmd => cmd.name === 'global-opencode')).toMatchObject({
source: 'user',
description: 'Global OpenCode prompt',
content: 'Global OpenCode body',
})
expect(commands.find(cmd => cmd.name === 'project-opencode')).toMatchObject({
source: 'project',
description: 'Project OpenCode prompt',
content: 'Project OpenCode body',
})
})
it('lets project opencode prompts override same-name built-ins', async () => {
await writeFile(
join(projectDir, '.opencode', 'command', 'status.md'),
['---', 'description: Project status', '---', '', 'Project status prompt'].join('\n')
)
const commands = await listSlashCommands('opencode', projectDir)
const statusCommands = commands.filter(cmd => cmd.name === 'status')
expect(statusCommands).toHaveLength(1)
expect(statusCommands[0]?.source).toBe('project')
expect(statusCommands[0]?.description).toBe('Project status')
expect(statusCommands[0]?.content).toBe('Project status prompt')
})
it('loads Codex project prompts from cwd up to repo root with nearest override', async () => {
const repoRoot = join(sandboxDir, 'repo')
const workingDirectory = join(repoRoot, 'apps', 'web')
+6
View File
@@ -65,6 +65,10 @@ function getUserCommandsDir(agent: string): string | null {
const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex');
return join(codexHome, 'prompts');
}
case 'opencode': {
const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
return join(xdgConfigHome, 'opencode', 'command');
}
default:
// Gemini and other agents don't have user commands
return null;
@@ -81,6 +85,8 @@ function getProjectCommandsDir(agent: string, projectDir: string): string | null
return join(projectDir, '.claude', 'commands');
case 'codex':
return join(projectDir, '.codex', 'prompts');
case 'opencode':
return join(projectDir, '.opencode', 'command');
default:
// Gemini and other agents don't have project commands
return null;
@@ -101,6 +101,13 @@ function createModeWithEffort(model: string | undefined, modelReasoningEffort: s
};
}
function createResetMode(): OpencodeMode {
return {
permissionMode: 'default' as PermissionMode,
model: null
};
}
function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }>) {
const queue = new MessageQueue2<OpencodeMode>((mode) => JSON.stringify(mode));
items.forEach(({ message, mode }, index) => {
@@ -250,6 +257,37 @@ describe('opencodeRemoteLauncher inline model switch', () => {
it('resets to the backend launch-time default model when the queued mode.model is null', async () => {
// Seed the backend with a launch-time default model so the launcher
// captures it as `defaultBackendModel`. Without that, `/model default`
// resolves to null and the launcher has nothing to switch back to.
const opencodeBackendModule = await import('./utils/opencodeBackend');
const factory = (opencodeBackendModule as unknown as { createOpencodeBackend: ReturnType<typeof vi.fn> }).createOpencodeBackend;
const originalImpl = factory.getMockImplementation();
factory.mockImplementationOnce(() => {
const backend = (originalImpl as () => Record<string, unknown>)();
backend.getSessionModelsMetadata = vi.fn(() => ({
currentModelId: 'ollama/launch-default',
availableModels: []
}));
return backend;
});
const { session } = createSessionStub([
{ message: 'first', mode: createMode('ollama/custom') },
{ message: 'second', mode: createResetMode() }
]);
await opencodeRemoteLauncher(session as never);
// Switch to custom on turn 1, then back to the launch-time default on turn 2.
expect(harness.setModelArgs).toEqual([
{ sessionId: 'acp-session-1', modelId: 'ollama/custom', flavor: 'opencode' },
{ sessionId: 'acp-session-1', modelId: 'ollama/launch-default', flavor: 'opencode' }
]);
expect(harness.promptCount).toBe(2);
});
it('calls setConfigOption for OpenCode reasoning effort changes', async () => {
harness.thoughtLevelOption = {
id: 'effort',
+19 -7
View File
@@ -25,6 +25,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private displayPermissionMode: PermissionMode | null = null;
private instructionsSent = false;
private currentBackendModel: string | null = null;
private defaultBackendModel: string | null = null;
private currentBackendEffort: string | null = null;
private defaultBackendEffort: string | null = null;
private setModelSupported: boolean | undefined = undefined;
@@ -103,6 +104,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
// does not trigger a redundant setModel on the very first turn.
const initialMetadata = backend.getSessionModelsMetadata?.(acpSessionId);
this.currentBackendModel = initialMetadata?.currentModelId ?? null;
this.defaultBackendModel = this.currentBackendModel;
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
@@ -153,19 +155,29 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
// RPC, we learn that from the first method-not-found response and stop
// attempting it for the rest of this session.
//
// `batch.mode.model` semantics: a string is a specific model id;
// `null` means "reset to whatever model the backend launched with"
// (emitted by `/model default`); `undefined` means "no change".
const requestedModel = batch.mode.model === null
? this.defaultBackendModel
: batch.mode.model;
// The very first batch seeds currentBackendModel — the OpenCode CLI was
// launched with that model via --model and there is nothing to switch yet.
if (batch.mode.model && this.currentBackendModel === null) {
this.currentBackendModel = batch.mode.model;
} else if (batch.mode.model && batch.mode.model !== this.currentBackendModel) {
if (requestedModel && this.currentBackendModel === null) {
this.currentBackendModel = requestedModel;
} else if (requestedModel && requestedModel !== this.currentBackendModel) {
if (!backend.setModel || this.setModelSupported === false) {
batch.mode.model = this.currentBackendModel ?? undefined;
} else {
logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`);
logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`);
try {
await backend.setModel(acpSessionId, batch.mode.model, { flavor: 'opencode' });
this.currentBackendModel = batch.mode.model;
await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' });
this.currentBackendModel = requestedModel;
this.setModelSupported = true;
// Reflect the resolved model back into the batch so
// downstream display logic sees the concrete id rather
// than a `null` placeholder.
batch.mode.model = requestedModel;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const methodNotFound = /method not found/i.test(message);
@@ -180,7 +192,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
logger.warn('[opencode-remote] Inline model switch failed', error);
session.sendSessionEvent({
type: 'message',
message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel ?? '(default)'}.`
message: `Failed to switch model to ${requestedModel}. Continuing with ${this.currentBackendModel ?? '(default)'}.`
});
}
batch.mode.model = this.currentBackendModel ?? undefined;
+64
View File
@@ -13,9 +13,12 @@ const harness = vi.hoisted(() => ({
bootstrapArgs: [] as Array<Record<string, unknown>>,
opencodeLoopArgs: [] as Array<Record<string, unknown>>,
opencodeLoopError: null as Error | null,
listSlashCommands: vi.fn(async (..._args: unknown[]) => [] as Array<unknown>),
session: {
onUserMessage: vi.fn(),
onCancelQueuedMessage: vi.fn(),
sendAgentMessage: vi.fn(),
emitMessagesConsumed: vi.fn(),
rpcHandlerManager: {
registerHandler: vi.fn()
}
@@ -81,6 +84,10 @@ vi.mock('@/utils/attachmentFormatter', () => ({
formatMessageWithAttachments: vi.fn((text: string) => text)
}));
vi.mock('@/modules/common/slashCommands', () => ({
listSlashCommands: (agent: string, projectDir?: string) => harness.listSlashCommands(agent, projectDir)
}));
import { runOpencode } from './runOpencode';
describe('runOpencode set-session-config handler', () => {
@@ -93,7 +100,12 @@ describe('runOpencode set-session-config handler', () => {
mockOpencodeSession.setModelReasoningEffort.mockReset();
mockOpencodeSession.pushKeepAlive.mockReset();
harness.session.onUserMessage.mockReset();
harness.session.onCancelQueuedMessage.mockReset();
harness.session.sendAgentMessage.mockReset();
harness.session.emitMessagesConsumed.mockReset();
harness.session.rpcHandlerManager.registerHandler.mockReset();
harness.listSlashCommands.mockReset();
harness.listSlashCommands.mockResolvedValue([]);
lifecycleMock.registerProcessHandlers.mockClear();
lifecycleMock.cleanupAndExit.mockClear();
lifecycleMock.markCrash.mockClear();
@@ -223,4 +235,56 @@ describe('runOpencode set-session-config handler', () => {
expect(harness.opencodeLoopArgs[0]?.model).toBe('ollama/exaone:4.5-33b-q8');
});
it('opts in to clearQueuedThinkingGrace when acking a handled slash command', async () => {
await runOpencode({});
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
| undefined;
expect(userMessageHandler).toBeDefined();
userMessageHandler!({ content: { text: '/status' } }, 'local-status');
// Drain microtasks so the chain runs and acks the slash command.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.session.emitMessagesConsumed).toHaveBeenCalledWith(
['local-status'],
{ clearQueuedThinkingGrace: true }
);
// The slash reply should still have gone out as a separate message.
expect(harness.session.sendAgentMessage).toHaveBeenCalled();
});
it('cancels a slash command that is cancelled before listSlashCommands resolves', async () => {
let releaseListSlashCommands: () => void = () => {};
const slashCommandsPromise = new Promise<unknown[]>((resolve) => {
releaseListSlashCommands = () => resolve([]);
});
harness.listSlashCommands.mockReset();
harness.listSlashCommands.mockReturnValue(slashCommandsPromise);
await runOpencode({});
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
| undefined;
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as
((localId: string) => boolean) | undefined;
expect(userMessageHandler).toBeDefined();
expect(cancelHandler).toBeDefined();
userMessageHandler!({ content: { text: '/status' } }, 'local-1');
// Cancel arrives while listSlashCommands is still pending — the queue
// is empty, so without the preparing-localIds bookkeeping the cancel
// would return false and the slash reply would still fire when the
// chain resumes.
expect(cancelHandler!('local-1')).toBe(true);
releaseListSlashCommands();
// Drain microtasks so the chain runs the cancellation short-circuit.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled();
});
});
+125 -11
View File
@@ -1,4 +1,5 @@
import { logger } from '@/ui/logger';
import { randomUUID } from 'node:crypto';
import { opencodeLoop } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
@@ -13,6 +14,8 @@ import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc';
import { startOpencodeHookServer } from './utils/startOpencodeHookServer';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import { listSlashCommands } from '@/modules/common/slashCommands';
import { resolveOpencodeSlashCommand } from './utils/slashCommands';
export async function runOpencode(opts: {
startedBy?: 'runner' | 'terminal';
@@ -72,7 +75,10 @@ export async function runOpencode(opts: {
const messageQueue = new MessageQueue2<OpencodeMode>((mode) => hashObject({
permissionMode: mode.permissionMode,
model: mode.model ?? null,
// Distinguish "explicit reset" (null) from "no change" (undefined) so
// batches with different intent don't merge — the launcher uses null
// to mean "switch back to defaultBackendModel".
model: mode.model === null ? '__reset__' : mode.model ?? null,
modelReasoningEffort: mode.modelReasoningEffort ?? null
}));
@@ -120,20 +126,128 @@ export async function runOpencode(opts: {
logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}, modelReasoningEffort=${sessionModelReasoningEffort ?? '(default)'}`);
};
// Slash-command resolution now runs inside an async chain on
// `session.onUserMessage`, so there is a window between the message
// arriving and `messageQueue.push` / `sendAgentMessage` where
// `cancelByLocalId` would find nothing. Track in-flight localIds so the
// cancel RPC can ack the cancel during that window and the chain can
// short-circuit when it resumes.
const preparingLocalIds = new Set<string>();
const cancelledBeforeEnqueue = new Set<string>();
let userMessageChain: Promise<void> = Promise.resolve();
session.onUserMessage((message, localId) => {
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
const mode: OpencodeMode = {
permissionMode: currentPermissionMode,
model: sessionModel ?? undefined,
modelReasoningEffort: sessionModelReasoningEffort
};
messageQueue.push(formattedText, mode, localId);
if (localId) preparingLocalIds.add(localId);
userMessageChain = userMessageChain.then(async () => {
const wasCancelled = (): boolean => {
if (!localId) return false;
return cancelledBeforeEnqueue.delete(localId);
};
const buildMode = (): OpencodeMode => ({
permissionMode: currentPermissionMode,
// Propagate null distinctly from undefined so the launcher can
// tell "reset to default" (from `/model default`) apart from
// "model unchanged".
model: sessionModel,
modelReasoningEffort: sessionModelReasoningEffort
});
const pushPlain = () => {
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
messageQueue.push(formattedText, buildMode(), localId);
};
try {
if (wasCancelled()) return;
let text = message.content.text;
const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []);
if (wasCancelled()) return;
const slash = resolveOpencodeSlashCommand(text, {
commands,
permissionMode: currentPermissionMode,
model: sessionModel,
modelReasoningEffort: sessionModelReasoningEffort
});
if (slash.kind !== 'passthrough') {
if (slash.updates) {
if (slash.updates.permissionMode !== undefined) {
currentPermissionMode = slash.updates.permissionMode;
}
if (slash.updates.model !== undefined) {
sessionModel = slash.updates.model;
}
if (slash.updates.modelReasoningEffort !== undefined) {
sessionModelReasoningEffort = slash.updates.modelReasoningEffort;
}
syncSessionMode();
}
if (slash.kind === 'handled') {
// Ack the user's slash-command message before sending the
// agent reply. The web sorts the conversation by
// `invokedAt ?? createdAt` (web/src/lib/messages.ts), so
// stamping invokedAt first keeps the user prompt above
// the reply instead of below it. Pass
// `clearQueuedThinkingGrace` so the hub drops its 15s
// grace — this synchronous path never calls
// `onThinkingChange(true)`, so the next `thinking=false`
// keepalive must be honored immediately.
if (localId) {
session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true });
}
if (slash.message) {
session.sendAgentMessage({
type: 'message',
message: slash.message,
id: randomUUID()
});
}
// Push a thinking=false keepalive immediately so the
// spinner clears without waiting for the next 2s tick.
// (The hub-side queued-thinking grace is dropped on
// messages-consumed above, so this keepalive is honored.)
sessionWrapperRef.current?.onThinkingChange(false);
return;
}
if (slash.message) {
session.sendAgentMessage({
type: 'message',
message: slash.message,
id: randomUUID()
});
}
text = slash.text;
}
const formattedText = formatMessageWithAttachments(text, message.content.attachments);
messageQueue.push(formattedText, buildMode(), localId);
} catch (error) {
logger.debug('[opencode] Failed to handle user message', error);
if (!wasCancelled()) {
pushPlain();
}
} finally {
if (localId) {
preparingLocalIds.delete(localId);
cancelledBeforeEnqueue.delete(localId);
}
}
}).catch((error) => {
logger.debug('[opencode] User message handler chain failed', error);
});
});
session.onCancelQueuedMessage((localId) => {
const removed = messageQueue.cancelByLocalId(localId);
logger.debug(`[opencode] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`);
return removed;
const removedFromQueue = messageQueue.cancelByLocalId(localId);
if (removedFromQueue) {
logger.debug(`[opencode] cancelByLocalId(${localId}): removed from queue`);
return true;
}
if (preparingLocalIds.has(localId)) {
cancelledBeforeEnqueue.add(localId);
logger.debug(`[opencode] cancelByLocalId(${localId}): marked for cancellation before enqueue`);
return true;
}
logger.debug(`[opencode] cancelByLocalId(${localId}): not found (best-effort)`);
return false;
});
registerSessionConfigRpc<PermissionMode>({
+4 -1
View File
@@ -4,7 +4,10 @@ export type PermissionMode = OpencodePermissionMode;
export interface OpencodeMode {
permissionMode: PermissionMode;
model?: string;
// `string` is a specific model id; `null` means "reset to the backend's
// launch-time default" (e.g. after `/model default`); `undefined` means
// "no change requested for this batch".
model?: string | null;
modelReasoningEffort?: string | null;
}
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { resolveOpencodeSlashCommand } from './slashCommands';
const state = {
permissionMode: 'default' as const,
model: 'anthropic/claude-sonnet-4-5',
modelReasoningEffort: 'high' as const
};
describe('resolveOpencodeSlashCommand', () => {
it('enables plan mode without sending a turn', () => {
expect(resolveOpencodeSlashCommand('/plan', state)).toEqual({
kind: 'handled',
message: 'OpenCode plan mode enabled',
updates: { permissionMode: 'plan' }
});
});
it('enables plan mode and sends prompt when /plan has text', () => {
expect(resolveOpencodeSlashCommand('/plan design the fix', state)).toEqual({
kind: 'replace',
text: 'design the fix',
message: 'OpenCode plan mode enabled',
updates: { permissionMode: 'plan' }
});
});
it('returns to default permission mode from /plan off', () => {
expect(resolveOpencodeSlashCommand('/plan off', { ...state, permissionMode: 'plan' })).toEqual({
kind: 'handled',
message: 'OpenCode plan mode disabled',
updates: { permissionMode: 'default' }
});
});
it('handles /default', () => {
expect(resolveOpencodeSlashCommand('/default', { ...state, permissionMode: 'plan' })).toEqual({
kind: 'handled',
message: 'OpenCode permission mode set to default',
updates: { permissionMode: 'default' }
});
});
it('sets model, reasoning effort, and permission mode', () => {
expect(resolveOpencodeSlashCommand('/model openai/gpt-5', state)).toMatchObject({
updates: { model: 'openai/gpt-5' }
});
expect(resolveOpencodeSlashCommand('/model default', state)).toMatchObject({
updates: { model: null }
});
expect(resolveOpencodeSlashCommand('/reasoning low', state)).toMatchObject({
updates: { modelReasoningEffort: 'low' }
});
expect(resolveOpencodeSlashCommand('/effort default', state)).toMatchObject({
updates: { modelReasoningEffort: null }
});
expect(resolveOpencodeSlashCommand('/permissions yolo', state)).toMatchObject({
updates: { permissionMode: 'yolo' }
});
expect(resolveOpencodeSlashCommand('/permission plan', state)).toMatchObject({
updates: { permissionMode: 'plan' }
});
});
it('rejects unknown permission modes', () => {
expect(resolveOpencodeSlashCommand('/permissions bogus', state)).toMatchObject({
kind: 'handled',
message: expect.stringContaining('Unknown OpenCode permission mode')
});
});
it('shows current values when slash command has no argument', () => {
expect(resolveOpencodeSlashCommand('/model', state)).toEqual({
kind: 'handled',
message: 'OpenCode model: anthropic/claude-sonnet-4-5'
});
expect(resolveOpencodeSlashCommand('/reasoning', state)).toEqual({
kind: 'handled',
message: 'OpenCode reasoning effort: high'
});
expect(resolveOpencodeSlashCommand('/permissions', state)).toEqual({
kind: 'handled',
message: 'OpenCode permission mode: default'
});
});
it('returns status summary', () => {
const status = resolveOpencodeSlashCommand('/status', state);
expect(status).toMatchObject({
kind: 'handled',
message: expect.stringContaining('OpenCode status')
});
if (status.kind === 'handled') {
expect(status.message).toContain('permission: `default`');
expect(status.message).toContain('model: `anthropic/claude-sonnet-4-5`');
expect(status.message).toContain('reasoning: `high`');
}
});
it('expands /init into a project-analysis prompt', () => {
const result = resolveOpencodeSlashCommand('/init', state);
expect(result).toMatchObject({
kind: 'replace',
message: 'Initializing AGENTS.md…'
});
if (result.kind === 'replace') {
expect(result.text).toContain('AGENTS.md');
expect(result.text).toContain('Build / lint / test');
}
});
it('appends extra instructions when /init has arguments', () => {
const result = resolveOpencodeSlashCommand('/init focus on the cli/ workspace', state);
if (result.kind === 'replace') {
expect(result.text).toContain('AGENTS.md');
expect(result.text).toContain('Additional instructions: focus on the cli/ workspace');
} else {
throw new Error(`expected replace, got ${result.kind}`);
}
});
it('returns a not-yet-supported message for /clear and /compact', () => {
expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({
kind: 'handled',
message: '/clear is not yet supported in HAPI OpenCode sessions.'
});
expect(resolveOpencodeSlashCommand('/compact', state)).toEqual({
kind: 'handled',
message: '/compact is not yet supported in HAPI OpenCode sessions.'
});
});
it('expands custom OpenCode command prompts', () => {
expect(resolveOpencodeSlashCommand('/review src/index.ts', {
...state,
commands: [
{ name: 'review', source: 'project', content: 'Review this code.' }
]
})).toEqual({
kind: 'replace',
text: 'Review this code.\n\nUser arguments: src/index.ts',
message: 'Expanded /review'
});
});
it('expands custom prompts even when name matches a built-in', () => {
expect(resolveOpencodeSlashCommand('/clear', {
...state,
commands: [
{ name: 'clear', source: 'project', content: 'Clear project notes.' }
]
})).toEqual({
kind: 'replace',
text: 'Clear project notes.',
message: 'Expanded /clear'
});
});
it('renders /help with the supported commands', () => {
const help = resolveOpencodeSlashCommand('/help', state);
expect(help).toMatchObject({ kind: 'handled' });
if (help.kind === 'handled') {
expect(help.message).toContain('Supported OpenCode slash commands');
expect(help.message).toContain('/plan');
expect(help.message).toContain('/permissions');
}
});
it('passes unknown slash commands through', () => {
expect(resolveOpencodeSlashCommand('/unknown', state)).toEqual({ kind: 'passthrough' });
});
it('passes plain text through', () => {
expect(resolveOpencodeSlashCommand('hello there', state)).toEqual({ kind: 'passthrough' });
});
});
+208
View File
@@ -0,0 +1,208 @@
import { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes';
import type { OpencodePermissionMode } from '@hapi/protocol/types';
import type { SlashCommand } from '@/modules/common/slashCommands';
const OPENCODE_INIT_PROMPT = [
'Please analyze this codebase and create (or update) an `AGENTS.md` file at the repo root so future coding agents have what they need.',
'',
'Cover:',
'1. **Build / lint / test commands** — including how to run a *single* test, not just the whole suite.',
'2. **Code style** — imports, formatting, types, naming, error handling, anything non-obvious.',
'3. **Project layout** — only what is not derivable from a quick `ls`; highlight unusual boundaries or generated code.',
'',
'Guidelines:',
'- If `AGENTS.md` already exists, refine it rather than rewriting from scratch.',
'- If `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`, or similar conventions exist, fold their substance in (do not duplicate verbatim).',
'- Keep it concise (~2040 lines). Skip the obvious.'
].join('\n');
export type OpencodeSlashResolution =
| { kind: 'passthrough' }
| {
kind: 'handled';
message: string;
updates?: {
permissionMode?: OpencodePermissionMode;
model?: string | null;
modelReasoningEffort?: string | null;
};
}
| {
kind: 'replace';
text: string;
message?: string;
updates?: {
permissionMode?: OpencodePermissionMode;
model?: string | null;
modelReasoningEffort?: string | null;
};
};
export function resolveOpencodeSlashCommand(
text: string,
state: {
commands?: readonly SlashCommand[];
permissionMode: OpencodePermissionMode;
model?: string | null;
modelReasoningEffort?: string | null;
}
): OpencodeSlashResolution {
const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text);
if (!match) return { kind: 'passthrough' };
const command = match[1]?.toLowerCase();
const rest = match[2]?.trim() ?? '';
if (!command) return { kind: 'passthrough' };
const custom = state.commands?.find((candidate) =>
candidate.source !== 'builtin' && candidate.name.toLowerCase() === command
);
if (custom?.content) {
return {
kind: 'replace',
text: rest ? `${custom.content}\n\nUser arguments: ${rest}` : custom.content,
message: `Expanded /${custom.name}`
};
}
if (command === 'plan') {
const lowerRest = rest.toLowerCase();
if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') {
return {
kind: 'handled',
message: 'OpenCode plan mode disabled',
updates: { permissionMode: 'default' }
};
}
if (rest) {
return {
kind: 'replace',
text: rest,
message: 'OpenCode plan mode enabled',
updates: { permissionMode: 'plan' }
};
}
return {
kind: 'handled',
message: 'OpenCode plan mode enabled',
updates: { permissionMode: 'plan' }
};
}
if (command === 'default') {
return {
kind: 'handled',
message: 'OpenCode permission mode set to default',
updates: { permissionMode: 'default' }
};
}
if (command === 'status') {
return {
kind: 'handled',
message: [
'**OpenCode status**',
'',
`- permission: \`${state.permissionMode}\``,
`- model: \`${state.model ?? 'default'}\``,
`- reasoning: \`${state.modelReasoningEffort ?? 'default'}\``
].join('\n')
};
}
if (command === 'model') {
if (!rest) {
return { kind: 'handled', message: `OpenCode model: ${state.model ?? 'default'}` };
}
const model = rest === 'auto' || rest === 'default' ? null : rest;
return {
kind: 'handled',
message: `OpenCode model set to ${model ?? 'default'}`,
updates: { model }
};
}
if (command === 'reasoning' || command === 'effort') {
if (!rest) {
return {
kind: 'handled',
message: `OpenCode reasoning effort: ${state.modelReasoningEffort ?? 'default'}`
};
}
if (rest === 'default' || rest === 'auto') {
return {
kind: 'handled',
message: 'OpenCode reasoning effort set to default',
updates: { modelReasoningEffort: null }
};
}
return {
kind: 'handled',
message: `OpenCode reasoning effort set to ${rest}`,
updates: { modelReasoningEffort: rest }
};
}
if (command === 'permissions' || command === 'permission') {
if (!rest) {
return {
kind: 'handled',
message: `OpenCode permission mode: ${state.permissionMode}`
};
}
if (!(OPENCODE_PERMISSION_MODES as readonly string[]).includes(rest)) {
return {
kind: 'handled',
message: `Unknown OpenCode permission mode: ${rest}. Supported: ${OPENCODE_PERMISSION_MODES.join(', ')}.`
};
}
return {
kind: 'handled',
message: `OpenCode permission mode set to ${rest}`,
updates: { permissionMode: rest as OpencodePermissionMode }
};
}
if (command === 'clear' || command === 'compact') {
return {
kind: 'handled',
message: `/${command} is not yet supported in HAPI OpenCode sessions.`
};
}
if (command === 'init') {
const prompt = rest
? `${OPENCODE_INIT_PROMPT}\n\nAdditional instructions: ${rest}`
: OPENCODE_INIT_PROMPT;
return {
kind: 'replace',
text: prompt,
message: 'Initializing AGENTS.md…'
};
}
if (command === 'help') {
return {
kind: 'handled',
message: [
'**Supported OpenCode slash commands**',
'',
'- `/help` — show this list',
'- `/status` — show current OpenCode session config',
'- `/plan [prompt]` — enable plan mode, optionally send prompt',
'- `/plan off` — return to default permission mode',
'- `/default` — return to default permission mode',
'- `/init [extra]` — generate or refresh AGENTS.md for this project',
'',
'Model, reasoning effort, and permission mode have dedicated buttons in the composer. ' +
'You can still type `/model`, `/reasoning`, or `/permissions` if you prefer.',
'',
'`/clear` and `/compact` are not yet supported in HAPI OpenCode sessions.',
'',
'Custom commands from `~/.config/opencode/command` or `.opencode/command` are expanded before sending.'
].join('\n')
};
}
return { kind: 'passthrough' };
}