fix(server): implement slash command support for OpenCode harness (#169)
Add listCommands() to OpenCodeAgentSession via the OpenCode SDK's command.list() API, and route recognized /commands through session.command() instead of promptAsync(). This fixes issue #168 where slash commands didn't load when OpenCode was the selected harness.
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
|
||||||
|
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
|
||||||
|
|
||||||
|
describe("opencode agent commands E2E", () => {
|
||||||
|
let ctx: DaemonTestContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await createDaemonTestContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await ctx.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
test("lists available slash commands for an opencode agent", async () => {
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
...getFullAccessConfig("opencode"),
|
||||||
|
cwd: "/tmp",
|
||||||
|
title: "OpenCode Commands Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toBeTruthy();
|
||||||
|
expect(agent.provider).toBe("opencode");
|
||||||
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
|
const result = await ctx.client.listCommands(agent.id);
|
||||||
|
|
||||||
|
expect(result.error).toBeNull();
|
||||||
|
expect(result.commands.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const cmd of result.commands) {
|
||||||
|
expect(cmd.name).toBeTruthy();
|
||||||
|
expect(typeof cmd.description).toBe("string");
|
||||||
|
expect(typeof cmd.argumentHint).toBe("string");
|
||||||
|
expect(cmd.name.startsWith("/")).toBe(false);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
test("returns error for non-existent agent", async () => {
|
||||||
|
const result = await ctx.client.listCommands("non-existent-agent-id");
|
||||||
|
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(result.error).toContain("Agent not found");
|
||||||
|
expect(result.commands).toEqual([]);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||||
|
import type { AgentSlashCommand } from "../agent-sdk-types.js";
|
||||||
|
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||||
|
|
||||||
|
describe("opencode agent commands contract (real)", () => {
|
||||||
|
test("lists slash commands with the expected contract", async () => {
|
||||||
|
expect(isCommandAvailable("opencode")).toBe(true);
|
||||||
|
|
||||||
|
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
modeId: "plan",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(typeof session.listCommands).toBe("function");
|
||||||
|
const commands = await session.listCommands!();
|
||||||
|
|
||||||
|
expect(Array.isArray(commands)).toBe(true);
|
||||||
|
expect(commands.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
const typed = command as AgentSlashCommand;
|
||||||
|
expect(typeof typed.name).toBe("string");
|
||||||
|
expect(typed.name.length).toBeGreaterThan(0);
|
||||||
|
expect(typed.name.startsWith("/")).toBe(false);
|
||||||
|
expect(typeof typed.description).toBe("string");
|
||||||
|
expect(typeof typed.argumentHint).toBe("string");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
|
AgentSlashCommand,
|
||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
@@ -445,7 +446,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
throw new Error("OpenCode session creation returned no data");
|
throw new Error("OpenCode session creation returned no data");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OpenCodeAgentSession(openCodeConfig, client, session.id);
|
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeSession(
|
async resumeSession(
|
||||||
@@ -470,7 +471,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
directory: openCodeConfig.cwd,
|
directory: openCodeConfig.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId);
|
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
@@ -975,6 +976,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private readonly config: OpenCodeAgentConfig;
|
private readonly config: OpenCodeAgentConfig;
|
||||||
private readonly client: OpencodeClient;
|
private readonly client: OpencodeClient;
|
||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
private currentMode: string = "default";
|
private currentMode: string = "default";
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -994,10 +996,16 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
|
|
||||||
constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) {
|
constructor(
|
||||||
|
config: OpenCodeAgentConfig,
|
||||||
|
client: OpencodeClient,
|
||||||
|
sessionId: string,
|
||||||
|
logger: Logger,
|
||||||
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
|
this.logger = logger;
|
||||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,23 +1137,37 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
|
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
|
||||||
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
||||||
|
|
||||||
const promptResponse = await this.client.session.promptAsync({
|
let promptResponse;
|
||||||
sessionID: this.sessionId,
|
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||||
directory: this.config.cwd,
|
if (slashCommand) {
|
||||||
parts,
|
promptResponse = await this.client.session.command({
|
||||||
...(options?.outputSchema
|
sessionID: this.sessionId,
|
||||||
? {
|
directory: this.config.cwd,
|
||||||
format: {
|
command: slashCommand.commandName,
|
||||||
type: "json_schema" as const,
|
arguments: slashCommand.args,
|
||||||
schema: options.outputSchema as Record<string, unknown>,
|
...(this.config.model ? { model: this.config.model } : {}),
|
||||||
},
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
}
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
: {}),
|
});
|
||||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
} else {
|
||||||
...(model ? { model } : {}),
|
promptResponse = await this.client.session.promptAsync({
|
||||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
sessionID: this.sessionId,
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
directory: this.config.cwd,
|
||||||
});
|
parts,
|
||||||
|
...(options?.outputSchema
|
||||||
|
? {
|
||||||
|
format: {
|
||||||
|
type: "json_schema" as const,
|
||||||
|
schema: options.outputSchema as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (promptResponse.error) {
|
if (promptResponse.error) {
|
||||||
const errorMsg = JSON.stringify(promptResponse.error);
|
const errorMsg = JSON.stringify(promptResponse.error);
|
||||||
@@ -1335,6 +1357,20 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return this.currentMode;
|
return this.currentMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||||
|
const result = await this.client.command.list({
|
||||||
|
directory: this.config.cwd,
|
||||||
|
});
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return result.data.map((cmd) => ({
|
||||||
|
name: cmd.name,
|
||||||
|
description: cmd.description ?? "",
|
||||||
|
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async setMode(modeId: string): Promise<void> {
|
async setMode(modeId: string): Promise<void> {
|
||||||
this.currentMode = normalizeOpenCodeModeId(modeId);
|
this.currentMode = normalizeOpenCodeModeId(modeId);
|
||||||
}
|
}
|
||||||
@@ -1418,6 +1454,45 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
.map((p) => ({ type: "text", text: p.text }));
|
.map((p) => ({ type: "text", text: p.text }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const withoutPrefix = trimmed.slice(1);
|
||||||
|
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
|
||||||
|
const commandName =
|
||||||
|
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
|
||||||
|
if (!commandName || commandName.includes("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawArgs =
|
||||||
|
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
|
||||||
|
return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSlashCommandInvocation(
|
||||||
|
prompt: AgentPromptInput,
|
||||||
|
): Promise<{ commandName: string; args?: string } | null> {
|
||||||
|
if (typeof prompt !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = this.parseSlashCommandInput(prompt);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const commands = await this.listCommands();
|
||||||
|
return commands.some((command) => command.name === parsed.commandName) ? parsed : null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, commandName: parsed.commandName },
|
||||||
|
"Failed to resolve slash command; falling back to plain prompt input",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private parseModel(model?: string): { providerID: string; modelID: string } | undefined {
|
private parseModel(model?: string): { providerID: string; modelID: string } | undefined {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user