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:
@@ -444,6 +444,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
|
|
||||||
const availableModels = providerModelsQuery.data ?? null;
|
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({
|
const allProviderModelQueries = useQueries({
|
||||||
queries: providerDefinitions.map((def) => ({
|
queries: providerDefinitions.map((def) => ({
|
||||||
queryKey: ["providerModels", formState.serverId, def.id],
|
queryKey: ["providerModels", formState.serverId, def.id],
|
||||||
@@ -722,7 +747,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||||
const modeOptions = agentDefinition?.modes ?? [];
|
const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? [];
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
ArchiveWorkspaceResponseMessage,
|
ArchiveWorkspaceResponseMessage,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
ListProviderModelsResponseMessage,
|
ListProviderModelsResponseMessage,
|
||||||
|
ListProviderModesResponseMessage,
|
||||||
ListAvailableProvidersResponse,
|
ListAvailableProvidersResponse,
|
||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
@@ -216,6 +217,7 @@ type CreatePaseoWorktreePayload = Extract<
|
|||||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
|
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
||||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
type ListCommandsDraftConfig = Pick<
|
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?: {
|
async listAvailableProviders(options?: {
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}): Promise<ListAvailableProvidersPayload> {
|
}): Promise<ListAvailableProvidersPayload> {
|
||||||
|
|||||||
@@ -414,6 +414,10 @@ export interface ListModelsOptions {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListModesOptions {
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentClient {
|
export interface AgentClient {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
@@ -427,6 +431,7 @@ export interface AgentClient {
|
|||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
|
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
|
||||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||||
/**
|
/**
|
||||||
* Check if this provider is available (CLI binary is installed).
|
* Check if this provider is available (CLI binary is installed).
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
|
ListModesOptions,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
|
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -24,6 +26,7 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
|||||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||||
createClient: (logger: Logger) => AgentClient;
|
createClient: (logger: Logger) => AgentClient;
|
||||||
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||||
|
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuildProviderRegistryOptions = {
|
type BuildProviderRegistryOptions = {
|
||||||
@@ -69,6 +72,10 @@ export function buildProviderRegistry(
|
|||||||
...definition,
|
...definition,
|
||||||
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
|
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
|
||||||
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
|
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
|
||||||
|
fetchModes: (listOptions?: ListModesOptions) =>
|
||||||
|
modelClient.listModes
|
||||||
|
? modelClient.listModes(listOptions)
|
||||||
|
: Promise.resolve(definition.modes),
|
||||||
} satisfies ProviderDefinition,
|
} satisfies ProviderDefinition,
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -37,6 +37,58 @@ describe("opencode agent commands E2E", () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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 () => {
|
test("returns error for non-existent agent", async () => {
|
||||||
const result = await ctx.client.listCommands("non-existent-agent-id");
|
const result = await ctx.client.listCommands("non-existent-agent-id");
|
||||||
|
|
||||||
|
|||||||
@@ -35,4 +35,51 @@ describe("opencode agent commands contract (real)", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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 });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60_000);
|
}, 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 () => {
|
test("plan mode blocks edits while build mode can write files", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const planFile = path.join(cwd, "plan-mode-output.txt");
|
const planFile = path.join(cwd, "plan-mode-output.txt");
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
|
ListModesOptions,
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
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 OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" };
|
||||||
type OpenCodeMessageRole = "user" | "assistant";
|
type OpenCodeMessageRole = "user" | "assistant";
|
||||||
|
|
||||||
@@ -563,6 +562,41 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
return models;
|
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(
|
async listPersistedAgents(
|
||||||
_options?: ListPersistedAgentsOptions,
|
_options?: ListPersistedAgentsOptions,
|
||||||
): Promise<PersistedAgentDescriptor[]> {
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
@@ -1160,7 +1194,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
command: slashCommand.commandName,
|
command: slashCommand.commandName,
|
||||||
arguments: slashCommand.args,
|
arguments: slashCommand.args ?? "",
|
||||||
...(this.config.model ? { model: this.config.model } : {}),
|
...(this.config.model ? { model: this.config.model } : {}),
|
||||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
@@ -1432,7 +1466,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
? []
|
? []
|
||||||
: response.data
|
: response.data
|
||||||
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
||||||
.filter((agent) => OPENCODE_MODE_IDS.has(agent.name))
|
|
||||||
.map((agent) => ({
|
.map((agent) => ({
|
||||||
id: agent.name,
|
id: agent.name,
|
||||||
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1661,6 +1661,10 @@ export class Session {
|
|||||||
await this.handleListProviderModelsRequest(msg);
|
await this.handleListProviderModelsRequest(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "list_provider_modes_request":
|
||||||
|
await this.handleListProviderModesRequest(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
case "list_available_providers_request":
|
case "list_available_providers_request":
|
||||||
await this.handleListAvailableProvidersRequest(msg);
|
await this.handleListAvailableProvidersRequest(msg);
|
||||||
break;
|
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(
|
private async handleListAvailableProvidersRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ class FakeAgentSession implements AgentSession {
|
|||||||
private async resolveSlashCommandInput(
|
private async resolveSlashCommandInput(
|
||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
): Promise<{ commandName: string; args?: string } | null> {
|
): 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;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = this.parseSlashCommandInput(prompt);
|
const parsed = this.parseSlashCommandInput(prompt);
|
||||||
|
|||||||
@@ -728,6 +728,13 @@ export const ListProviderModelsRequestMessageSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const ListAvailableProvidersRequestMessageSchema = z.object({
|
||||||
type: z.literal("list_available_providers_request"),
|
type: z.literal("list_available_providers_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -1208,6 +1215,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
DictationStreamCancelMessageSchema,
|
DictationStreamCancelMessageSchema,
|
||||||
CreateAgentRequestMessageSchema,
|
CreateAgentRequestMessageSchema,
|
||||||
ListProviderModelsRequestMessageSchema,
|
ListProviderModelsRequestMessageSchema,
|
||||||
|
ListProviderModesRequestMessageSchema,
|
||||||
ListAvailableProvidersRequestMessageSchema,
|
ListAvailableProvidersRequestMessageSchema,
|
||||||
ResumeAgentRequestMessageSchema,
|
ResumeAgentRequestMessageSchema,
|
||||||
RefreshAgentRequestMessageSchema,
|
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({
|
const ProviderAvailabilitySchema = z.object({
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
available: z.boolean(),
|
available: z.boolean(),
|
||||||
@@ -2317,6 +2336,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ProjectIconResponseSchema,
|
ProjectIconResponseSchema,
|
||||||
FileDownloadTokenResponseSchema,
|
FileDownloadTokenResponseSchema,
|
||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
|
ListProviderModesResponseMessageSchema,
|
||||||
ListAvailableProvidersResponseSchema,
|
ListAvailableProvidersResponseSchema,
|
||||||
ListCommandsResponseSchema,
|
ListCommandsResponseSchema,
|
||||||
ListTerminalsResponseSchema,
|
ListTerminalsResponseSchema,
|
||||||
@@ -2386,6 +2406,9 @@ export type AgentDeletedMessage = z.infer<typeof AgentDeletedMessageSchema>;
|
|||||||
export type ListProviderModelsResponseMessage = z.infer<
|
export type ListProviderModelsResponseMessage = z.infer<
|
||||||
typeof ListProviderModelsResponseMessageSchema
|
typeof ListProviderModelsResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
export type ListProviderModesResponseMessage = z.infer<
|
||||||
|
typeof ListProviderModesResponseMessageSchema
|
||||||
|
>;
|
||||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||||
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||||
@@ -2425,6 +2448,9 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
|
|||||||
export type ListProviderModelsRequestMessage = z.infer<
|
export type ListProviderModelsRequestMessage = z.infer<
|
||||||
typeof ListProviderModelsRequestMessageSchema
|
typeof ListProviderModelsRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
|
export type ListProviderModesRequestMessage = z.infer<
|
||||||
|
typeof ListProviderModesRequestMessageSchema
|
||||||
|
>;
|
||||||
export type ListAvailableProvidersRequestMessage = z.infer<
|
export type ListAvailableProvidersRequestMessage = z.infer<
|
||||||
typeof ListAvailableProvidersRequestMessageSchema
|
typeof ListAvailableProvidersRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user