diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts new file mode 100644 index 000000000..28431cc28 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts @@ -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); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts new file mode 100644 index 000000000..9fbf2566b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.real.e2e.test.ts @@ -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); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7ac1811dd..e6d35cf5f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -21,6 +21,7 @@ import type { AgentRuntimeInfo, AgentSession, AgentSessionConfig, + AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, AgentUsage, @@ -445,7 +446,7 @@ export class OpenCodeAgentClient implements AgentClient { 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( @@ -470,7 +471,7 @@ export class OpenCodeAgentClient implements AgentClient { directory: openCodeConfig.cwd, }); - return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId); + return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger); } async listModels(options?: ListModelsOptions): Promise { @@ -975,6 +976,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly config: OpenCodeAgentConfig; private readonly client: OpencodeClient; private readonly sessionId: string; + private readonly logger: Logger; private currentMode: string = "default"; private pendingPermissions = new Map(); private abortController: AbortController | null = null; @@ -994,10 +996,16 @@ class OpenCodeAgentSession implements AgentSession { private nextTurnOrdinal = 0; 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.client = client; this.sessionId = sessionId; + this.logger = logger; this.currentMode = normalizeOpenCodeModeId(config.modeId); } @@ -1129,23 +1137,37 @@ class OpenCodeAgentSession implements AgentSession { thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined; const effectiveMode = normalizeOpenCodeModeId(this.currentMode); - const promptResponse = await this.client.session.promptAsync({ - sessionID: this.sessionId, - directory: this.config.cwd, - parts, - ...(options?.outputSchema - ? { - format: { - type: "json_schema" as const, - schema: options.outputSchema as Record, - }, - } - : {}), - ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}), - ...(model ? { model } : {}), - ...(effectiveMode ? { agent: effectiveMode } : {}), - ...(effectiveVariant ? { variant: effectiveVariant } : {}), - }); + let promptResponse; + const slashCommand = await this.resolveSlashCommandInvocation(prompt); + if (slashCommand) { + promptResponse = await this.client.session.command({ + sessionID: this.sessionId, + directory: this.config.cwd, + command: slashCommand.commandName, + arguments: slashCommand.args, + ...(this.config.model ? { model: this.config.model } : {}), + ...(effectiveMode ? { agent: effectiveMode } : {}), + ...(effectiveVariant ? { variant: effectiveVariant } : {}), + }); + } else { + promptResponse = await this.client.session.promptAsync({ + sessionID: this.sessionId, + directory: this.config.cwd, + parts, + ...(options?.outputSchema + ? { + format: { + type: "json_schema" as const, + schema: options.outputSchema as Record, + }, + } + : {}), + ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}), + ...(model ? { model } : {}), + ...(effectiveMode ? { agent: effectiveMode } : {}), + ...(effectiveVariant ? { variant: effectiveVariant } : {}), + }); + } if (promptResponse.error) { const errorMsg = JSON.stringify(promptResponse.error); @@ -1335,6 +1357,20 @@ class OpenCodeAgentSession implements AgentSession { return this.currentMode; } + async listCommands(): Promise { + 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 { this.currentMode = normalizeOpenCodeModeId(modeId); } @@ -1418,6 +1454,45 @@ class OpenCodeAgentSession implements AgentSession { .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 { if (!model) { return undefined;