fix(opencode): support executable slash commands (#597)

This commit is contained in:
Thanh Minh
2026-04-28 20:25:04 +08:00
committed by GitHub
parent 013e53881b
commit 3ba4d09294
4 changed files with 230 additions and 7 deletions
@@ -28,6 +28,100 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
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 () => {
const idleEventGate = createDeferred<void>();
@@ -99,6 +99,10 @@ type OpenCodeMcpConfig =
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
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 = [
"insufficient balance",
"no resource package",
@@ -2035,6 +2039,44 @@ class OpenCodeAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
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
// endpoint does NOT replay past events, so if the command completes before
// 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({
directory: this.config.cwd,
});
const commandsByName = new Map(
OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS.map((command) => [command.name, command]),
);
if (result.error || !result.data) {
return [];
return Array.from(commandsByName.values());
}
return result.data.map((cmd) => ({
name: cmd.name,
description: cmd.description ?? "",
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
}));
for (const cmd of result.data) {
commandsByName.set(cmd.name, {
name: cmd.name,
description: cmd.description ?? "",
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
});
}
return Array.from(commandsByName.values());
}
async setMode(modeId: string): Promise<void> {