fix(opencode): support executable slash commands (#597)
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { useAgentCommandsQuery } from "./use-agent-commands-query";
|
||||||
|
|
||||||
|
const { mockClient, mockRuntime } = vi.hoisted(() => {
|
||||||
|
const hoistedClient = {
|
||||||
|
listCommands: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
mockClient: hoistedClient,
|
||||||
|
mockRuntime: {
|
||||||
|
client: hoistedClient,
|
||||||
|
isConnected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => mockRuntime.client,
|
||||||
|
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommandsHook(input: Parameters<typeof useAgentCommandsQuery>[0]) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
|
||||||
|
return renderHook(() => useAgentCommandsQuery(input), { wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useAgentCommandsQuery", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRuntime.client = mockClient;
|
||||||
|
mockRuntime.isConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads commands for a draft composer without an agent id", async () => {
|
||||||
|
mockClient.listCommands.mockResolvedValue({
|
||||||
|
commands: [{ name: "compact", description: "Compact context", argumentHint: "" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftConfig = {
|
||||||
|
provider: "opencode" as const,
|
||||||
|
cwd: "/repo",
|
||||||
|
modeId: "build",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderCommandsHook({
|
||||||
|
serverId: "server-1",
|
||||||
|
agentId: "",
|
||||||
|
draftConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.commands).toEqual([
|
||||||
|
{ name: "compact", description: "Compact context", argumentHint: "" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.listCommands).toHaveBeenCalledWith("", { draftConfig });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ export function useAgentCommandsQuery({
|
|||||||
const response = await client.listCommands(agentId, { draftConfig });
|
const response = await client.listCommands(agentId, { draftConfig });
|
||||||
return response.commands as AgentSlashCommand[];
|
return response.commands as AgentSlashCommand[];
|
||||||
},
|
},
|
||||||
enabled: enabled && !!client && isConnected && !!agentId,
|
enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||||
staleTime: COMMANDS_STALE_TIME,
|
staleTime: COMMANDS_STALE_TIME,
|
||||||
retry: 3,
|
retry: 3,
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||||
|
|||||||
+94
@@ -28,6 +28,100 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||||
|
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||||
|
session: {
|
||||||
|
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
connected: ["openai"],
|
||||||
|
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||||
|
acquire: vi.fn().mockResolvedValue({
|
||||||
|
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||||
|
release: vi.fn(),
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||||
|
|
||||||
|
await expect(session.listCommands?.()).resolves.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await expect(session.listCommands?.()).resolves.not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ name: "models", description: expect.any(String), argumentHint: "" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||||
|
const command = vi.fn();
|
||||||
|
const summarize = vi.fn().mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
|
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||||
|
session: {
|
||||||
|
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||||||
|
command,
|
||||||
|
summarize,
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
connected: ["openai"],
|
||||||
|
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
subscribe: vi.fn().mockResolvedValue({
|
||||||
|
stream: (async function* () {})(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
list: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
agents: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||||
|
acquire: vi.fn().mockResolvedValue({
|
||||||
|
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||||
|
release: vi.fn(),
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||||
|
|
||||||
|
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||||
|
sessionId: "session-1",
|
||||||
|
finalText: "",
|
||||||
|
timeline: [],
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(summarize).toHaveBeenCalledWith({ sessionID: "session-1", directory: "/tmp" });
|
||||||
|
expect(command).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||||
const idleEventGate = createDeferred<void>();
|
const idleEventGate = createDeferred<void>();
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ type OpenCodeMcpConfig =
|
|||||||
|
|
||||||
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
|
||||||
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
|
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
|
||||||
|
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||||
|
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||||
|
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
|
||||||
|
];
|
||||||
const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
|
const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
|
||||||
"insufficient balance",
|
"insufficient balance",
|
||||||
"no resource package",
|
"no resource package",
|
||||||
@@ -2035,6 +2039,44 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||||
if (slashCommand) {
|
if (slashCommand) {
|
||||||
|
if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") {
|
||||||
|
void this.client.session
|
||||||
|
.summarize({
|
||||||
|
sessionID: this.sessionId,
|
||||||
|
directory: this.config.cwd,
|
||||||
|
...(model ? { providerID: model.providerID, modelID: model.modelID } : {}),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.error) {
|
||||||
|
this.finishForegroundTurn(
|
||||||
|
{
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: "opencode",
|
||||||
|
error: toDiagnosticErrorMessage(response.error),
|
||||||
|
},
|
||||||
|
turnId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.finishForegroundTurn(
|
||||||
|
{ type: "turn_completed", provider: "opencode", usage: undefined },
|
||||||
|
turnId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.finishForegroundTurn(
|
||||||
|
{
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: "opencode",
|
||||||
|
error: toDiagnosticErrorMessage(error),
|
||||||
|
},
|
||||||
|
turnId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { turnId };
|
||||||
|
}
|
||||||
|
|
||||||
// command() blocks until the server finishes processing. OpenCode's SSE
|
// command() blocks until the server finishes processing. OpenCode's SSE
|
||||||
// endpoint does NOT replay past events, so if the command completes before
|
// endpoint does NOT replay past events, so if the command completes before
|
||||||
// our SSE reader connects, we miss `session.idle` and the turn hangs.
|
// our SSE reader connects, we miss `session.idle` and the turn hangs.
|
||||||
@@ -2386,14 +2428,22 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const result = await this.client.command.list({
|
const result = await this.client.command.list({
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const commandsByName = new Map(
|
||||||
|
OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, command]),
|
||||||
|
);
|
||||||
if (result.error || !result.data) {
|
if (result.error || !result.data) {
|
||||||
return [];
|
return Array.from(commandsByName.values());
|
||||||
}
|
}
|
||||||
return result.data.map((cmd) => ({
|
|
||||||
name: cmd.name,
|
for (const cmd of result.data) {
|
||||||
description: cmd.description ?? "",
|
commandsByName.set(cmd.name, {
|
||||||
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
name: cmd.name,
|
||||||
}));
|
description: cmd.description ?? "",
|
||||||
|
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Array.from(commandsByName.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMode(modeId: string): Promise<void> {
|
async setMode(modeId: string): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user