From 5b797bb95dd30c9ce29d7206eb4ab69a0c67ec0f Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Sun, 31 May 2026 19:35:31 +0800 Subject: [PATCH] feat(opencode): slash command support (#671) (#753) --- cli/src/api/apiSession.ts | 17 +- cli/src/modules/common/slashCommands.test.ts | 71 ++++++ cli/src/modules/common/slashCommands.ts | 6 + .../opencode/opencodeRemoteLauncher.test.ts | 38 ++++ cli/src/opencode/opencodeRemoteLauncher.ts | 26 ++- cli/src/opencode/runOpencode.test.ts | 64 ++++++ cli/src/opencode/runOpencode.ts | 136 +++++++++++- cli/src/opencode/types.ts | 5 +- cli/src/opencode/utils/slashCommands.test.ts | 176 +++++++++++++++ cli/src/opencode/utils/slashCommands.ts | 208 ++++++++++++++++++ hub/src/socket/handlers/cli/index.ts | 6 +- .../socket/handlers/cli/sessionHandlers.ts | 15 +- hub/src/socket/server.ts | 4 +- hub/src/startHub.ts | 3 +- hub/src/sync/sessionCache.ts | 16 ++ hub/src/sync/syncEngine.ts | 4 + shared/src/slashCommands.ts | 8 +- 17 files changed, 775 insertions(+), 28 deletions(-) create mode 100644 cli/src/opencode/utils/slashCommands.test.ts create mode 100644 cli/src/opencode/utils/slashCommands.ts diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 10992dc8..d187eba2 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -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 { diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index b0a75745..5a11acef 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -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') diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 24eaa42d..32217cb7 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -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; diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index f606f5f0..3a1aeae7 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -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((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 }).createOpencodeBackend; + const originalImpl = factory.getMockImplementation(); + factory.mockImplementationOnce(() => { + const backend = (originalImpl as () => Record)(); + 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', diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index fc1e1f77..6eeb8a78 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -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; diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index 0b7a3aca..41ef11ad 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -13,9 +13,12 @@ const harness = vi.hoisted(() => ({ bootstrapArgs: [] as Array>, opencodeLoopArgs: [] as Array>, opencodeLoopError: null as Error | null, + listSlashCommands: vi.fn(async (..._args: unknown[]) => [] as Array), 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((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(); + }); }); diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index b8eeca2a..a78f9311 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -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((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(); + const cancelledBeforeEnqueue = new Set(); + + let userMessageChain: Promise = 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({ diff --git a/cli/src/opencode/types.ts b/cli/src/opencode/types.ts index 60682bda..29283de2 100644 --- a/cli/src/opencode/types.ts +++ b/cli/src/opencode/types.ts @@ -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; } diff --git a/cli/src/opencode/utils/slashCommands.test.ts b/cli/src/opencode/utils/slashCommands.test.ts new file mode 100644 index 00000000..fe942445 --- /dev/null +++ b/cli/src/opencode/utils/slashCommands.test.ts @@ -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' }); + }); +}); diff --git a/cli/src/opencode/utils/slashCommands.ts b/cli/src/opencode/utils/slashCommands.ts new file mode 100644 index 00000000..8a49fc98 --- /dev/null +++ b/cli/src/opencode/utils/slashCommands.ts @@ -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 (~20–40 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' }; +} diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index 6ef231a1..223af963 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -44,10 +44,11 @@ export type CliHandlersDeps = { onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void + onMessagesConsumed?: (sessionId: string) => void } export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void { - const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued } = deps + const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps const terminalNamespace = io.of('/terminal') const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null @@ -109,7 +110,8 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers onWebappEvent, onBackgroundTaskDelta, onSessionActivity, - onSweepImmediateQueued + onSweepImmediateQueued, + onMessagesConsumed }) registerMachineHandlers(socket, { store, diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index a36dd82a..b2809003 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -67,10 +67,13 @@ export type SessionHandlersDeps = { onSessionActivity?: (sessionId: string, updatedAt: number) => void /** Delegates session-end immediate-queue sweep to the MessageService layer. */ onSweepImmediateQueued?: (sessionId: string, now: number) => void + /** Drops the queued-thinking grace so synchronous CLI handlers (e.g. slash + * commands) don't leave the spinner stuck for the full grace window. */ + onMessagesConsumed?: (sessionId: string) => void } export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void { - const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued } = deps + const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionEnd, onWebappEvent, onBackgroundTaskDelta, onSessionActivity, onSweepImmediateQueued, onMessagesConsumed } = deps socket.on('message', (data: unknown) => { const parsed = messageSchema.safeParse(data) @@ -269,7 +272,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session onSessionAlive?.(data) }) - socket.on('messages-consumed', (data: { sid: string; localIds: string[] }) => { + socket.on('messages-consumed', (data: { sid: string; localIds: string[]; clearQueuedThinkingGrace?: boolean }) => { if (!data || typeof data.sid !== 'string' || !Array.isArray(data.localIds)) { return } @@ -286,6 +289,14 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session try { store.messages.markMessagesInvoked(data.sid, localIds, invokedAt) onSessionActivity?.(data.sid, invokedAt) + // Only drop the queued-thinking grace when the CLI explicitly opts in + // (synchronous handlers like slash commands that will never send + // their own `thinking=true` keepalive). Normal queue drains still + // need the grace so the spinner doesn't flicker between the queue + // shift and `backend.prompt` start. + if (data.clearQueuedThinkingGrace === true) { + onMessagesConsumed?.(data.sid) + } // Emit only after the DB write succeeds. Otherwise a transient SQLite // failure would broadcast an `invokedAt` that was never persisted — // live clients would hide the queued rows while a refresh / secondary diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 153067ad..af7533e5 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -42,6 +42,7 @@ export type SocketServerDeps = { onBackgroundTaskDelta?: (sessionId: string, delta: { started: number; completed: number }) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void + onMessagesConsumed?: (sessionId: string) => void } export function createSocketServer(deps: SocketServerDeps): { @@ -120,7 +121,8 @@ export function createSocketServer(deps: SocketServerDeps): { onWebappEvent: deps.onWebappEvent, onBackgroundTaskDelta: deps.onBackgroundTaskDelta, onSessionActivity: deps.onSessionActivity, - onSweepImmediateQueued: deps.onSweepImmediateQueued + onSweepImmediateQueued: deps.onSweepImmediateQueued, + onMessagesConsumed: deps.onMessagesConsumed })) terminalNs.use(async (socket, next) => { diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index d645632b..2a07ad70 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -189,7 +189,8 @@ export async function startHub(options: StartHubOptions = {}): Promise syncEngine?.handleMachineAlive(payload), onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta), onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt), - onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now) + onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now), + onMessagesConsumed: (sessionId) => syncEngine?.clearQueuedThinkingGrace(sessionId) }) syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 7312e6b6..8006cc24 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -260,6 +260,22 @@ export class SessionCache { } } + /** + * Drop the queued-message thinking grace timer for a session. + * + * `markMessageQueued` sets a 15s grace during which we keep `thinking=true` + * even if the CLI sends `keepAlive(thinking=false)` — that grace exists to + * cover the gap between the user POSTing a prompt and the CLI starting to + * stream. Sessions that handle the message synchronously (e.g. slash + * commands intercepted in `onUserMessage`) never call onThinkingChange and + * would otherwise leave the spinner stuck for the full grace window. The + * messages-consumed socket event signals the CLI has finished its + * synchronous handling, so it's safe to drop the grace. + */ + clearQueuedThinkingGrace(sessionId: string): void { + this.pendingThinkingUntilBySessionId.delete(sessionId) + } + markMessageQueued(sessionId: string, time: number = Date.now()): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) if (!session) return diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 73c284a3..0dec1e66 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -291,6 +291,10 @@ export class SyncEngine { this.triggerDedupIfNeeded(payload.sid) } + clearQueuedThinkingGrace(sessionId: string): void { + this.sessionCache.clearQueuedThinkingGrace(sessionId) + } + handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void { this.sessionCache.handleSessionEnd(payload) this.eventPublisher.emit({ diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index b62c353f..fc752b71 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -32,7 +32,13 @@ export const BUILTIN_SLASH_COMMANDS = { { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, { name: 'stats', description: 'Check session stats', source: 'builtin' }, ], - opencode: [], + opencode: [ + { name: 'help', description: 'Show supported HAPI OpenCode slash commands', source: 'builtin' }, + { name: 'status', description: 'Show current OpenCode session config', source: 'builtin' }, + { name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' }, + { name: 'default', description: 'Return OpenCode permission mode to default', source: 'builtin' }, + { name: 'init', description: 'Generate or refresh AGENTS.md for this project', source: 'builtin' }, + ], cursor: [], } as const satisfies Record