From f9d1da9a0e4e8809a966b14e94bde7fe7f3ba7de Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 9 Mar 2026 18:33:58 +0000 Subject: [PATCH 1/4] agent: @U0AJM7X8FBR make a small change to /api README noting the Chat SDK inte --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/README.md b/README.md index 623b583f2..d6cc4fe70 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,56 @@ export const config = { }; ``` +## Chat SDK Integrations + +The API uses the [Chat SDK](../chat) to integrate with external platforms via a unified adapter pattern. + +### Architecture + +``` +Slack (events/messages) + └─▶ POST /api/coding-agent/slack ← Slack webhook (subscription events + interactivity) + └─▶ Chat SDK (SlackAdapter) + └─▶ Handler dispatches Trigger.dev task + └─▶ POST /api/coding-agent/callback ← Trigger.dev callback URL + └─▶ Posts result back to Slack thread + +GitHub (webhooks) + └─▶ PR events, repo operations + └─▶ lib/github/* helpers (file trees, submodules, repo management) +``` + +- **Slack adapter** — Receives messages via Slack's Events API, processes them through Chat SDK handlers, and triggers coding agent tasks via Trigger.dev. Results are posted back to the originating Slack thread. +- **GitHub integration** — Manages repo operations (file trees, submodules, PRs) used by the coding agent to create and update pull requests. +- **State** — Thread state (status, run IDs, PRs) is stored in Redis via the Chat SDK's ioredis state adapter. + +### Updating Testing/Dev URLs + +When deploying to a new environment (e.g. preview branches, local dev via ngrok), you need to update callback URLs in two places: + +#### Slack — Subscription Events & Interactivity + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → select your app +2. **Event Subscriptions** → Update the Request URL to: + ``` + https:///api/coding-agent/slack + ``` +3. **Interactivity & Shortcuts** → Update the Request URL to the same endpoint: + ``` + https:///api/coding-agent/slack + ``` +4. Slack will send a `url_verification` challenge — the route handles this automatically. + +#### Trigger.dev — Callback URL + +The coding agent task calls back to the API when it finishes. Update the callback URL environment variable so it points to your current deployment: + +``` +TRIGGER_CALLBACK_URL=https:///api/coding-agent/callback +``` + +Set this in your `.env` (local) or in your hosting provider's environment variables (Vercel, etc.). + ## Accessing Mainnet To access the mainnet facilitator in Next.js, simply install and use the `@coinbase/x402` package. \ No newline at end of file From b36709d7c9c9225011063a59f2d9d72ca9d3afd9 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 9 Mar 2026 18:36:09 +0000 Subject: [PATCH 2/4] fix: update Chat SDK link to https://github.com/vercel/chat --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d6cc4fe70..38caae24e 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ export const config = { ## Chat SDK Integrations -The API uses the [Chat SDK](../chat) to integrate with external platforms via a unified adapter pattern. +The API uses the [Chat SDK](https://github.com/vercel/chat) to integrate with external platforms via a unified adapter pattern. ### Architecture From ac5d4d0d99cde4e4944a876d0a1ce0facf6aef79 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 9 Mar 2026 19:38:36 +0000 Subject: [PATCH 3/4] agent: @U0AJM7X8FBR We want to remove the sandbox streaming tool from the API c --- .../__tests__/setupToolsForRequest.test.ts | 51 --- lib/chat/setupToolsForRequest.ts | 12 +- .../createPromptSandboxStreamingTool.test.ts | 283 ---------------- .../tools/createPromptSandboxStreamingTool.ts | 109 ------ ...t.ts => registerPromptSandboxTool.test.ts} | 66 +--- lib/mcp/tools/sandbox/index.ts | 5 +- ...ndTool.ts => registerPromptSandboxTool.ts} | 26 +- .../createSandboxPostHandler.test.ts | 57 ++-- .../__tests__/processCreateSandbox.test.ts | 79 +---- .../__tests__/promptSandboxStreaming.test.ts | 309 ------------------ lib/sandbox/createSandbox.ts | 2 +- lib/sandbox/createSandboxPostHandler.ts | 6 +- lib/sandbox/processCreateSandbox.ts | 33 +- lib/sandbox/promptSandboxStreaming.ts | 96 ------ lib/sandbox/validateSandboxBody.ts | 16 +- .../triggerRunSandboxCommand.test.ts | 46 --- lib/trigger/triggerPromptSandbox.ts | 23 ++ lib/trigger/triggerRunSandboxCommand.ts | 20 -- 18 files changed, 105 insertions(+), 1134 deletions(-) delete mode 100644 lib/chat/tools/__tests__/createPromptSandboxStreamingTool.test.ts delete mode 100644 lib/chat/tools/createPromptSandboxStreamingTool.ts rename lib/mcp/tools/sandbox/__tests__/{registerRunSandboxCommandTool.test.ts => registerPromptSandboxTool.test.ts} (76%) rename lib/mcp/tools/sandbox/{registerRunSandboxCommandTool.ts => registerPromptSandboxTool.ts} (67%) delete mode 100644 lib/sandbox/__tests__/promptSandboxStreaming.test.ts delete mode 100644 lib/sandbox/promptSandboxStreaming.ts delete mode 100644 lib/trigger/__tests__/triggerRunSandboxCommand.test.ts create mode 100644 lib/trigger/triggerPromptSandbox.ts delete mode 100644 lib/trigger/triggerRunSandboxCommand.ts diff --git a/lib/chat/__tests__/setupToolsForRequest.test.ts b/lib/chat/__tests__/setupToolsForRequest.test.ts index 633f83747..c5b4bbf24 100644 --- a/lib/chat/__tests__/setupToolsForRequest.test.ts +++ b/lib/chat/__tests__/setupToolsForRequest.test.ts @@ -10,13 +10,6 @@ vi.mock("@/lib/composio/toolRouter", () => ({ getComposioTools: vi.fn(), })); -vi.mock("@/lib/chat/tools/createPromptSandboxStreamingTool", () => ({ - createPromptSandboxStreamingTool: vi.fn(() => ({ - description: "Mock streaming sandbox tool", - parameters: {}, - })), -})); - // Import after mocks import { setupToolsForRequest } from "../setupToolsForRequest"; import { getMcpTools } from "@/lib/mcp/getMcpTools"; @@ -301,49 +294,5 @@ describe("setupToolsForRequest", () => { }); }); - describe("local streaming tool override", () => { - it("includes prompt_sandbox when authToken is provided", async () => { - const body: ChatRequestBody = { - accountId: "account-123", - orgId: null, - authToken: "test-token-123", - messages: [{ id: "1", role: "user", content: "Hello" }], - }; - - const result = await setupToolsForRequest(body); - expect(result).toHaveProperty("prompt_sandbox"); - }); - - it("overrides MCP prompt_sandbox with local streaming version", async () => { - mockGetMcpTools.mockResolvedValue({ - prompt_sandbox: { description: "MCP version", parameters: {} }, - }); - - const body: ChatRequestBody = { - accountId: "account-123", - orgId: null, - authToken: "test-token-123", - messages: [{ id: "1", role: "user", content: "Hello" }], - }; - - const result = await setupToolsForRequest(body); - - expect(result.prompt_sandbox).toEqual( - expect.objectContaining({ description: "Mock streaming sandbox tool" }), - ); - }); - - it("does not include prompt_sandbox when authToken is not provided", async () => { - const body: ChatRequestBody = { - accountId: "account-123", - orgId: null, - messages: [{ id: "1", role: "user", content: "Hello" }], - }; - - const result = await setupToolsForRequest(body); - - expect(result).not.toHaveProperty("prompt_sandbox"); - }); - }); }); diff --git a/lib/chat/setupToolsForRequest.ts b/lib/chat/setupToolsForRequest.ts index 71e029519..92afb5dd9 100644 --- a/lib/chat/setupToolsForRequest.ts +++ b/lib/chat/setupToolsForRequest.ts @@ -3,14 +3,12 @@ import { filterExcludedTools } from "./filterExcludedTools"; import { ChatRequestBody } from "./validateChatRequest"; import { getMcpTools } from "@/lib/mcp/getMcpTools"; import { getComposioTools } from "@/lib/composio/toolRouter"; -import { createPromptSandboxStreamingTool } from "@/lib/chat/tools/createPromptSandboxStreamingTool"; /** * Sets up and filters tools for a chat request. * Aggregates tools from: * - MCP server (via HTTP transport to /api/mcp for proper auth) * - Composio Tool Router (Google Sheets, Google Drive, Google Docs, TikTok) - * - Local streaming tools (override MCP versions for real-time output) * * @param body - The chat request body * @returns Filtered tool set ready for use @@ -24,18 +22,10 @@ export async function setupToolsForRequest(body: ChatRequestBody): Promise ({ - promptSandboxStreaming: (...args: unknown[]) => mockPromptSandboxStreaming(...args), -})); - -// Helper to drain an async iterable into yields + return value -async function drainGenerator(iterable: AsyncIterable) { - const yields: unknown[] = []; - for await (const value of iterable) { - yields.push(value); - } - return yields; -} - -describe("createPromptSandboxStreamingTool", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("yields booting → streaming → complete statuses in order", async () => { - const finalResult = { - sandboxId: "sbx_123", - stdout: "Hello world", - stderr: "", - exitCode: 0, - created: false, - }; - - async function* fakeStreaming() { - yield { data: "Hello ", stream: "stdout" as const }; - yield { data: "world", stream: "stdout" as const }; - return finalResult; - } - - mockPromptSandboxStreaming.mockReturnValue(fakeStreaming()); - - const tool = createPromptSandboxStreamingTool("acc_123", "api-key-123"); - const iterable = tool.execute!( - { prompt: "say hello" }, - { - abortSignal: new AbortController().signal, - toolCallId: "tc_1", - messages: [], - }, - ) as AsyncIterable; - - const yields = await drainGenerator(iterable); - - // First yield: booting - expect(yields[0]).toEqual({ - status: "booting", - output: "", - }); - - // Middle yields: streaming with accumulated stdout - expect(yields[1]).toEqual({ - status: "streaming", - output: "Hello ", - }); - expect(yields[2]).toEqual({ - status: "streaming", - output: "Hello world", - }); - - // Last yield: complete - expect(yields[3]).toEqual({ - status: "complete", - output: "Hello world", - stderr: "", - exitCode: 0, - }); - }); - - it("passes accountId, apiKey, and prompt to promptSandboxStreaming", async () => { - async function* fakeStreaming() { - return { - sandboxId: "sbx_123", - stdout: "", - stderr: "", - exitCode: 0, - created: false, - }; - } - - mockPromptSandboxStreaming.mockReturnValue(fakeStreaming()); - - const tool = createPromptSandboxStreamingTool("acc_456", "key_789"); - const iterable = tool.execute!( - { prompt: "do stuff" }, - { - abortSignal: new AbortController().signal, - toolCallId: "tc_2", - messages: [], - }, - ) as AsyncIterable; - - await drainGenerator(iterable); - - expect(mockPromptSandboxStreaming).toHaveBeenCalledWith({ - accountId: "acc_456", - apiKey: "key_789", - prompt: SANDBOX_PROMPT_NOTE + "\n\n" + "do stuff", - abortSignal: expect.any(AbortSignal), - }); - }); - - it("yields only stderr chunks in streaming status", async () => { - async function* fakeStreaming() { - yield { data: "warning!", stream: "stderr" as const }; - return { - sandboxId: "sbx_123", - stdout: "", - stderr: "warning!", - exitCode: 1, - created: false, - }; - } - - mockPromptSandboxStreaming.mockReturnValue(fakeStreaming()); - - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - const iterable = tool.execute!( - { prompt: "fail" }, - { - abortSignal: new AbortController().signal, - toolCallId: "tc_3", - messages: [], - }, - ) as AsyncIterable; - - const yields = await drainGenerator(iterable); - - // booting - expect(yields[0]).toEqual({ status: "booting", output: "" }); - - // streaming — stderr doesn't change output (only stdout does) - expect(yields[1]).toEqual({ status: "streaming", output: "" }); - - // complete — stderr is included - expect(yields[2]).toEqual({ - status: "complete", - output: "", - stderr: "warning!", - exitCode: 1, - }); - }); - - it("has the correct tool description and input schema", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("sandbox"); - expect(tool.inputSchema).toBeDefined(); - }); - - describe("sandbox prompt note", () => { - it("prepends SANDBOX_PROMPT_NOTE to the user prompt", async () => { - async function* fakeStreaming() { - return { - sandboxId: "sbx_123", - stdout: "", - stderr: "", - exitCode: 0, - created: false, - }; - } - - mockPromptSandboxStreaming.mockReturnValue(fakeStreaming()); - - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - const iterable = tool.execute!( - { prompt: "update the release" }, - { - abortSignal: new AbortController().signal, - toolCallId: "tc_note", - messages: [], - }, - ) as AsyncIterable; - - await drainGenerator(iterable); - - const calledPrompt = mockPromptSandboxStreaming.mock.calls[0][0].prompt; - expect(calledPrompt).toContain("push"); - expect(calledPrompt).toContain("main"); - expect(calledPrompt).toContain("orgs"); - expect(calledPrompt).toContain("update the release"); - }); - - it("exports SANDBOX_PROMPT_NOTE as a non-empty string", () => { - expect(typeof SANDBOX_PROMPT_NOTE).toBe("string"); - expect(SANDBOX_PROMPT_NOTE.length).toBeGreaterThan(0); - }); - }); - - describe("fresh sandbox (no snapshot)", () => { - it("includes runId and fromSnapshot in complete status", async () => { - async function* fakeStreaming() { - return { - sandboxId: "sbx_fresh", - stdout: "", - stderr: "", - exitCode: 0, - created: true, - fromSnapshot: false, - runId: "run_abc", - }; - } - - mockPromptSandboxStreaming.mockReturnValue(fakeStreaming()); - - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - const iterable = tool.execute!( - { prompt: "hello" }, - { - abortSignal: new AbortController().signal, - toolCallId: "tc_fresh", - messages: [], - }, - ) as AsyncIterable; - - const yields = await drainGenerator(iterable); - const complete = yields[yields.length - 1]; - - expect(complete).toEqual({ - status: "complete", - output: "", - stderr: "", - exitCode: 0, - fromSnapshot: false, - runId: "run_abc", - }); - }); - }); - - describe("description explains runId background task behavior", () => { - it("tells LLM not to interpret empty output when runId is present", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("runId"); - expect(tool.description).toContain("background task"); - expect(tool.description).toContain("do NOT"); - }); - - it("tells LLM not to poll task status", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("Do NOT automatically poll"); - }); - - it("tells LLM to let user know they can ask for status", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("ask you to check"); - }); - }); - - describe("description mentions release management", () => { - it("includes release management as a primary use case", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("release management"); - }); - - it("describes itself as the primary tool", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("primary tool"); - }); - - it("mentions RELEASE.md documents", () => { - const tool = createPromptSandboxStreamingTool("acc_1", "key_1"); - - expect(tool.description).toContain("RELEASE.md"); - }); - }); -}); diff --git a/lib/chat/tools/createPromptSandboxStreamingTool.ts b/lib/chat/tools/createPromptSandboxStreamingTool.ts deleted file mode 100644 index 8cddbb183..000000000 --- a/lib/chat/tools/createPromptSandboxStreamingTool.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { z } from "zod"; -import type { Tool } from "ai"; -import { promptSandboxStreaming } from "@/lib/sandbox/promptSandboxStreaming"; - -export const SANDBOX_PROMPT_NOTE = - "IMPORTANT: When you make changes to any files inside the orgs/ directory, " + - "always commit and push those changes directly to main so they are preserved and shared across sessions.\n\n" + - "IMPORTANT: When a prompt includes attached file URLs (e.g. from email attachments), " + - "always download the files first using curl and save them locally before referencing them. " + - "These URLs are temporary and expire after 1 hour. Never store the download URL directly in files — " + - "download the content, save it to the appropriate location in the repo, and reference the local path instead."; - -const promptSandboxSchema = z.object({ - prompt: z.string().min(1).describe("The prompt to send to OpenClaw running in the sandbox."), -}); - -interface SandboxStreamProgress { - status: "booting" | "streaming" | "complete"; - output: string; - stderr?: string; - exitCode?: number; - fromSnapshot?: boolean; - runId?: string; -} - -interface PromptSandboxFinalResult { - sandboxId: string; - stdout: string; - stderr: string; - exitCode: number; - created: boolean; - fromSnapshot: boolean; - runId?: string; -} - -/** - * Creates a local AI SDK generator tool that streams sandbox output to the UI. - * Overrides the MCP prompt_sandbox tool with real-time streaming support. - * - * @param accountId - The account ID for sandbox lookup - * @param apiKey - The API key passed as RECOUP_API_KEY to the sandbox - * @returns An AI SDK tool with generator-based execute function - */ -export function createPromptSandboxStreamingTool( - accountId: string, - apiKey: string, -): Tool, SandboxStreamProgress> { - return { - description: - "Send a prompt to the agent running in the artist's persistent sandbox environment. " + - "This is your primary tool — use it for release management (creating, updating, or reviewing releases), " + - "file operations, data analysis, content generation, and any multi-step task. " + - "The sandbox has skills for managing RELEASE.md documents, generating deliverables, and more. " + - "Reuses the account's existing running sandbox or creates one from the latest snapshot. " + - "Streams output in real-time. " + - "IMPORTANT: When the result contains a `runId`, it means the sandbox is being set up for the first time " + - "and the command was dispatched to a background task. The output will be empty because the task is still running. " + - "The UI automatically shows a live progress view for background tasks — do NOT summarize or interpret the empty output. " + - "Simply tell the user their request is being processed in the sandbox and the results will appear in the task progress view above. " + - "Do NOT automatically poll or check the task status — instead, let the user know they can ask you to check on it whenever they want.", - inputSchema: promptSandboxSchema, - execute: async function* ({ prompt }, { abortSignal }) { - yield { status: "booting" as const, output: "" }; - - const augmentedPrompt = SANDBOX_PROMPT_NOTE + "\n\n" + prompt; - - const gen = promptSandboxStreaming({ - accountId, - apiKey, - prompt: augmentedPrompt, - abortSignal, - }); - - let stdout = ""; - let finalResult: PromptSandboxFinalResult | undefined; - - while (true) { - const iterResult = await gen.next(); - - if (iterResult.done) { - finalResult = iterResult.value as PromptSandboxFinalResult; - break; - } - - const chunk = iterResult.value as { - data: string; - stream: "stdout" | "stderr"; - }; - - if (chunk.stream === "stdout") { - stdout += chunk.data; - } - - yield { status: "streaming" as const, output: stdout }; - } - - yield { - status: "complete" as const, - output: finalResult!.stdout, - stderr: finalResult!.stderr, - exitCode: finalResult!.exitCode, - ...(finalResult!.fromSnapshot === false && { fromSnapshot: false }), - ...(finalResult!.runId && { runId: finalResult!.runId }), - }; - - return finalResult as never; - }, - }; -} diff --git a/lib/mcp/tools/sandbox/__tests__/registerRunSandboxCommandTool.test.ts b/lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts similarity index 76% rename from lib/mcp/tools/sandbox/__tests__/registerRunSandboxCommandTool.test.ts rename to lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts index e03ded65e..1b344d256 100644 --- a/lib/mcp/tools/sandbox/__tests__/registerRunSandboxCommandTool.test.ts +++ b/lib/mcp/tools/sandbox/__tests__/registerPromptSandboxTool.test.ts @@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; -import { registerRunSandboxCommandTool } from "../registerRunSandboxCommandTool"; +import { registerPromptSandboxTool } from "../registerPromptSandboxTool"; const mockProcessCreateSandbox = vi.fn(); const mockResolveAccountId = vi.fn(); @@ -44,7 +44,7 @@ function createMockExtra(authInfo?: { } as unknown as ServerRequestHandlerExtra; } -describe("registerRunSandboxCommandTool", () => { +describe("registerPromptSandboxTool", () => { let mockServer: McpServer; let registeredHandler: (args: unknown, extra: ServerRequestHandlerExtra) => Promise; @@ -57,12 +57,12 @@ describe("registerRunSandboxCommandTool", () => { }), } as unknown as McpServer; - registerRunSandboxCommandTool(mockServer); + registerPromptSandboxTool(mockServer); }); - it("registers the run_sandbox_command tool", () => { + it("registers the prompt_sandbox tool", () => { expect(mockServer.registerTool).toHaveBeenCalledWith( - "run_sandbox_command", + "prompt_sandbox", expect.objectContaining({ description: expect.any(String), }), @@ -77,7 +77,7 @@ describe("registerRunSandboxCommandTool", () => { }); const result = await registeredHandler( - { command: "ls" }, + { prompt: "say hello" }, createMockExtra(), ); @@ -98,7 +98,7 @@ describe("registerRunSandboxCommandTool", () => { }); const result = await registeredHandler( - { command: "ls" }, + { prompt: "say hello" }, createMockExtra(), ); @@ -112,49 +112,6 @@ describe("registerRunSandboxCommandTool", () => { }); }); - it("calls processCreateSandbox with command and returns success", async () => { - mockResolveAccountId.mockResolvedValue({ - accountId: "acc_123", - error: null, - }); - mockProcessCreateSandbox.mockResolvedValue({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_abc123", - }); - - const result = await registeredHandler( - { command: "npm install", args: ["express"], cwd: "/app" }, - createMockExtra({ accountId: "acc_123" }), - ); - - expect(mockProcessCreateSandbox).toHaveBeenCalledWith({ - accountId: "acc_123", - command: "npm install", - args: ["express"], - cwd: "/app", - prompt: undefined, - }); - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining('"sandboxId":"sbx_123"'), - }, - ], - }); - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining('"runId":"run_abc123"'), - }, - ], - }); - }); - it("calls processCreateSandbox with prompt and returns success", async () => { mockResolveAccountId.mockResolvedValue({ accountId: "acc_123", @@ -175,9 +132,6 @@ describe("registerRunSandboxCommandTool", () => { expect(mockProcessCreateSandbox).toHaveBeenCalledWith({ accountId: "acc_123", - command: undefined, - args: undefined, - cwd: undefined, prompt: "create a hello world index.html", }); expect(result).toEqual({ @@ -203,7 +157,7 @@ describe("registerRunSandboxCommandTool", () => { }); const extra = createMockExtra({ accountId: "org_123", orgId: "org_123" }); - await registeredHandler({ command: "ls", account_id: "user_456" }, extra); + await registeredHandler({ prompt: "say hello", account_id: "user_456" }, extra); expect(mockResolveAccountId).toHaveBeenCalledWith({ authInfo: extra.authInfo, @@ -224,7 +178,7 @@ describe("registerRunSandboxCommandTool", () => { }); const extra = createMockExtra({ accountId: "acc_123" }); - await registeredHandler({ command: "ls" }, extra); + await registeredHandler({ prompt: "say hello" }, extra); expect(mockResolveAccountId).toHaveBeenCalledWith({ authInfo: extra.authInfo, @@ -240,7 +194,7 @@ describe("registerRunSandboxCommandTool", () => { mockProcessCreateSandbox.mockRejectedValue(new Error("Sandbox creation failed")); const result = await registeredHandler( - { command: "ls" }, + { prompt: "say hello" }, createMockExtra({ accountId: "acc_123" }), ); diff --git a/lib/mcp/tools/sandbox/index.ts b/lib/mcp/tools/sandbox/index.ts index 5587ea096..ffb5d8431 100644 --- a/lib/mcp/tools/sandbox/index.ts +++ b/lib/mcp/tools/sandbox/index.ts @@ -1,12 +1,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerRunSandboxCommandTool } from "./registerRunSandboxCommandTool"; +import { registerPromptSandboxTool } from "./registerPromptSandboxTool"; /** * Registers all sandbox-related MCP tools on the server. - * Note: prompt_sandbox is now a local streaming tool in setupToolsForRequest. * * @param server - The MCP server instance to register tools on. */ export const registerAllSandboxTools = (server: McpServer): void => { - registerRunSandboxCommandTool(server); + registerPromptSandboxTool(server); }; diff --git a/lib/mcp/tools/sandbox/registerRunSandboxCommandTool.ts b/lib/mcp/tools/sandbox/registerPromptSandboxTool.ts similarity index 67% rename from lib/mcp/tools/sandbox/registerRunSandboxCommandTool.ts rename to lib/mcp/tools/sandbox/registerPromptSandboxTool.ts index ebb02173e..cd0ec5fba 100644 --- a/lib/mcp/tools/sandbox/registerRunSandboxCommandTool.ts +++ b/lib/mcp/tools/sandbox/registerPromptSandboxTool.ts @@ -8,18 +8,11 @@ import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; import { processCreateSandbox } from "@/lib/sandbox/processCreateSandbox"; -const runSandboxCommandSchema = z.object({ - command: z - .string() - .optional() - .describe("The command to run in the sandbox. Cannot be used with prompt."), - args: z.array(z.string()).optional().describe("Arguments for the command."), - cwd: z.string().optional().describe("Working directory for the command."), +const promptSandboxSchema = z.object({ prompt: z .string() - .optional() .describe( - 'A prompt to pass to OpenClaw. Runs `openclaw agent --agent main --message ""` in the sandbox. Cannot be used with command.', + 'A prompt to pass to OpenClaw. Runs `openclaw agent --agent main --message ""` in the sandbox.', ), account_id: z .string() @@ -30,18 +23,18 @@ const runSandboxCommandSchema = z.object({ }); /** - * Registers the "run_sandbox_command" tool on the MCP server. - * Creates a sandbox and runs a command in it. + * Registers the "prompt_sandbox" tool on the MCP server. + * Creates a sandbox and runs an OpenClaw prompt in it. * * @param server - The MCP server instance to register the tool on. */ -export function registerRunSandboxCommandTool(server: McpServer): void { +export function registerPromptSandboxTool(server: McpServer): void { server.registerTool( - "run_sandbox_command", + "prompt_sandbox", { description: - 'Create a sandbox and run a command or OpenClaw prompt in it. Use prompt to run `openclaw agent --agent main --message ""`. Returns the sandbox ID and a run ID to track progress.', - inputSchema: runSandboxCommandSchema, + 'Create a sandbox and run an OpenClaw prompt in it. Runs `openclaw agent --agent main --message ""`. Returns the sandbox ID and a run ID to track progress.', + inputSchema: promptSandboxSchema, }, async (args, extra: RequestHandlerExtra) => { const authInfo = extra.authInfo as McpAuthInfo | undefined; @@ -61,9 +54,6 @@ export function registerRunSandboxCommandTool(server: McpServer): void { try { const result = await processCreateSandbox({ accountId, - command: args.command, - args: args.args, - cwd: args.cwd, prompt: args.prompt, }); diff --git a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts index d9665175a..0e1cfc007 100644 --- a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts @@ -6,8 +6,7 @@ import { createSandboxPostHandler } from "../createSandboxPostHandler"; import { validateSandboxBody } from "@/lib/sandbox/validateSandboxBody"; import { createSandbox } from "@/lib/sandbox/createSandbox"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; -import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand"; - +import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; vi.mock("@/lib/sandbox/validateSandboxBody", () => ({ @@ -22,11 +21,10 @@ vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ insertAccountSandbox: vi.fn(), })); -vi.mock("@/lib/trigger/triggerRunSandboxCommand", () => ({ - triggerRunSandboxCommand: vi.fn(), +vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ + triggerPromptSandbox: vi.fn(), })); - vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ selectAccountSnapshots: vi.fn(), })); @@ -58,12 +56,12 @@ describe("createSandboxPostHandler", () => { expect(response.status).toBe(401); }); - it("returns runId from command task when command is provided", async () => { + it("returns runId when prompt is provided", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "create a hello world page", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ @@ -84,7 +82,7 @@ describe("createSandboxPostHandler", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + vi.mocked(triggerPromptSandbox).mockResolvedValue({ id: "run_abc123", }); @@ -112,7 +110,7 @@ describe("createSandboxPostHandler", () => { accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "say hello", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([ { @@ -140,7 +138,7 @@ describe("createSandboxPostHandler", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + vi.mocked(triggerPromptSandbox).mockResolvedValue({ id: "run_def456", }); @@ -157,7 +155,7 @@ describe("createSandboxPostHandler", () => { accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "say hello", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ @@ -178,7 +176,7 @@ describe("createSandboxPostHandler", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + vi.mocked(triggerPromptSandbox).mockResolvedValue({ id: "run_def456", }); @@ -193,7 +191,7 @@ describe("createSandboxPostHandler", () => { accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "say hello", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ @@ -214,7 +212,7 @@ describe("createSandboxPostHandler", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + vi.mocked(triggerPromptSandbox).mockResolvedValue({ id: "run_def456", }); @@ -227,14 +225,12 @@ describe("createSandboxPostHandler", () => { }); }); - it("calls triggerRunSandboxCommand with command, args, cwd, sandboxId, and accountId", async () => { + it("calls triggerPromptSandbox with prompt, sandboxId, and accountId", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", - args: ["-la"], - cwd: "/home", + prompt: "create a hello world page", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ @@ -255,17 +251,15 @@ describe("createSandboxPostHandler", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ + vi.mocked(triggerPromptSandbox).mockResolvedValue({ id: "run_ghi789", }); const request = createMockRequest(); await createSandboxPostHandler(request); - expect(triggerRunSandboxCommand).toHaveBeenCalledWith({ - command: "ls", - args: ["-la"], - cwd: "/home", + expect(triggerPromptSandbox).toHaveBeenCalledWith({ + prompt: "create a hello world page", sandboxId: "sbx_789", accountId: "acc_123", }); @@ -276,7 +270,7 @@ describe("createSandboxPostHandler", () => { accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "say hello", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed")); @@ -297,7 +291,7 @@ describe("createSandboxPostHandler", () => { accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "say hello", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ @@ -322,7 +316,7 @@ describe("createSandboxPostHandler", () => { }); }); - it("returns 200 without runId when no command is provided", async () => { + it("returns 200 without runId when no prompt is provided", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, @@ -364,15 +358,15 @@ describe("createSandboxPostHandler", () => { }, ], }); - expect(triggerRunSandboxCommand).not.toHaveBeenCalled(); + expect(triggerPromptSandbox).not.toHaveBeenCalled(); }); - it("returns 200 without runId when triggerRunSandboxCommand throws", async () => { + it("returns 200 without runId when triggerPromptSandbox throws", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - command: "ls", + prompt: "say hello", }); vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ @@ -393,12 +387,12 @@ describe("createSandboxPostHandler", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockRejectedValue(new Error("Task trigger failed")); + vi.mocked(triggerPromptSandbox).mockRejectedValue(new Error("Task trigger failed")); const request = createMockRequest(); const response = await createSandboxPostHandler(request); - // Sandbox was created successfully, so return 200 even if command trigger fails + // Sandbox was created successfully, so return 200 even if prompt trigger fails expect(response.status).toBe(200); const json = await response.json(); expect(json).toEqual({ @@ -413,5 +407,4 @@ describe("createSandboxPostHandler", () => { ], }); }); - }); diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index a1e9b89a7..f70b40c26 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { processCreateSandbox } from "../processCreateSandbox"; import { createSandbox } from "@/lib/sandbox/createSandbox"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; -import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand"; +import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; vi.mock("@/lib/sandbox/createSandbox", () => ({ @@ -14,8 +14,8 @@ vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ insertAccountSandbox: vi.fn(), })); -vi.mock("@/lib/trigger/triggerRunSandboxCommand", () => ({ - triggerRunSandboxCommand: vi.fn(), +vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ + triggerPromptSandbox: vi.fn(), })); vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ @@ -27,7 +27,7 @@ describe("processCreateSandbox", () => { vi.clearAllMocks(); }); - it("creates sandbox without command and returns result without runId", async () => { + it("creates sandbox without prompt and returns result without runId", async () => { vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ sandbox: {} as never, @@ -56,10 +56,10 @@ describe("processCreateSandbox", () => { timeout: 600000, createdAt: "2024-01-01T00:00:00.000Z", }); - expect(triggerRunSandboxCommand).not.toHaveBeenCalled(); + expect(triggerPromptSandbox).not.toHaveBeenCalled(); }); - it("creates sandbox with command and returns result with runId", async () => { + it("creates sandbox with prompt and returns result with runId", async () => { vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ sandbox: {} as never, @@ -79,15 +79,13 @@ describe("processCreateSandbox", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ - id: "run_abc123", + vi.mocked(triggerPromptSandbox).mockResolvedValue({ + id: "run_prompt123", }); const result = await processCreateSandbox({ accountId: "acc_123", - command: "ls", - args: ["-la"], - cwd: "/home", + prompt: "create a hello world index.html", }); expect(result).toEqual({ @@ -95,12 +93,10 @@ describe("processCreateSandbox", () => { sandboxStatus: "running", timeout: 600000, createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_abc123", + runId: "run_prompt123", }); - expect(triggerRunSandboxCommand).toHaveBeenCalledWith({ - command: "ls", - args: ["-la"], - cwd: "/home", + expect(triggerPromptSandbox).toHaveBeenCalledWith({ + prompt: "create a hello world index.html", sandboxId: "sbx_123", accountId: "acc_123", }); @@ -196,51 +192,6 @@ describe("processCreateSandbox", () => { }); }); - it("converts prompt to openclaw agent command", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - vi.mocked(triggerRunSandboxCommand).mockResolvedValue({ - id: "run_prompt123", - }); - - const result = await processCreateSandbox({ - accountId: "acc_123", - prompt: "create a hello world index.html", - }); - - expect(result).toEqual({ - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_prompt123", - }); - expect(triggerRunSandboxCommand).toHaveBeenCalledWith({ - command: "openclaw", - args: ["agent", "--agent", "main", "--message", "create a hello world index.html"], - cwd: undefined, - sandboxId: "sbx_123", - accountId: "acc_123", - }); - }); - it("throws when createSandbox fails", async () => { vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed")); @@ -250,7 +201,7 @@ describe("processCreateSandbox", () => { ); }); - it("returns result without runId when triggerRunSandboxCommand fails", async () => { + it("returns result without runId when triggerPromptSandbox fails", async () => { vi.mocked(selectAccountSnapshots).mockResolvedValue([]); vi.mocked(createSandbox).mockResolvedValue({ sandbox: {} as never, @@ -270,11 +221,11 @@ describe("processCreateSandbox", () => { }, error: null, }); - vi.mocked(triggerRunSandboxCommand).mockRejectedValue(new Error("Task trigger failed")); + vi.mocked(triggerPromptSandbox).mockRejectedValue(new Error("Task trigger failed")); const result = await processCreateSandbox({ accountId: "acc_123", - command: "ls", + prompt: "say hello", }); expect(result).toEqual({ diff --git a/lib/sandbox/__tests__/promptSandboxStreaming.test.ts b/lib/sandbox/__tests__/promptSandboxStreaming.test.ts deleted file mode 100644 index 506ec453a..000000000 --- a/lib/sandbox/__tests__/promptSandboxStreaming.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Sandbox } from "@vercel/sandbox"; - -import { promptSandboxStreaming } from "../promptSandboxStreaming"; - -const mockGetOrCreateSandbox = vi.fn(); -const mockTriggerRunSandboxCommand = vi.fn(); - -vi.mock("../getOrCreateSandbox", () => ({ - getOrCreateSandbox: (...args: unknown[]) => mockGetOrCreateSandbox(...args), -})); - -vi.mock("@/lib/trigger/triggerRunSandboxCommand", () => ({ - triggerRunSandboxCommand: (...args: unknown[]) => - mockTriggerRunSandboxCommand(...args), -})); - -describe("promptSandboxStreaming", () => { - const mockRunCommand = vi.fn(); - const mockSandbox = { - sandboxId: "sbx_123", - status: "running", - runCommand: mockRunCommand, - } as unknown as Sandbox; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("when sandbox has snapshot (existing setup)", () => { - it("yields log chunks in order and returns final result", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, - fromSnapshot: true, - }); - - async function* fakeLogs() { - yield { data: "Hello ", stream: "stdout" as const }; - yield { data: "world", stream: "stdout" as const }; - } - - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 0 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); - - const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; - let finalResult; - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "say hello", - }); - - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; - } - chunks.push( - result.value as { data: string; stream: "stdout" | "stderr" }, - ); - } - - expect(chunks).toEqual([ - { data: "Hello ", stream: "stdout" }, - { data: "world", stream: "stdout" }, - ]); - - expect(finalResult).toEqual({ - sandboxId: "sbx_123", - stdout: "Hello world", - stderr: "", - exitCode: 0, - created: false, - fromSnapshot: true, - }); - }); - - it("accumulates stderr separately", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, - fromSnapshot: true, - }); - - async function* fakeLogs() { - yield { data: "output", stream: "stdout" as const }; - yield { data: "warn: something", stream: "stderr" as const }; - } - - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 0 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); - - let finalResult; - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "test", - }); - - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; - } - } - - expect(finalResult).toEqual({ - sandboxId: "sbx_123", - stdout: "output", - stderr: "warn: something", - exitCode: 0, - created: false, - fromSnapshot: true, - }); - }); - - it("uses detached mode with runCommand", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, - fromSnapshot: true, - }); - - async function* fakeLogs() { - yield { data: "done", stream: "stdout" as const }; - } - - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 0 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "run", - }); - - // Drain the generator - for await (const _ of gen) { - // consume - } - - expect(mockRunCommand).toHaveBeenCalledWith({ - cmd: "openclaw", - args: ["agent", "--agent", "main", "--message", "run"], - env: { RECOUP_API_KEY: "key_abc" }, - detached: true, - }); - }); - - it("handles non-zero exit code", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_123", - created: false, - fromSnapshot: true, - }); - - async function* fakeLogs() { - yield { data: "error output", stream: "stderr" as const }; - } - - const mockCmd = { - logs: () => fakeLogs(), - wait: vi.fn().mockResolvedValue({ exitCode: 1 }), - }; - mockRunCommand.mockResolvedValue(mockCmd); - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "bad command", - }); - - let finalResult; - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; - } - } - - expect(finalResult).toEqual({ - sandboxId: "sbx_123", - stdout: "", - stderr: "error output", - exitCode: 1, - created: false, - fromSnapshot: true, - }); - }); - }); - - describe("when sandbox is fresh (no snapshot)", () => { - it("triggers runSandboxCommand and returns runId without yielding", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_fresh", - created: true, - fromSnapshot: false, - }); - - mockTriggerRunSandboxCommand.mockResolvedValue({ id: "run_abc" }); - - const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; - let finalResult; - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "say hello", - }); - - while (true) { - const result = await gen.next(); - if (result.done) { - finalResult = result.value; - break; - } - chunks.push( - result.value as { data: string; stream: "stdout" | "stderr" }, - ); - } - - expect(mockTriggerRunSandboxCommand).toHaveBeenCalledWith({ - command: "openclaw", - args: ["agent", "--agent", "main", "--message", "say hello"], - sandboxId: "sbx_fresh", - accountId: "acc_1", - }); - - expect(chunks).toEqual([]); - - expect(finalResult).toEqual({ - sandboxId: "sbx_fresh", - stdout: "", - stderr: "", - exitCode: 0, - created: true, - fromSnapshot: false, - runId: "run_abc", - }); - }); - - it("does not run command directly on sandbox", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_fresh", - created: true, - fromSnapshot: false, - }); - - mockTriggerRunSandboxCommand.mockResolvedValue({ id: "run_abc" }); - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "test", - }); - - for await (const _ of gen) { - // consume - } - - expect(mockRunCommand).not.toHaveBeenCalled(); - }); - - it("does not poll for task completion", async () => { - mockGetOrCreateSandbox.mockResolvedValue({ - sandbox: mockSandbox, - sandboxId: "sbx_fresh", - created: true, - fromSnapshot: false, - }); - - mockTriggerRunSandboxCommand.mockResolvedValue({ id: "run_abc" }); - - const gen = promptSandboxStreaming({ - accountId: "acc_1", - apiKey: "key_abc", - prompt: "test", - }); - - for await (const _ of gen) { - // consume - } - - // Should return immediately without waiting for task completion - expect(mockTriggerRunSandboxCommand).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/lib/sandbox/createSandbox.ts b/lib/sandbox/createSandbox.ts index b26e4472b..16d0da3fd 100644 --- a/lib/sandbox/createSandbox.ts +++ b/lib/sandbox/createSandbox.ts @@ -23,7 +23,7 @@ const DEFAULT_RUNTIME = "node22"; /** * Creates a Vercel Sandbox and returns its info. * - * The sandbox is left running so that commands can be executed via the runSandboxCommand task. + * The sandbox is left running so that prompts can be executed via the prompt_sandbox tool. * Accepts the same parameters as Sandbox.create from @vercel/sandbox. * * @param params - Sandbox creation parameters (source, timeout, resources, runtime, ports) diff --git a/lib/sandbox/createSandboxPostHandler.ts b/lib/sandbox/createSandboxPostHandler.ts index a4f4b414d..e953b88a0 100644 --- a/lib/sandbox/createSandboxPostHandler.ts +++ b/lib/sandbox/createSandboxPostHandler.ts @@ -8,13 +8,13 @@ import { processCreateSandbox } from "@/lib/sandbox/processCreateSandbox"; * Handler for POST /api/sandboxes. * * Creates a Vercel Sandbox (from account's snapshot if available, otherwise fresh). - * If a command is provided, triggers the run-sandbox-command task to execute it. - * If no command is provided, simply creates the sandbox without running any command. + * If a prompt is provided, triggers a task to run the prompt via OpenClaw. + * If no prompt is provided, simply creates the sandbox without running anything. * Requires authentication via x-api-key header or Authorization Bearer token. * Saves sandbox info to the account_sandboxes table. * * @param request - The request object - * @returns A NextResponse with sandbox creation result (includes runId only if command was provided) + * @returns A NextResponse with sandbox creation result (includes runId only if prompt was provided) */ export async function createSandboxPostHandler(request: NextRequest): Promise { const validated = await validateSandboxBody(request); diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index 81e1c368c..be568c110 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -1,18 +1,17 @@ import { createSandbox, type SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; -import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand"; -import type { SandboxBody } from "@/lib/sandbox/validateSandboxBody"; +import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; -type ProcessCreateSandboxInput = Pick< - SandboxBody, - "accountId" | "command" | "args" | "cwd" | "prompt" ->; +type ProcessCreateSandboxInput = { + accountId: string; + prompt?: string; +}; type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string }; /** - * Shared domain logic for creating a sandbox and optionally running a command. - * Used by both POST /api/sandboxes handler and the run_sandbox_command MCP tool. + * Shared domain logic for creating a sandbox and optionally running a prompt. + * Used by both POST /api/sandboxes handler and the prompt_sandbox MCP tool. * * @param input - The sandbox creation parameters * @returns The sandbox creation result with optional runId @@ -20,11 +19,7 @@ type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string }; export async function processCreateSandbox( input: ProcessCreateSandboxInput, ): Promise { - const { accountId, prompt, cwd } = input; - - // Convert prompt shortcut to openclaw agent command - const command = prompt ? "openclaw" : input.command; - const args = prompt ? ["agent", "--agent", "main", "--message", prompt] : input.args; + const { accountId, prompt } = input; // Get account's most recent snapshot if available const accountSnapshots = await selectAccountSnapshots(accountId); @@ -40,20 +35,18 @@ export async function processCreateSandbox( sandbox_id: result.sandboxId, }); - // Trigger the command execution task if a command was provided + // Trigger the prompt execution task if a prompt was provided let runId: string | undefined; - if (command) { + if (prompt) { try { - const handle = await triggerRunSandboxCommand({ - command, - args, - cwd, + const handle = await triggerPromptSandbox({ + prompt, sandboxId: result.sandboxId, accountId, }); runId = handle.id; } catch (triggerError) { - console.error("Failed to trigger run-sandbox-command task:", triggerError); + console.error("Failed to trigger prompt sandbox task:", triggerError); runId = undefined; } } diff --git a/lib/sandbox/promptSandboxStreaming.ts b/lib/sandbox/promptSandboxStreaming.ts deleted file mode 100644 index 3109ecf8e..000000000 --- a/lib/sandbox/promptSandboxStreaming.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { getOrCreateSandbox } from "./getOrCreateSandbox"; -import { triggerRunSandboxCommand } from "@/lib/trigger/triggerRunSandboxCommand"; - -interface PromptSandboxStreamingInput { - accountId: string; - apiKey: string; - prompt: string; - abortSignal?: AbortSignal; -} - -interface PromptSandboxStreamingResult { - sandboxId: string; - stdout: string; - stderr: string; - exitCode: number; - created: boolean; - fromSnapshot: boolean; - runId?: string; -} - -/** - * Streams output from OpenClaw running inside a persistent per-account sandbox. - * - * For sandboxes with an existing snapshot, runs the prompt directly and streams output. - * For fresh sandboxes (no snapshot), triggers the runSandboxCommand background task - * which handles full setup (OpenClaw install, GitHub repo, etc.) and runs the prompt. - * Returns immediately with a runId so the caller can track progress without blocking. - * - * @param input - The account ID, API key, prompt, and optional abort signal - * @yields Log chunks with data and stream type (stdout/stderr) - * @returns The sandbox ID, accumulated output, exit code, whether the sandbox was newly created, and optional runId - */ -export async function* promptSandboxStreaming( - input: PromptSandboxStreamingInput, -): AsyncGenerator< - { data: string; stream: "stdout" | "stderr" }, - PromptSandboxStreamingResult, - undefined -> { - const { accountId, apiKey, prompt, abortSignal } = input; - - const { sandbox, sandboxId, created, fromSnapshot } = await getOrCreateSandbox(accountId); - - // Fresh sandbox: trigger background task for full setup + prompt execution - if (created && !fromSnapshot) { - const handle = await triggerRunSandboxCommand({ - command: "openclaw", - args: ["agent", "--agent", "main", "--message", prompt], - sandboxId, - accountId, - }); - - return { - sandboxId, - stdout: "", - stderr: "", - exitCode: 0, - created, - fromSnapshot, - runId: handle.id, - }; - } - - // Existing setup: run prompt directly and stream output - const cmd = await sandbox.runCommand({ - cmd: "openclaw", - args: ["agent", "--agent", "main", "--message", prompt], - env: { - RECOUP_API_KEY: apiKey, - }, - detached: true, - }); - - let stdout = ""; - let stderr = ""; - - for await (const log of cmd.logs({ signal: abortSignal })) { - if (log.stream === "stdout") { - stdout += log.data; - } else { - stderr += log.data; - } - yield log; - } - - const { exitCode } = await cmd.wait(); - - return { - sandboxId, - stdout, - stderr, - exitCode, - created, - fromSnapshot, - }; -} diff --git a/lib/sandbox/validateSandboxBody.ts b/lib/sandbox/validateSandboxBody.ts index 8253bb7a7..ccde70e50 100644 --- a/lib/sandbox/validateSandboxBody.ts +++ b/lib/sandbox/validateSandboxBody.ts @@ -5,18 +5,10 @@ import { validateAuthContext, type AuthContext } from "@/lib/auth/validateAuthCo import { safeParseJson } from "@/lib/networking/safeParseJson"; import { z } from "zod"; -export const sandboxBodySchema = z - .object({ - account_id: z.string().uuid("account_id must be a valid UUID").optional(), - command: z.string().min(1, "command cannot be empty").optional(), - args: z.array(z.string()).optional(), - cwd: z.string().optional(), - prompt: z.string().min(1, "prompt cannot be empty").optional(), - }) - .refine(data => !(data.command && data.prompt), { - message: "Cannot specify both command and prompt", - path: ["prompt"], - }); +export const sandboxBodySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), + prompt: z.string().min(1, "prompt cannot be empty").optional(), +}); export type SandboxBody = z.infer & AuthContext; diff --git a/lib/trigger/__tests__/triggerRunSandboxCommand.test.ts b/lib/trigger/__tests__/triggerRunSandboxCommand.test.ts deleted file mode 100644 index 64718f398..000000000 --- a/lib/trigger/__tests__/triggerRunSandboxCommand.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { tasks } from "@trigger.dev/sdk"; -import { triggerRunSandboxCommand } from "../triggerRunSandboxCommand"; - -vi.mock("@trigger.dev/sdk", () => ({ - tasks: { - trigger: vi.fn(), - }, -})); - -describe("triggerRunSandboxCommand", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("triggers run-sandbox-command task with correct payload", async () => { - const mockHandle = { id: "run_123" }; - vi.mocked(tasks.trigger).mockResolvedValue(mockHandle); - - const payload = { - command: "ls", - args: ["-la"], - cwd: "/home", - sandboxId: "sbx_456", - accountId: "acc_123", - }; - - const result = await triggerRunSandboxCommand(payload); - - expect(tasks.trigger).toHaveBeenCalledWith("run-sandbox-command", payload); - expect(result).toEqual(mockHandle); - }); - - it("passes through the task handle from trigger", async () => { - const mockHandle = { id: "run_789", publicAccessToken: "token_abc" }; - vi.mocked(tasks.trigger).mockResolvedValue(mockHandle); - - const result = await triggerRunSandboxCommand({ - command: "echo", - sandboxId: "sbx_999", - accountId: "acc_456", - }); - - expect(result).toBe(mockHandle); - }); -}); diff --git a/lib/trigger/triggerPromptSandbox.ts b/lib/trigger/triggerPromptSandbox.ts new file mode 100644 index 000000000..ea4623fee --- /dev/null +++ b/lib/trigger/triggerPromptSandbox.ts @@ -0,0 +1,23 @@ +import { tasks } from "@trigger.dev/sdk"; + +type PromptSandboxPayload = { + prompt: string; + sandboxId: string; + accountId: string; +}; + +/** + * Triggers the run-sandbox-command task to execute an OpenClaw prompt in a sandbox. + * + * @param payload - The task payload with prompt, sandboxId, and accountId + * @returns The task handle with runId + */ +export async function triggerPromptSandbox(payload: PromptSandboxPayload) { + const handle = await tasks.trigger("run-sandbox-command", { + command: "openclaw", + args: ["agent", "--agent", "main", "--message", payload.prompt], + sandboxId: payload.sandboxId, + accountId: payload.accountId, + }); + return handle; +} diff --git a/lib/trigger/triggerRunSandboxCommand.ts b/lib/trigger/triggerRunSandboxCommand.ts deleted file mode 100644 index 524f68ecb..000000000 --- a/lib/trigger/triggerRunSandboxCommand.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { tasks } from "@trigger.dev/sdk"; - -type RunSandboxCommandPayload = { - command: string; - args?: string[]; - cwd?: string; - sandboxId: string; - accountId: string; -}; - -/** - * Triggers the run-sandbox-command task to execute a command in a sandbox. - * - * @param payload - The task payload with command, args, cwd, sandboxId, and accountId - * @returns The task handle with runId - */ -export async function triggerRunSandboxCommand(payload: RunSandboxCommandPayload) { - const handle = await tasks.trigger("run-sandbox-command", payload); - return handle; -} From dab05920bf8ba82a03d846dc3d7d8d99cececf89 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 9 Mar 2026 22:57:01 +0000 Subject: [PATCH 4/4] agent: address feedback - fix validateSandboxBody tests for prompt-only schema --- .../__tests__/validateSandboxBody.test.ts | 38 ++++--------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/lib/sandbox/__tests__/validateSandboxBody.test.ts b/lib/sandbox/__tests__/validateSandboxBody.test.ts index 6eb3c95a0..122887e62 100644 --- a/lib/sandbox/__tests__/validateSandboxBody.test.ts +++ b/lib/sandbox/__tests__/validateSandboxBody.test.ts @@ -42,13 +42,13 @@ describe("validateSandboxBody", () => { expect((result as NextResponse).status).toBe(401); }); - it("returns validated body with auth context when command is provided", async () => { + it("returns validated body with auth context when prompt is provided", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: "org_456", authToken: "token", }); - vi.mocked(safeParseJson).mockResolvedValue({ command: "ls" }); + vi.mocked(safeParseJson).mockResolvedValue({ prompt: "say hello" }); const request = createMockRequest(); const result = await validateSandboxBody(request); @@ -57,19 +57,20 @@ describe("validateSandboxBody", () => { accountId: "acc_123", orgId: "org_456", authToken: "token", - command: "ls", + prompt: "say hello", }); }); - it("returns validated body with optional args and cwd", async () => { + it("strips unknown fields from body", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_123", orgId: "org_456", authToken: "token", }); vi.mocked(safeParseJson).mockResolvedValue({ + prompt: "say hello", command: "ls", - args: ["-la", "/home"], + args: ["-la"], cwd: "/tmp", }); @@ -80,9 +81,7 @@ describe("validateSandboxBody", () => { accountId: "acc_123", orgId: "org_456", authToken: "token", - command: "ls", - args: ["-la", "/home"], - cwd: "/tmp", + prompt: "say hello", }); }); @@ -125,19 +124,6 @@ describe("validateSandboxBody", () => { }); }); - it("returns error response when both command and prompt are provided", async () => { - vi.mocked(safeParseJson).mockResolvedValue({ - command: "ls", - prompt: "do something", - }); - - const request = createMockRequest(); - const result = await validateSandboxBody(request); - - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - }); - it("returns error response when prompt is empty string", async () => { vi.mocked(safeParseJson).mockResolvedValue({ prompt: "" }); @@ -148,16 +134,6 @@ describe("validateSandboxBody", () => { expect((result as NextResponse).status).toBe(400); }); - it("returns error response when command is empty string", async () => { - vi.mocked(safeParseJson).mockResolvedValue({ command: "" }); - - const request = createMockRequest(); - const result = await validateSandboxBody(request); - - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - }); - it("passes body account_id to validateAuthContext for override", async () => { const targetAccountId = "10000000-1000-4000-8000-100000000001"; vi.mocked(validateAuthContext).mockResolvedValue({