diff --git a/packages/app/src/hooks/use-agent-commands-query.test.ts b/packages/app/src/hooks/use-agent-commands-query.test.ts new file mode 100644 index 000000000..4bcd288cc --- /dev/null +++ b/packages/app/src/hooks/use-agent-commands-query.test.ts @@ -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[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 }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 5d39ba65d..7f45c615f 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -58,7 +58,7 @@ export function useAgentCommandsQuery({ const response = await client.listCommands(agentId, { draftConfig }); return response.commands as AgentSlashCommand[]; }, - enabled: enabled && !!client && isConnected && !!agentId, + enabled: enabled && !!client && isConnected && (!!agentId || !!draftConfig), staleTime: COMMANDS_STALE_TIME, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts index 02636f158..0670de6d4 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -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(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 61c713953..2a1d649a6 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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 {