feat(opencode): support custom agents and slash commands

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
This commit is contained in:
Mohamed Boudra
2026-04-03 22:15:44 +07:00
parent 176942bbf1
commit 255c2665cc
12 changed files with 377 additions and 6 deletions
+26 -1
View File
@@ -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 ?? [];
@@ -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<ListProviderModesPayload> {
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<ListAvailableProvidersPayload> {
@@ -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<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
/**
* Check if this provider is available (CLI binary is installed).
@@ -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<AgentModelDefinition[]>;
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
}
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,
];
}),
@@ -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");
@@ -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<void>((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);
});
@@ -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");
@@ -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<AgentMode[]> {
const { url } = await this.serverManager.ensureRunning();
const directory = options?.cwd ?? process.cwd();
const client = createOpencodeClient({ baseUrl: url, directory });
const timeoutPromise = new Promise<never>((_, 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<PersistedAgentDescriptor[]> {
@@ -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),
@@ -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<ReturnType<typeof createTestPaseoDaemon>>;
}> {
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,
);
});
+39
View File
@@ -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<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {
@@ -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);
+26
View File
@@ -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<typeof AgentDeletedMessageSchema>;
export type ListProviderModelsResponseMessage = z.infer<
typeof ListProviderModelsResponseMessageSchema
>;
export type ListProviderModesResponseMessage = z.infer<
typeof ListProviderModesResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
@@ -2425,6 +2448,9 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ListProviderModesRequestMessage = z.infer<
typeof ListProviderModesRequestMessageSchema
>;
export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema
>;