From 255c2665ccd88c3437ce1c210f9695050ef482ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Apr 2026 22:15:44 +0700 Subject: [PATCH] feat(opencode): support custom agents and slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the hardcoded mode whitelist so user-defined agents (from opencode.json or global config) appear in the mode picker. Add a listProviderModes API (client → session → provider) so the draft agent form fetches dynamic modes instead of relying on the static manifest. Fix slash command dispatch to accept optional arguments. Closes #185 --- .../app/src/hooks/use-agent-form-state.ts | 27 +++++- packages/server/src/client/daemon-client.ts | 18 ++++ .../src/server/agent/agent-sdk-types.ts | 5 ++ .../src/server/agent/provider-registry.ts | 7 ++ .../opencode-agent-commands.e2e.test.ts | 52 ++++++++++++ .../opencode-agent-commands.real.e2e.test.ts | 47 +++++++++++ .../agent/providers/opencode-agent.test.ts | 36 ++++++++ .../server/agent/providers/opencode-agent.ts | 41 ++++++++- .../opencode-custom-agents.real.e2e.test.ts | 83 +++++++++++++++++++ packages/server/src/server/session.ts | 39 +++++++++ .../server/test-utils/fake-agent-client.ts | 2 +- packages/server/src/shared/messages.ts | 26 ++++++ 12 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index bec3f0392..badebe2b6 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -444,6 +444,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const availableModels = providerModelsQuery.data ?? null; + const providerModesQuery = useQuery({ + queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd], + enabled: Boolean( + isVisible && + isTargetDaemonReady && + formState.serverId && + client && + isConnected && + providerDefinitionMap.has(formState.provider), + ), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModes(formState.provider, { + cwd: debouncedCwd, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.modes ?? []; + }, + }); + const allProviderModelQueries = useQueries({ queries: providerDefinitions.map((def) => ({ queryKey: ["providerModels", formState.serverId, def.id], @@ -722,7 +747,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ]); const agentDefinition = providerDefinitionMap.get(formState.provider); - const modeOptions = agentDefinition?.modes ?? []; + const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? []; const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 8f1c1178e..4d6f0e2a2 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -38,6 +38,7 @@ import type { ArchiveWorkspaceResponseMessage, ListCommandsResponse, ListProviderModelsResponseMessage, + ListProviderModesResponseMessage, ListAvailableProvidersResponse, ListTerminalsResponse, CreateTerminalResponse, @@ -216,6 +217,7 @@ type CreatePaseoWorktreePayload = Extract< type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; +type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< @@ -2474,6 +2476,22 @@ export class DaemonClient { }); } + async listProviderModes( + provider: AgentProvider, + options?: { cwd?: string; requestId?: string }, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "list_provider_modes_request", + provider, + cwd: options?.cwd, + }, + responseType: "list_provider_modes_response", + timeout: 45000, + }); + } + async listAvailableProviders(options?: { requestId?: string; }): Promise { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index a4440aa81..8fad606fa 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -414,6 +414,10 @@ export interface ListModelsOptions { cwd?: string; } +export interface ListModesOptions { + cwd?: string; +} + export interface AgentClient { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; @@ -427,6 +431,7 @@ export interface AgentClient { launchContext?: AgentLaunchContext, ): Promise; listModels(options?: ListModelsOptions): Promise; + listModes?(options?: ListModesOptions): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; /** * Check if this provider is available (CLI binary is installed). diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 34d646eff..10c534d74 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,8 +1,10 @@ import type { AgentClient, + AgentMode, AgentModelDefinition, AgentProvider, ListModelsOptions, + ListModesOptions, } from "./agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js"; import type { Logger } from "pino"; @@ -24,6 +26,7 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { createClient: (logger: Logger) => AgentClient; fetchModels: (options?: ListModelsOptions) => Promise; + fetchModes: (options?: ListModesOptions) => Promise; } type BuildProviderRegistryOptions = { @@ -69,6 +72,10 @@ export function buildProviderRegistry( ...definition, createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings), fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions), + fetchModes: (listOptions?: ListModesOptions) => + modelClient.listModes + ? modelClient.listModes(listOptions) + : Promise.resolve(definition.modes), } satisfies ProviderDefinition, ]; }), 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 index 28431cc28..13ef380d0 100644 --- 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 @@ -37,6 +37,58 @@ describe("opencode agent commands E2E", () => { } }, 60_000); + test("sendMessage executes a slash command without arguments", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "OpenCode Slash Command No Args", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.provider).toBe("opencode"); + + await ctx.client.sendMessage(agent.id, "/help"); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain("PASEO_SKILL_OK"); + }, 30_000); + + test("sendMessage executes a slash command with arguments", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "OpenCode Slash Command With Args", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.provider).toBe("opencode"); + + await ctx.client.sendMessage(agent.id, "/help some-topic"); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain("PASEO_SKILL_OK"); + }, 30_000); + + test("sendMessage keeps unknown slash input as plain prompt text", async () => { + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("opencode"), + cwd: "/tmp", + title: "OpenCode Slash Fallback", + }); + + const token = `RAW_PROMPT_TOKEN_${Date.now()}`; + await ctx.client.sendMessage( + agent.id, + `/not-a-real-command respond with exactly: ${token}`, + ); + const state = await ctx.client.waitForFinish(agent.id, 30_000); + + expect(state.status).toBe("idle"); + expect(state.lastMessage).toContain(token); + }, 30_000); + test("returns error for non-existent agent", async () => { const result = await ctx.client.listCommands("non-existent-agent-id"); 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 index 9fbf2566b..dcc2b97ea 100644 --- 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 @@ -35,4 +35,51 @@ describe("opencode agent commands contract (real)", () => { await session.close(); } }, 60_000); + + test("executes a slash command without arguments", async () => { + expect(isCommandAvailable("opencode")).toBe(true); + + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "default", + }); + + try { + const commands = await session.listCommands!(); + expect(commands.length).toBeGreaterThan(0); + + // Pick the first available command and send it without arguments. + const command = commands[0]!; + const events: Array<{ type: string }> = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn(`/${command.name}`); + expect(turnId).toBeTruthy(); + + // Wait for the turn to complete or fail. + await new Promise((resolve) => { + const unsub = session.subscribe((event) => { + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + unsub(); + resolve(); + } + }); + }); + + // The turn should not have failed with an "invalid_type" error on arguments. + const failEvent = events.find((e) => e.type === "turn_failed"); + if (failEvent && "error" in failEvent) { + expect(String(failEvent.error)).not.toContain("invalid_type"); + expect(String(failEvent.error)).not.toContain("expected string, received undefined"); + } + } finally { + await session.close(); + } + }, 60_000); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index d17a29424..b894292e5 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -219,6 +219,42 @@ const hasOpenCode = isBinaryInstalled("opencode"); rmSync(cwd, { recursive: true, force: true }); }, 60_000); + test("custom agents defined in opencode.json appear in available modes", async () => { + const cwd = tmpCwd(); + writeFileSync( + path.join(cwd, "opencode.json"), + JSON.stringify({ + agent: { + "paseo-test-custom": { + description: "Custom agent defined for Paseo integration test", + mode: "primary", + }, + }, + }), + ); + + const client = new OpenCodeAgentClient(logger); + const session = await client.createSession(buildConfig(cwd)); + + const modes = await session.getAvailableModes(); + + expect(modes.some((mode) => mode.id === "build")).toBe(true); + expect(modes.some((mode) => mode.id === "plan")).toBe(true); + + const custom = modes.find((mode) => mode.id === "paseo-test-custom"); + expect(custom).toBeDefined(); + expect(custom!.label).toBe("Paseo-test-custom"); + expect(custom!.description).toBe("Custom agent defined for Paseo integration test"); + + // System agents should not appear as selectable modes + expect(modes.some((mode) => mode.id === "compaction")).toBe(false); + expect(modes.some((mode) => mode.id === "summary")).toBe(false); + expect(modes.some((mode) => mode.id === "title")).toBe(false); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, 60_000); + test("plan mode blocks edits while build mode can write files", async () => { const cwd = tmpCwd(); const planFile = path.join(cwd, "plan-mode-output.txt"); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9f6a26749..b9c6f75d8 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -26,6 +26,7 @@ import type { AgentTimelineItem, AgentUsage, ListModelsOptions, + ListModesOptions, ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, @@ -63,8 +64,6 @@ const DEFAULT_MODES: AgentMode[] = [ }, ]; -const OPENCODE_MODE_IDS = new Set(DEFAULT_MODES.map((mode) => mode.id)); - type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; type OpenCodeMessageRole = "user" | "assistant"; @@ -563,6 +562,41 @@ export class OpenCodeAgentClient implements AgentClient { return models; } + async listModes(options?: ListModesOptions): Promise { + const { url } = await this.serverManager.ensureRunning(); + const directory = options?.cwd ?? process.cwd(); + const client = createOpencodeClient({ baseUrl: url, directory }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("OpenCode app.agents timed out after 10s")), + 10_000, + ); + }); + + const response = await Promise.race([ + client.app.agents({ directory }), + timeoutPromise, + ]); + + if (response.error || !response.data) { + return DEFAULT_MODES; + } + + const discovered = response.data + .filter((agent) => agent.mode === "primary" && agent.hidden !== true) + .map((agent) => ({ + id: agent.name, + label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1), + description: + typeof agent.description === "string" && agent.description.trim().length > 0 + ? agent.description.trim() + : DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description, + })); + + return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES; + } + async listPersistedAgents( _options?: ListPersistedAgentsOptions, ): Promise { @@ -1160,7 +1194,7 @@ class OpenCodeAgentSession implements AgentSession { sessionID: this.sessionId, directory: this.config.cwd, command: slashCommand.commandName, - arguments: slashCommand.args, + arguments: slashCommand.args ?? "", ...(this.config.model ? { model: this.config.model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), @@ -1432,7 +1466,6 @@ class OpenCodeAgentSession implements AgentSession { ? [] : response.data .filter((agent) => agent.mode === "primary" && agent.hidden !== true) - .filter((agent) => OPENCODE_MODE_IDS.has(agent.name)) .map((agent) => ({ id: agent.name, label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1), diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts new file mode 100644 index 000000000..190d8e189 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -0,0 +1,83 @@ +import { describe, test, expect } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-custom-")); +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - custom agent discovery", () => { + test.runIf(isProviderAvailable("opencode"))( + "custom agents defined in opencode.json appear in availableModes", + async () => { + const cwd = tmpCwd(); + writeFileSync( + path.join(cwd, "opencode.json"), + JSON.stringify({ + agent: { + "paseo-e2e-custom": { + description: "Custom agent for Paseo daemon E2E test", + mode: "primary", + }, + }, + }), + ); + + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode custom agent discovery", + }); + + // Wait for modes to be populated (they arrive after session init) + const snapshot = await client.waitForAgentUpsert( + agent.id, + (s) => s.availableModes.length > 0, + 30_000, + ); + + expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true); + expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true); + + const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom"); + expect(custom).toBeDefined(); + expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test"); + + // System agents should not leak through + expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false); + expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9161bd975..741ac6e6a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1661,6 +1661,10 @@ export class Session { await this.handleListProviderModelsRequest(msg); break; + case "list_provider_modes_request": + await this.handleListProviderModesRequest(msg); + break; + case "list_available_providers_request": await this.handleListAvailableProvidersRequest(msg); break; @@ -3042,6 +3046,41 @@ export class Session { } } + private async handleListProviderModesRequest( + msg: Extract, + ): Promise { + const fetchedAt = new Date().toISOString(); + try { + const modes = await this.providerRegistry[msg.provider].fetchModes({ + cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, + }); + this.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + modes, + error: null, + fetchedAt, + requestId: msg.requestId, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, provider: msg.provider }, + `Failed to list modes for ${msg.provider}`, + ); + this.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + error: (error as Error)?.message ?? String(error), + fetchedAt, + requestId: msg.requestId, + }, + }); + } + } + private async handleListAvailableProvidersRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index b873de66d..a2b53368e 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -243,7 +243,7 @@ class FakeAgentSession implements AgentSession { private async resolveSlashCommandInput( prompt: AgentPromptInput, ): Promise<{ commandName: string; args?: string } | null> { - if (this.providerName !== "codex" || typeof prompt !== "string") { + if ((this.providerName !== "codex" && this.providerName !== "opencode") || typeof prompt !== "string") { return null; } const parsed = this.parseSlashCommandInput(prompt); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 55ffa15af..5b9771058 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -728,6 +728,13 @@ export const ListProviderModelsRequestMessageSchema = z.object({ requestId: z.string(), }); +export const ListProviderModesRequestMessageSchema = z.object({ + type: z.literal("list_provider_modes_request"), + provider: AgentProviderSchema, + cwd: z.string().optional(), + requestId: z.string(), +}); + export const ListAvailableProvidersRequestMessageSchema = z.object({ type: z.literal("list_available_providers_request"), requestId: z.string(), @@ -1208,6 +1215,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ DictationStreamCancelMessageSchema, CreateAgentRequestMessageSchema, ListProviderModelsRequestMessageSchema, + ListProviderModesRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -2112,6 +2120,17 @@ export const ListProviderModelsResponseMessageSchema = z.object({ }), }); +export const ListProviderModesResponseMessageSchema = z.object({ + type: z.literal("list_provider_modes_response"), + payload: z.object({ + provider: AgentProviderSchema, + modes: z.array(AgentModeSchema).optional(), + error: z.string().nullable().optional(), + fetchedAt: z.string(), + requestId: z.string(), + }), +}); + const ProviderAvailabilitySchema = z.object({ provider: AgentProviderSchema, available: z.boolean(), @@ -2317,6 +2336,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectIconResponseSchema, FileDownloadTokenResponseSchema, ListProviderModelsResponseMessageSchema, + ListProviderModesResponseMessageSchema, ListAvailableProvidersResponseSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, @@ -2386,6 +2406,9 @@ export type AgentDeletedMessage = z.infer; export type ListProviderModelsResponseMessage = z.infer< typeof ListProviderModelsResponseMessageSchema >; +export type ListProviderModesResponseMessage = z.infer< + typeof ListProviderModesResponseMessageSchema +>; export type ListAvailableProvidersResponse = z.infer; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; @@ -2425,6 +2448,9 @@ export type CreateAgentRequestMessage = z.infer; +export type ListProviderModesRequestMessage = z.infer< + typeof ListProviderModesRequestMessageSchema +>; export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >;