mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,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' });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
Reference in New Issue
Block a user