diff --git a/.changeset/agent-options.md b/.changeset/agent-options.md new file mode 100644 index 0000000..010c8fe --- /dev/null +++ b/.changeset/agent-options.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add type-safe options to RunOptions and StreamOptions for passing SDK-specific options to Claude Code, Codex, and OpenCode agents diff --git a/.changeset/box-sizes.md b/.changeset/box-sizes.md new file mode 100644 index 0000000..0586fcf --- /dev/null +++ b/.changeset/box-sizes.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add configurable box sizes (small, medium, large) to Box.create() and Box.fromSnapshot() diff --git a/.changeset/delete-boxes.md b/.changeset/delete-boxes.md new file mode 100644 index 0000000..c98d436 --- /dev/null +++ b/.changeset/delete-boxes.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add Box.delete() static method for bulk box deletion diff --git a/.changeset/prompt-files.md b/.changeset/prompt-files.md new file mode 100644 index 0000000..0f3ad65 --- /dev/null +++ b/.changeset/prompt-files.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add multi-modal prompt files support for run and stream (file paths as multipart, base64 as JSON) diff --git a/.changeset/skills-api.md b/.changeset/skills-api.md new file mode 100644 index 0000000..704bc52 --- /dev/null +++ b/.changeset/skills-api.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add box.skills namespace for managing platform skills (add, remove, list) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7517629..876925a 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -26,7 +26,7 @@ jobs: - run: pnpm build - - run: pnpm test:integration + - run: pnpm -r test:integration env: UPSTASH_BOX_API_KEY: ${{ secrets.UPSTASH_BOX_API_KEY }} AGENT_API_KEY: ${{ secrets.AGENT_API_KEY }} diff --git a/package.json b/package.json index e853689..94c2874 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,6 @@ "devDependencies": { "@changesets/cli": "^2.29.4", "dotenv": "^17.3.1", - "vitest": "^4.1.1" + "vitest": "^4.1.2" } } diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 8d7030d..0930edf 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -324,6 +324,31 @@ The `provider` field in agent config is optional — the SDK infers it from the | `OpenRouterModel.O3` | `openrouter/openai/o3` | | `OpenRouterModel.O4_Mini` | `openrouter/openai/o4-mini` | +## Box Sizes + +Boxes have configurable resource sizes, set at creation time via the `size` option. Defaults to `"small"`. + +| Size | CPU | Memory | +| -------- | ------- | ------ | +| `small` | 2 cores | 2 GB | +| `medium` | 4 cores | 8 GB | +| `large` | 8 cores | 16 GB | + +```ts +const box = await Box.create({ + size: "large", + agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_5 }, +}); + +console.log(box.size); // "large" +``` + +Also supported in `Box.fromSnapshot()`: + +```ts +const box = await Box.fromSnapshot("snap_abc123", { size: "medium" }); +``` + ## Runtimes `Runtime` is a string union type: `"node" | "python" | "golang" | "ruby" | "rust"` diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index 88e3066..e1b01e5 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { z } from "zod/v3"; +import { Agent } from "../types.js"; import type { Chunk } from "../types.js"; import { mockSSEResponse, mockResponse, createTestBox, mockSSEResponseChunked } from "./helpers.js"; @@ -213,7 +214,7 @@ describe("box.agent.run", () => { }, }); - expect(run.id).toBe("box-123"); + expect(run.id).toEqual(expect.any(String)); const [, runCall] = fetchMock.mock.calls; const body = JSON.parse(runCall[1].body as string); @@ -240,7 +241,7 @@ describe("box.agent.run", () => { webhook: { url: "https://example.com/hook" }, }); - expect(run.id).toBe("box-123"); + expect(run.id).toEqual(expect.any(String)); const [, runCall] = fetchMock.mock.calls; const body = JSON.parse(runCall[1].body as string); @@ -285,6 +286,123 @@ describe("box.agent.run", () => { }), ).rejects.toThrow("bad request"); }); + + it("sends ClaudeCode agent_options in request body", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ + prompt: "test", + options: { + maxTurns: 5, + effort: "max", + thinking: { type: "enabled", budgetTokens: 16000 }, + }, + }); + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toEqual({ + maxTurns: 5, + effort: "max", + thinking: { type: "enabled", budgetTokens: 16000 }, + }); + }); + + it("sends Codex agent_options in request body", async () => { + const { box, fetchMock } = await createTestBox({ agent: Agent.Codex }); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ + prompt: "test", + options: { + modelReasoningEffort: "high", + personality: "pragmatic", + webSearch: true, + }, + }); + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toEqual({ + model_reasoning_effort: "high", + personality: "pragmatic", + web_search: true, + }); + }); + + it("sends OpenCode agent_options in request body", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ + prompt: "test", + options: { + reasoningEffort: "high", + textVerbosity: "low", + reasoningSummary: "concise", + }, + }); + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toEqual({ + reasoningEffort: "high", + textVerbosity: "low", + reasoningSummary: "concise", + }); + }); + + it("does not send agent_options when not provided", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ prompt: "test" }); + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toBeUndefined(); + }); + + it("sends agent_options in webhook run", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce(mockResponse({ status: "accepted", box_id: "box-123" })); + + await box.agent.run({ + prompt: "test", + options: { maxTurns: 3 }, + webhook: { url: "https://example.com/hook" }, + }); + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toEqual({ maxTurns: 3 }); + }); }); describe("box.agent.stream", () => { @@ -461,4 +579,53 @@ describe("box.agent.stream", () => { expect(run.result).toBe("partial"); expect(run.cost.computeMs).toBeGreaterThanOrEqual(0); }); + + it("sends agent_options in stream request body (OpenCode)", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + const run = await box.agent.stream({ + prompt: "test", + options: { reasoningEffort: "high", textVerbosity: "low" }, + }); + for await (const _ of run) { + // consume + } + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toEqual({ reasoningEffort: "high", textVerbosity: "low" }); + }); + + it("sends agent_options in stream request body (Codex)", async () => { + const { box, fetchMock } = await createTestBox({ agent: Agent.Codex }); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + const run = await box.agent.stream({ + prompt: "test", + options: { modelReasoningEffort: "medium", personality: "friendly" }, + }); + for await (const _ of run) { + // consume + } + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.agent_options).toEqual({ + model_reasoning_effort: "medium", + personality: "friendly", + }); + }); }); diff --git a/packages/sdk/src/__tests__/box-create.test.ts b/packages/sdk/src/__tests__/box-create.test.ts index 0b1739a..507741f 100644 --- a/packages/sdk/src/__tests__/box-create.test.ts +++ b/packages/sdk/src/__tests__/box-create.test.ts @@ -310,6 +310,35 @@ describe("Box.create", () => { expect(box.networkPolicy).toEqual({ mode: "allow-all" }); }); + it("sends size in body when provided", async () => { + const data = { ...TEST_BOX_DATA, status: "running", size: "large" }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data)); + + const box = await Box.create({ ...TEST_CONFIG, size: "large" }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.size).toBe("large"); + expect(box.size).toBe("large"); + }); + + it("omits size from body when not provided", async () => { + const data = { ...TEST_BOX_DATA, status: "running" }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data)); + + await Box.create(TEST_CONFIG); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.size).toBeUndefined(); + }); + + it("defaults size to small when not in response", async () => { + const data = { ...TEST_BOX_DATA, status: "running" }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data)); + + const box = await Box.create(TEST_CONFIG); + expect(box.size).toBe("small"); + }); + it("throws on API error response", async () => { vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: "rate limited" }, 429)); diff --git a/packages/sdk/src/__tests__/box-delete.test.ts b/packages/sdk/src/__tests__/box-delete.test.ts new file mode 100644 index 0000000..8fe2328 --- /dev/null +++ b/packages/sdk/src/__tests__/box-delete.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Box, BoxError } from "../client.js"; +import { mockResponse, TEST_CONFIG } from "./helpers.js"; + +describe("Box.delete (static)", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + delete process.env.UPSTASH_BOX_API_KEY; + }); + afterEach(() => vi.restoreAllMocks()); + + it("deletes a single box by ID", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); + + await Box.delete({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl, boxIds: "box-1" }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]!; + expect(url).toBe(`${TEST_CONFIG.baseUrl}/v2/box`); + expect(init?.method).toBe("DELETE"); + const body = JSON.parse(init?.body as string); + expect(body.ids).toEqual(["box-1"]); + }); + + it("deletes multiple boxes by ID", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); + + await Box.delete({ + apiKey: TEST_CONFIG.apiKey, + baseUrl: TEST_CONFIG.baseUrl, + boxIds: ["box-1", "box-2", "box-3"], + }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.ids).toEqual(["box-1", "box-2", "box-3"]); + }); + + it("throws when apiKey is missing", async () => { + await expect(Box.delete({ boxIds: "box-1" })).rejects.toThrow("apiKey is required"); + }); + + it("uses env var for apiKey", async () => { + process.env.UPSTASH_BOX_API_KEY = "env-key"; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); + + await Box.delete({ boxIds: "box-1" }); + + const [, init] = vi.mocked(fetch).mock.calls[0]!; + expect((init?.headers as Record)["X-Box-Api-Key"]).toBe("env-key"); + }); + + it("throws on API error", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: "not found" }, 404)); + + await expect( + Box.delete({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl, boxIds: "bad-id" }), + ).rejects.toThrow("not found"); + }); +}); diff --git a/packages/sdk/src/__tests__/box-from-snapshot.test.ts b/packages/sdk/src/__tests__/box-from-snapshot.test.ts index 1154ebd..726c794 100644 --- a/packages/sdk/src/__tests__/box-from-snapshot.test.ts +++ b/packages/sdk/src/__tests__/box-from-snapshot.test.ts @@ -132,6 +132,27 @@ describe("Box.fromSnapshot", () => { expect(body.attach_headers).toBeUndefined(); }); + it("sends size in body when provided", async () => { + const data = { ...TEST_BOX_DATA, status: "running", size: "medium" }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data)); + + const box = await Box.fromSnapshot("snap-1", { ...TEST_CONFIG, size: "medium" }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.size).toBe("medium"); + expect(box.size).toBe("medium"); + }); + + it("omits size from body when not provided", async () => { + const data = { ...TEST_BOX_DATA, status: "running" }; + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(data)); + + await Box.fromSnapshot("snap-1", TEST_CONFIG); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.size).toBeUndefined(); + }); + it("throws on API error", async () => { vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: "snapshot not found" }, 404)); diff --git a/packages/sdk/src/__tests__/box-prompt-files.test.ts b/packages/sdk/src/__tests__/box-prompt-files.test.ts new file mode 100644 index 0000000..5767f10 --- /dev/null +++ b/packages/sdk/src/__tests__/box-prompt-files.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { mockSSEResponse, mockResponse, createTestBox } from "./helpers.js"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_CSV = resolve(__dirname, "integration/fixtures/sample.csv"); + +describe("box.agent.run — files (base64 JSON)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("sends base64 files in JSON body with media_type wire format", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "It's a cat" } }, + ]), + ); + + await box.agent.run({ + prompt: "What's in this image?", + files: [ + { data: "iVBORw0KGgo=", mediaType: "image/png", filename: "cat.png" }, + { data: "JVBERi0xLjQ=", mediaType: "application/pdf" }, + ], + }); + + const [, runCall] = fetchMock.mock.calls; + expect(runCall[1].headers["Content-Type"]).toBe("application/json"); + const body = JSON.parse(runCall[1].body as string); + expect(body.prompt).toBe("What's in this image?"); + expect(body.files).toEqual([ + { data: "iVBORw0KGgo=", media_type: "image/png", filename: "cat.png" }, + { data: "JVBERi0xLjQ=", media_type: "application/pdf", filename: undefined }, + ]); + }); + + it("sends base64 files in webhook run", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce(mockResponse({ status: "accepted", box_id: "box-123" })); + + await box.agent.run({ + prompt: "Describe this", + files: [{ data: "abc123", mediaType: "image/jpeg", filename: "photo.jpg" }], + webhook: { url: "https://example.com/hook" }, + }); + + const [, runCall] = fetchMock.mock.calls; + expect(runCall[1].headers["Content-Type"]).toBe("application/json"); + const body = JSON.parse(runCall[1].body as string); + expect(body.files).toEqual([ + { data: "abc123", media_type: "image/jpeg", filename: "photo.jpg" }, + ]); + expect(body.webhook).toBeDefined(); + }); + + it("does not include files key when no files provided", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ prompt: "hello" }); + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.files).toBeUndefined(); + }); +}); + +describe("box.agent.stream — files (base64 JSON)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("sends base64 files in stream request", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "It's a chart" } }, + ]), + ); + + const run = await box.agent.stream({ + prompt: "Analyze this chart", + files: [{ data: "iVBORw0KGgo=", mediaType: "image/png", filename: "chart.png" }], + }); + for await (const _ of run) { + // consume + } + + const [, runCall] = fetchMock.mock.calls; + expect(runCall[1].headers["Content-Type"]).toBe("application/json"); + const body = JSON.parse(runCall[1].body as string); + expect(body.files).toEqual([ + { data: "iVBORw0KGgo=", media_type: "image/png", filename: "chart.png" }, + ]); + }); + + it("does not include files key when no files provided", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + const run = await box.agent.stream({ prompt: "hello" }); + for await (const _ of run) { + // consume + } + + const [, runCall] = fetchMock.mock.calls; + const body = JSON.parse(runCall[1].body as string); + expect(body.files).toBeUndefined(); + }); +}); + +describe("box.agent.run — files (multipart file paths)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("sends local file as multipart FormData", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "3 rows" } }, + ]), + ); + + const run = await box.agent.run({ + prompt: "How many rows?", + files: [FIXTURE_CSV], + }); + + expect(run.result).toBe("3 rows"); + expect(run.status).toBe("completed"); + + const [, runCall] = fetchMock.mock.calls; + // Multipart — no Content-Type header (browser/node sets boundary automatically) + expect(runCall[1].headers["Content-Type"]).toBeUndefined(); + expect(runCall[1].body).toBeInstanceOf(FormData); + const formData = runCall[1].body as FormData; + expect(formData.get("prompt")).toBe("How many rows?"); + expect(formData.getAll("files")).toHaveLength(1); + const file = formData.get("files") as File; + expect(file.type).toBe("text/csv"); + }); + + it("includes agent_options as JSON string in multipart form", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ + prompt: "Analyze", + files: [FIXTURE_CSV], + options: { maxTurns: 3 }, + }); + + const formData = fetchMock.mock.calls[1]![1].body as FormData; + expect(formData.get("agent_options")).toBe(JSON.stringify({ maxTurns: 3 })); + }); + + it("sends multipart FormData with webhook fields", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce(mockResponse({ status: "accepted", box_id: "box-123" })); + + await box.agent.run({ + prompt: "Analyze", + files: [FIXTURE_CSV], + webhook: { url: "https://example.com/hook", headers: { "X-Test": "value" } }, + }); + + const [, runCall] = fetchMock.mock.calls; + expect(runCall[1].headers["Content-Type"]).toBeUndefined(); + expect(runCall[1].body).toBeInstanceOf(FormData); + const formData = runCall[1].body as FormData; + expect(formData.get("prompt")).toBe("Analyze"); + expect(formData.getAll("files")).toHaveLength(1); + expect(formData.get("webhook")).toBe( + JSON.stringify({ url: "https://example.com/hook", headers: { "X-Test": "value" } }), + ); + }); + + it("sends multiple file paths", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "done" } }, + ]), + ); + + await box.agent.run({ + prompt: "Compare these", + files: [FIXTURE_CSV, FIXTURE_CSV], + }); + + const formData = fetchMock.mock.calls[1]![1].body as FormData; + expect(formData.getAll("files")).toHaveLength(2); + }); +}); + +describe("box.agent.stream — files (multipart file paths)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("sends local file as multipart FormData in stream", async () => { + const { box, fetchMock } = await createTestBox(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "Charlie" } }, + ]), + ); + + const run = await box.agent.stream({ + prompt: "Oldest person?", + files: [FIXTURE_CSV], + }); + + const chunks = []; + for await (const chunk of run) { + chunks.push(chunk); + } + + expect(run.result).toBe("Charlie"); + expect(run.status).toBe("completed"); + + const [, runCall] = fetchMock.mock.calls; + expect(runCall[1].headers["Content-Type"]).toBeUndefined(); + expect(runCall[1].body).toBeInstanceOf(FormData); + }); +}); diff --git a/packages/sdk/src/__tests__/box-skills.test.ts b/packages/sdk/src/__tests__/box-skills.test.ts new file mode 100644 index 0000000..7c325d1 --- /dev/null +++ b/packages/sdk/src/__tests__/box-skills.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { mockResponse, createTestBox, TEST_BOX_DATA } from "./helpers.js"; + +describe("box.skills", () => { + afterEach(() => vi.restoreAllMocks()); + + describe("add", () => { + it("sends POST with skill_id", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ message: "Skill added" })); + + await box.skills.add("vercel/next.js/update-docs"); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/config/skills"); + expect(init?.method).toBe("POST"); + const body = JSON.parse(init?.body as string); + expect(body.skill_id).toBe("vercel/next.js/update-docs"); + }); + + it("throws on API error", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ error: "Invalid skill_id format" }, 400)); + + await expect(box.skills.add("bad-format")).rejects.toThrow("Invalid skill_id format"); + }); + + it("throws on duplicate skill", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ error: "Skill already enabled" }, 409)); + + await expect(box.skills.add("vercel/next.js/update-docs")).rejects.toThrow( + "Skill already enabled", + ); + }); + }); + + describe("remove", () => { + it("sends DELETE with skill path", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ message: "Skill removed" })); + + await box.skills.remove("vercel/next.js/update-docs"); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123/config/skills/vercel/next.js/update-docs"); + expect(init?.method).toBe("DELETE"); + }); + + it("throws on API error", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ error: "Skill not found" }, 404)); + + await expect(box.skills.remove("vercel/next.js/update-docs")).rejects.toThrow( + "Skill not found", + ); + }); + }); + + describe("list", () => { + it("returns enabled skills from box data", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce( + mockResponse({ + ...TEST_BOX_DATA, + enabled_skills: ["vercel/next.js/update-docs", "upstash/qstash-js/qstash-js"], + }), + ); + + const skills = await box.skills.list(); + + expect(skills).toEqual(["vercel/next.js/update-docs", "upstash/qstash-js/qstash-js"]); + + const [url, init] = fetchMock.mock.calls[1]!; + expect(url).toContain("/v2/box/box-123"); + expect(init?.method).toBe("GET"); + }); + + it("returns empty array when no skills enabled", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ ...TEST_BOX_DATA })); + + const skills = await box.skills.list(); + + expect(skills).toEqual([]); + }); + + it("returns empty array when enabled_skills is explicitly empty", async () => { + const { box, fetchMock } = await createTestBox(); + fetchMock.mockResolvedValueOnce(mockResponse({ ...TEST_BOX_DATA, enabled_skills: [] })); + + const skills = await box.skills.list(); + + expect(skills).toEqual([]); + }); + }); +}); diff --git a/packages/sdk/src/__tests__/ephemeral-box.test.ts b/packages/sdk/src/__tests__/ephemeral-box.test.ts index 4c4397a..62e251c 100644 --- a/packages/sdk/src/__tests__/ephemeral-box.test.ts +++ b/packages/sdk/src/__tests__/ephemeral-box.test.ts @@ -102,6 +102,24 @@ describe("EphemeralBox.create", () => { expect(body.name).toBe("my-ephemeral"); }); + it("sends size in body when provided", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ ...EPHEMERAL_BOX_DATA, size: "large" })); + + const box = await EphemeralBox.create({ ...EPHEMERAL_CONFIG, size: "large" }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.size).toBe("large"); + }); + + it("omits size from body when not provided", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse(EPHEMERAL_BOX_DATA)); + + await EphemeralBox.create(EPHEMERAL_CONFIG); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]?.body as string); + expect(body.size).toBeUndefined(); + }); + it("sends network_policy in body when provided", async () => { vi.mocked(fetch).mockResolvedValueOnce(mockResponse(EPHEMERAL_BOX_DATA)); @@ -283,3 +301,31 @@ describe("EphemeralBox instance", () => { expect(init?.method).toBe("DELETE"); }); }); + +describe("EphemeralBox.delete (static)", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + delete process.env.UPSTASH_BOX_API_KEY; + }); + afterEach(() => vi.restoreAllMocks()); + + it("deletes specific boxes by ID", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); + + await EphemeralBox.delete({ + apiKey: EPHEMERAL_CONFIG.apiKey, + baseUrl: EPHEMERAL_CONFIG.baseUrl, + boxIds: ["box-1", "box-2"], + }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]!; + expect(url).toBe(`${EPHEMERAL_CONFIG.baseUrl}/v2/box`); + expect(init?.method).toBe("DELETE"); + const body = JSON.parse(init?.body as string); + expect(body.ids).toEqual(["box-1", "box-2"]); + }); + + it("throws when apiKey is missing", async () => { + await expect(EphemeralBox.delete({ boxIds: "box-1" })).rejects.toThrow("apiKey is required"); + }); +}); diff --git a/packages/sdk/src/__tests__/helpers.ts b/packages/sdk/src/__tests__/helpers.ts index ed75447..bab7f8f 100644 --- a/packages/sdk/src/__tests__/helpers.ts +++ b/packages/sdk/src/__tests__/helpers.ts @@ -118,13 +118,13 @@ export function mockSSEResponseChunked(events: Array<{ event: string; data: unkn /** * Creates a real Box instance by mocking the fetch for Box.get(). */ -export async function createTestBox( +export async function createTestBox( overrides?: Partial, -): Promise<{ box: Box; fetchMock: ReturnType }> { +): Promise<{ box: Box; fetchMock: ReturnType }> { const data = { ...TEST_BOX_DATA, ...overrides }; const fetchMock = vi.fn().mockResolvedValueOnce(mockResponse(data)); vi.stubGlobal("fetch", fetchMock); - const box = await Box.get(data.id!, { + const box = await Box.get(data.id!, { apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl, }); diff --git a/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts b/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts new file mode 100644 index 0000000..ea6d907 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Agent, Box, ClaudeCode } from "../../index.js"; +import { UPSTASH_BOX_API_KEY } from "./setup.js"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("options", () => { + let box: Box; + + beforeAll(async () => { + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_6 }, + }); + }, 120000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("passes options to a run", async () => { + const run = await box.agent.run({ + prompt: "Reply with exactly: AGENT_OPTIONS_TEST", + options: { + maxTurns: 2, + effort: "low", + }, + }); + + expect(run.status).toBe("completed"); + expect(run.result).toContain("AGENT_OPTIONS_TEST"); + }, 120000); + + it("passes options to a stream", async () => { + const run = await box.agent.stream({ + prompt: "Reply with exactly: STREAM_OPTIONS_TEST", + options: { + maxTurns: 2, + effort: "low", + }, + }); + + let output = ""; + for await (const chunk of run) { + if (chunk.type === "text-delta") output += chunk.text; + } + + expect(run.status).toBe("completed"); + expect(output).toContain("STREAM_OPTIONS_TEST"); + }, 120000); +}); diff --git a/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts b/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts new file mode 100644 index 0000000..1cecb05 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { Box, EphemeralBox } from "../../index.js"; +import { UPSTASH_BOX_API_KEY } from "./setup.js"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("Box.delete (static)", () => { + it("deletes a single box by ID", async () => { + const box = await EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }); + + await Box.delete({ apiKey: UPSTASH_BOX_API_KEY!, boxIds: box.id }); + + const boxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); + expect(boxes.find((b) => b.id === box.id)).toBeUndefined(); + }, 60000); + + it("deletes multiple boxes by ID", async () => { + const [b1, b2] = await Promise.all([ + EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), + EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), + ]); + + await Box.delete({ apiKey: UPSTASH_BOX_API_KEY!, boxIds: [b1.id, b2.id] }); + + const boxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); + expect(boxes.find((b) => b.id === b1.id)).toBeUndefined(); + expect(boxes.find((b) => b.id === b2.id)).toBeUndefined(); + }, 60000); +}); diff --git a/packages/sdk/src/__tests__/integration/box-size.integration.test.ts b/packages/sdk/src/__tests__/integration/box-size.integration.test.ts new file mode 100644 index 0000000..54206ad --- /dev/null +++ b/packages/sdk/src/__tests__/integration/box-size.integration.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { Box } from "../../index.js"; +import { UPSTASH_BOX_API_KEY } from "./setup.js"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("Box.create — size", () => { + const boxes: Box[] = []; + + afterAll(async () => { + await Promise.allSettled(boxes.map((b) => b.delete())); + }, 30000); + + it("creates a small box (default)", async () => { + const box = await Box.create({ apiKey: UPSTASH_BOX_API_KEY! }); + boxes.push(box); + + expect(box.size).toBe("small"); + + const fetched = await Box.get(box.id, { apiKey: UPSTASH_BOX_API_KEY! }); + expect(fetched.size).toBe("small"); + }, 120000); + + it("creates a medium box", async () => { + const box = await Box.create({ apiKey: UPSTASH_BOX_API_KEY!, size: "medium" }); + boxes.push(box); + + expect(box.size).toBe("medium"); + + const fetched = await Box.get(box.id, { apiKey: UPSTASH_BOX_API_KEY! }); + expect(fetched.size).toBe("medium"); + }, 120000); + + it("creates a large box", async () => { + const box = await Box.create({ apiKey: UPSTASH_BOX_API_KEY!, size: "large" }); + boxes.push(box); + + expect(box.size).toBe("large"); + + const fetched = await Box.get(box.id, { apiKey: UPSTASH_BOX_API_KEY! }); + expect(fetched.size).toBe("large"); + }, 120000); + + it("size appears in Box.list results", async () => { + const allBoxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); + for (const b of boxes) { + const found = allBoxes.find((lb) => lb.id === b.id); + expect(found).toBeDefined(); + expect(found!.size).toBe(b.size); + } + }); +}); diff --git a/packages/sdk/src/__tests__/integration/ephemeral-size.integration.test.ts b/packages/sdk/src/__tests__/integration/ephemeral-size.integration.test.ts new file mode 100644 index 0000000..8e858b7 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/ephemeral-size.integration.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { Box, EphemeralBox } from "../../index.js"; +import { UPSTASH_BOX_API_KEY } from "./setup.js"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("EphemeralBox.create — size", () => { + const boxes: EphemeralBox[] = []; + + afterAll(async () => { + await Promise.allSettled(boxes.map((b) => b.delete())); + }, 30000); + + it("creates an ephemeral box with default size (small)", async () => { + const box = await EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }); + boxes.push(box); + + const fetched = await Box.get(box.id, { apiKey: UPSTASH_BOX_API_KEY! }); + expect(fetched.size).toBe("small"); + }, 30000); + + it("creates an ephemeral box with medium size", async () => { + const box = await EphemeralBox.create({ + apiKey: UPSTASH_BOX_API_KEY!, + size: "medium", + ttl: 300, + }); + boxes.push(box); + + const fetched = await Box.get(box.id, { apiKey: UPSTASH_BOX_API_KEY! }); + expect(fetched.size).toBe("medium"); + }, 30000); + + it("creates an ephemeral box with large size", async () => { + const box = await EphemeralBox.create({ + apiKey: UPSTASH_BOX_API_KEY!, + size: "large", + ttl: 300, + }); + boxes.push(box); + + const fetched = await Box.get(box.id, { apiKey: UPSTASH_BOX_API_KEY! }); + expect(fetched.size).toBe("large"); + }, 30000); +}); diff --git a/packages/sdk/src/__tests__/integration/exec.integration.test.ts b/packages/sdk/src/__tests__/integration/exec.integration.test.ts index 83ed470..5fbf61f 100644 --- a/packages/sdk/src/__tests__/integration/exec.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/exec.integration.test.ts @@ -38,7 +38,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("exec", () => { }); it("exec.stream: streams multi-line shell output", async () => { - const run = await box.exec.stream("ping -c 5 127.0.0.1"); + const run = await box.exec.stream('for i in $(seq 0 4); do echo "line=$i"; sleep 0.5; done'); const chunks: string[] = []; for await (const chunk of run) { if (chunk.type === "output") { @@ -48,11 +48,9 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("exec", () => { const fullOutput = chunks.join(""); - expect(fullOutput).toContain("PING 127.0.0.1"); for (let i = 0; i < 5; i++) { - expect(fullOutput).toContain(`seq=${i}`); + expect(fullOutput).toContain(`line=${i}`); } - expect(fullOutput).toContain("packets transmitted"); // Must have received multiple output chunks (not all in one batch) expect(chunks.length).toBeGreaterThanOrEqual(2); @@ -60,7 +58,6 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("exec", () => { // Run should be populated after iteration expect(run.status).toBe("completed"); expect(run.exitCode).toBe(0); - expect(run.result).toContain("PING 127.0.0.1"); }, 30000); it("exec.streamCode: streams multi-line JS output", async () => { diff --git a/packages/sdk/src/__tests__/integration/fixtures/sample.csv b/packages/sdk/src/__tests__/integration/fixtures/sample.csv new file mode 100644 index 0000000..3a401a1 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/fixtures/sample.csv @@ -0,0 +1,4 @@ +name,age,city +Alice,30,New York +Bob,25,San Francisco +Charlie,35,Chicago diff --git a/packages/sdk/src/__tests__/integration/fixtures/sample.txt b/packages/sdk/src/__tests__/integration/fixtures/sample.txt new file mode 100644 index 0000000..3a401a1 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/fixtures/sample.txt @@ -0,0 +1,4 @@ +name,age,city +Alice,30,New York +Bob,25,San Francisco +Charlie,35,Chicago diff --git a/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts new file mode 100644 index 0000000..6465ae0 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Agent, Box, ClaudeCode } from "../../index.js"; +import { UPSTASH_BOX_API_KEY } from "./setup.js"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SAMPLE_CSV_PATH = resolve(__dirname, "fixtures/sample.csv"); +const SAMPLE_TXT_PATH = resolve(__dirname, "fixtures/sample.txt"); + +describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — base64 JSON", () => { + let box: Box; + + beforeAll(async () => { + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_6 }, + }); + }, 120000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("accepts a base64 image in a run", async () => { + // 1x1 red PNG + const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + + const run = await box.agent.run({ + prompt: "Describe this image in one word. Reply with ONLY that word.", + files: [{ data: tinyPng, mediaType: "image/png", filename: "red.png" }], + options: { maxTurns: 1 }, + }); + + expect(run.status).toBe("completed"); + expect(run.result.length).toBeGreaterThan(0); + }, 120000); + + it("accepts a base64 image in a stream", async () => { + const tinyPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + + const run = await box.agent.stream({ + prompt: "What color is this image? Reply with ONLY the color name.", + files: [{ data: tinyPng, mediaType: "image/png" }], + options: { maxTurns: 1 }, + }); + + let output = ""; + for await (const chunk of run) { + if (chunk.type === "text-delta") output += chunk.text; + } + + expect(run.status).toBe("completed"); + expect(output.length).toBeGreaterThan(0); + }, 120000); +}); + +describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (multipart)", () => { + let box: Box; + + beforeAll(async () => { + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_6 }, + }); + }, 120000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("uploads a local CSV file in a run", async () => { + const run = await box.agent.run({ + prompt: + "How many rows of data are in this CSV (excluding the header)? Reply with ONLY the number.", + files: [SAMPLE_CSV_PATH], + options: { maxTurns: 1 }, + }); + + expect(run.status).toBe("completed"); + expect(run.result).toContain("3"); + }, 120000); + + it("uploads a local CSV file in a stream", async () => { + const run = await box.agent.stream({ + prompt: "What is the oldest person's name in this CSV? Reply with ONLY the name.", + files: [SAMPLE_CSV_PATH], + options: { maxTurns: 1 }, + }); + + let output = ""; + for await (const chunk of run) { + if (chunk.type === "text-delta") output += chunk.text; + } + + expect(run.status).toBe("completed"); + expect(output.toLowerCase()).toContain("charlie"); + }, 120000); +}); + +describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (txt)", () => { + let box: Box; + + beforeAll(async () => { + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_6 }, + }); + }, 120000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("uploads a local TXT file in a run", async () => { + const run = await box.agent.run({ + prompt: + "How many rows of data are in this file (excluding the header)? Reply with ONLY the number.", + files: [SAMPLE_TXT_PATH], + options: { maxTurns: 1 }, + }); + + expect(run.status).toBe("completed"); + expect(run.result).toContain("3"); + }, 120000); + + it("uploads a local TXT file in a stream", async () => { + const run = await box.agent.stream({ + prompt: "What is the oldest person's name in this file? Reply with ONLY the name.", + files: [SAMPLE_TXT_PATH], + options: { maxTurns: 1 }, + }); + + let output = ""; + for await (const chunk of run) { + if (chunk.type === "text-delta") output += chunk.text; + } + + expect(run.status).toBe("completed"); + expect(output.toLowerCase()).toContain("charlie"); + }, 120000); +}); diff --git a/packages/sdk/src/__tests__/integration/skills-api.integration.test.ts b/packages/sdk/src/__tests__/integration/skills-api.integration.test.ts new file mode 100644 index 0000000..f9bc614 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/skills-api.integration.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { Agent, Box, ClaudeCode } from "../../index.js"; +import { UPSTASH_BOX_API_KEY } from "./setup.js"; + +describe.skipIf(!UPSTASH_BOX_API_KEY)("skills API", () => { + let box: Box; + const skillId = "upstash/qstash-js/qstash-js"; + + beforeAll(async () => { + box = await Box.create({ + apiKey: UPSTASH_BOX_API_KEY!, + agent: { provider: Agent.ClaudeCode, model: ClaudeCode.Sonnet_4_6 }, + }); + }, 120000); + + afterAll(async () => { + try { + await box?.delete(); + } catch { + // cleanup best-effort + } + }, 30000); + + it("add, list, then remove a skill", async () => { + // List — should not contain the skill + const before = await box.skills.list(); + expect(before).not.toContain(skillId); + + // Add + await box.skills.add(skillId); + + // List — should contain the skill + const after = await box.skills.list(); + expect(after).toContain(skillId); + + // Remove + await box.skills.remove(skillId); + + // List — should no longer contain the skill + const afterRemove = await box.skills.list(); + expect(afterRemove).not.toContain(skillId); + }, 60000); + + it("list returns empty when no skills are enabled", async () => { + const skills = await box.skills.list(); + expect(Array.isArray(skills)).toBe(true); + }); +}); diff --git a/packages/sdk/src/__tests__/integration/webhook.integration.test.ts b/packages/sdk/src/__tests__/integration/webhook.integration.test.ts index 539eece..017651e 100644 --- a/packages/sdk/src/__tests__/integration/webhook.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/webhook.integration.test.ts @@ -2,8 +2,13 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { z } from "zod/v3"; import { Box, Agent, ClaudeCode } from "../../index.js"; import { UPSTASH_BOX_API_KEY } from "./setup.js"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; -const WEBHOOK_URL = "https://mock.httpstatus.io/200"; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SAMPLE_CSV_PATH = resolve(__dirname, "fixtures/sample.csv"); + +const WEBHOOK_URL = "https://testing-ma-feat.requestcatcher.com"; /** * Polls box.listRuns() until a run with the given ID reaches a terminal status. @@ -11,8 +16,8 @@ const WEBHOOK_URL = "https://mock.httpstatus.io/200"; async function waitForRun( box: Box, runId: string, - timeoutMs = 120_000, - intervalMs = 3_000, + timeoutMs = 20_000, + intervalMs = 1_000, ): Promise<{ status: string; output?: string }> { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -83,6 +88,22 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("agent.run with webhook", () => { expect(run.status).toBe("running"); }, 30_000); + it("sends multipart file paths with webhook", async () => { + const run = await box.agent.run({ + prompt: + "How many rows of data are in this CSV (excluding the header)? Reply with ONLY the number.", + files: [SAMPLE_CSV_PATH], + webhook: { url: WEBHOOK_URL, headers: { "X-Test-Header": "file-upload" } }, + }); + + expect(run.id).toBeTruthy(); + expect(run.status).toBe("running"); + + const completed = await waitForRun(box, run.id); + expect(completed.status).toBe("completed"); + expect(completed.output).toContain("3"); + }, 30_000); + it("webhook run eventually completes and appears in listRuns", async () => { const run = await box.agent.run({ prompt: "Reply with exactly: POLL_CHECK", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index a990717..65660cf 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,10 +1,13 @@ import { zodToJsonSchema as zodToJsonSchemaLib } from "zod-to-json-schema"; import { type BoxConfig, + type BoxConnectionOptions, type BoxData, type BoxGetOptions, type BoxRunData, + type BoxSize, type ListOptions, + type PromptFiles, type RunOptions, type StreamOptions, type Chunk, @@ -37,6 +40,9 @@ import { type ExecScheduleOptions, type AgentScheduleOptions, type Schedule, + type ClaudeCodeAgentOptions, + type CodexAgentOptions, + type OpenCodeAgentOptions, Agent, } from "./types.js"; import type { ZodType } from "zod/v3"; @@ -54,6 +60,31 @@ export function inferDefaultProvider(model: string): Agent { /** @deprecated Use `inferDefaultProvider` instead. */ export const inferDefaultRunner = inferDefaultProvider; +/** Map of camelCase Codex option keys to their snake_case backend equivalents. */ +const CODEX_KEY_MAP: Record = { + modelReasoningEffort: "model_reasoning_effort", + modelReasoningSummary: "model_reasoning_summary", + personality: "personality", + webSearch: "web_search", +}; + +/** Convert camelCase Codex agent options to the snake_case keys the backend expects. */ +function toBackendAgentOptions( + agent: Agent | undefined, + options: + | ClaudeCodeAgentOptions + | CodexAgentOptions + | OpenCodeAgentOptions + | Record, +): Record { + if (agent !== Agent.Codex) return options as Record; + const mapped: Record = {}; + for (const [key, value] of Object.entries(options)) { + mapped[CODEX_KEY_MAP[key as keyof CodexAgentOptions] ?? key] = value; + } + return mapped; +} + /** * Error thrown by the Box SDK */ @@ -228,9 +259,12 @@ export class StreamRun extends Run implements AsyncIte * await box.delete(); * ``` */ -export class Box { +export class Box { readonly id: string; + /** Resource size of this box (`"small"`, `"medium"`, or `"large"`). */ + readonly size: BoxSize; + /** Current network access policy for this box. */ get networkPolicy(): NetworkPolicy { return this._networkPolicy; @@ -239,10 +273,12 @@ export class Box { /** Agent operations namespace */ readonly agent: { run( - options: RunOptions & { responseSchema: RunOptions["responseSchema"] }, + options: RunOptions & { + responseSchema: RunOptions["responseSchema"]; + }, ): Promise>; - run(options: RunOptions): Promise>; - stream(options: StreamOptions): Promise>; + run(options: RunOptions): Promise>; + stream(options: StreamOptions): Promise>; }; /** File operations namespace */ @@ -301,6 +337,16 @@ export class Box { checkout: (options: GitCheckoutOptions) => Promise; }; + /** Skills namespace — manage platform skills from the Context7 registry */ + readonly skills: { + /** Add a skill. Format: `owner/repo/skill-name`. */ + add: (skillId: string) => Promise; + /** Remove a skill. Format: `owner/repo/skill-name`. */ + remove: (skillId: string) => Promise; + /** List enabled skills for this box. */ + list: () => Promise; + }; + /** * The current working directory tracked in the SDK (not in the box). * Every new session starts at /workspace/home. @@ -359,6 +405,7 @@ export class Box { }, ) { this.id = data.id; + this.size = data.size ?? "small"; this._cwd = Box.WORKSPACE; this._networkPolicy = deserializeNetworkPolicy(data.network_policy); this._model = data.model; @@ -426,12 +473,18 @@ export class Box { exec: (options) => this._gitExec(options), checkout: (options) => this._gitCheckout(options), }; + + this.skills = { + add: (skillId) => this._skillAdd(skillId), + remove: (skillId) => this._skillRemove(skillId), + list: () => this._skillList(), + }; } /** * Create a new sandboxed box. */ - static async create(config?: BoxConfig): Promise { + static async create(config?: BoxConfig): Promise> { const apiKey = config?.apiKey ?? process.env.UPSTASH_BOX_API_KEY; if (!apiKey) { throw new BoxError( @@ -454,6 +507,7 @@ export class Box { const body: Record = {}; if (config?.name) body.name = config.name; + if (config?.size) body.size = config.size; if (config?.agent) { body.model = config.agent.model; body.agent = config.agent.provider ?? config.agent.runner; @@ -546,10 +600,48 @@ export class Box { return (await response.json()) as BoxData[]; } + /** + * Delete specific boxes by ID. + */ + static async delete( + options: BoxConnectionOptions & { boxIds: string | string[] }, + ): Promise { + const apiKey = options.apiKey ?? process.env.UPSTASH_BOX_API_KEY; + if (!apiKey) { + throw new BoxError( + "apiKey is required. Pass it in options or set UPSTASH_BOX_API_KEY env var.", + ); + } + + const baseUrl = ( + options.baseUrl ?? + process.env.UPSTASH_BOX_BASE_URL ?? + DEFAULT_BASE_URL + ).replace(/\/$/, ""); + const headers: Record = { + "X-Box-Api-Key": apiKey, + "Content-Type": "application/json", + }; + + const ids = Array.isArray(options.boxIds) ? options.boxIds : [options.boxIds]; + const response = await fetch(`${baseUrl}/v2/box`, { + method: "DELETE", + headers, + body: JSON.stringify({ ids }), + }); + if (!response.ok) { + const msg = await parseErrorResponse(response); + throw new BoxError(msg, response.status); + } + } + /** * Get an existing box by ID */ - static async get(boxId: string, options?: BoxGetOptions): Promise { + static async get( + boxId: string, + options?: BoxGetOptions, + ): Promise> { const apiKey = options?.apiKey ?? process.env.UPSTASH_BOX_API_KEY; if (!apiKey) { throw new BoxError( @@ -619,15 +711,23 @@ export class Box { requestBody.json_schema = jsonSchema; } } + if (options.options) + requestBody.agent_options = toBackendAgentOptions(this._agent, options.options); requestBody.webhook = options.webhook.headers ? { url: options.webhook.url, headers: options.webhook.headers } : { url: options.webhook.url }; const url = `${this._baseUrl}/v2/box/${this.id}/run`; + const { body: fetchBody, headers: fetchHeaders } = await buildRunRequest( + this._headers, + requestBody, + options.files, + ); + const response = await fetch(url, { method: "POST", - headers: { ...this._headers, "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), + headers: fetchHeaders, + body: fetchBody, }); if (!response.ok) { @@ -635,8 +735,8 @@ export class Box { throw new BoxError(msg, response.status); } - const data = (await response.json()) as { status: string; box_id: string }; - Run._update(run, { id: data.box_id, status: "running" }); + const data = (await response.json()) as { status: string; run_id: string }; + Run._update(run, { id: data.run_id, status: "running" }); return run; } @@ -679,12 +779,20 @@ export class Box { requestBody.json_schema = jsonSchema; } } + if (options.options) + requestBody.agent_options = toBackendAgentOptions(this._agent, options.options); const url = `${this._baseUrl}/v2/box/${this.id}/run/stream`; + const { body: fetchBody, headers: fetchHeaders } = await buildRunRequest( + this._headers, + requestBody, + options.files, + ); + const response = await fetch(url, { method: "POST", - headers: { ...this._headers, "Content-Type": "application/json" }, - body: JSON.stringify(requestBody), + headers: fetchHeaders, + body: fetchBody, signal: abortController.signal, }); @@ -813,11 +921,22 @@ export class Box { } const folder = this._getFolder(); + const requestBody: Record = { prompt: options.prompt }; + if (folder) requestBody.folder = folder; + if (options.options) + requestBody.agent_options = toBackendAgentOptions(this._agent, options.options); + const url = `${this._baseUrl}/v2/box/${this.id}/run/stream`; + const { body: fetchBody, headers: fetchHeaders } = await buildRunRequest( + this._headers, + requestBody, + options.files, + ); + const response = await fetch(url, { method: "POST", - headers: { ...this._headers, "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: options.prompt, ...(folder ? { folder } : {}) }), + headers: fetchHeaders, + body: fetchBody, signal: abortController.signal, }); @@ -1541,7 +1660,10 @@ export class Box { /** * Create a new box from a saved snapshot. */ - static async fromSnapshot(snapshotId: string, config?: BoxConfig): Promise { + static async fromSnapshot( + snapshotId: string, + config?: BoxConfig, + ): Promise> { const apiKey = config?.apiKey ?? process.env.UPSTASH_BOX_API_KEY; if (!apiKey) { throw new BoxError( @@ -1561,6 +1683,7 @@ export class Box { snapshot_id: snapshotId, }; if (config?.name) body.name = config.name; + if (config?.size) body.size = config.size; if (config?.agent) { body.model = config.agent.model; body.agent = config.agent.provider ?? config.agent.runner; @@ -1825,6 +1948,24 @@ export class Box { }); } + // ==================== Skills ==================== + + private async _skillAdd(skillId: string): Promise { + await this._request("POST", `/v2/box/${this.id}/config/skills`, { + body: { skill_id: skillId }, + }); + } + + private async _skillRemove(skillId: string): Promise { + // skill_id format: owner/repo/skill-name + await this._request("DELETE", `/v2/box/${this.id}/config/skills/${skillId}`); + } + + private async _skillList(): Promise { + const data = await this._request("GET", `/v2/box/${this.id}`); + return data.enabled_skills ?? []; + } + // ==================== Preview ==================== async getPreviewUrl( @@ -2044,6 +2185,7 @@ export class EphemeralBox { const body: Record = { ephemeral: true }; if (config?.name) body.name = config.name; + if (config?.size) body.size = config.size; if (config?.ttl !== undefined) body.ttl = config.ttl; if (config?.runtime) body.runtime = config.runtime; if (config?.env) body.env_vars = config.env; @@ -2111,6 +2253,7 @@ export class EphemeralBox { ephemeral: true, }; if (config?.name) body.name = config.name; + if (config?.size) body.size = config.size; if (config?.ttl !== undefined) body.ttl = config.ttl; if (config?.runtime) body.runtime = config.runtime; if (config?.env) body.env_vars = config.env; @@ -2144,6 +2287,11 @@ export class EphemeralBox { * Get an existing ephemeral box by name */ static getByName = Box.get; + + /** + * Delete specific boxes by ID. + */ + static delete = Box.delete; } // ==================== Helpers ==================== @@ -2192,6 +2340,94 @@ function deserializeNetworkPolicy(raw: BoxData["network_policy"]): NetworkPolicy return { mode: raw.mode }; } +/** Check whether PromptFiles are local file paths (string[]) or base64 data objects. */ +function isFilePaths(files: PromptFiles): files is string[] { + return typeof files[0] === "string"; +} + +const MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".csv": "text/csv", + ".txt": "text/plain", + ".json": "application/json", + ".xml": "application/xml", + ".html": "text/html", + ".md": "text/markdown", + ".ts": "text/plain", + ".js": "text/plain", + ".py": "text/plain", +}; + +/** + * Build a multipart FormData body from run options + local file paths. + * All options are sent as form fields; files as binary parts. + */ +async function buildMultipartBody( + requestBody: Record, + filePaths: string[], +): Promise { + const [fs, path] = await Promise.all([import("node:fs/promises"), import("node:path")]); + + const formData = new FormData(); + + // Add all scalar fields + for (const [key, value] of Object.entries(requestBody)) { + if (value === undefined) continue; + if (typeof value === "string") { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + } + + // Add files as binary parts + for (const filePath of filePaths) { + const buffer = await fs.readFile(filePath); + const filename = path.basename(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mimeType = MIME_TYPES[ext] ?? "application/octet-stream"; + formData.append("files", new Blob([buffer], { type: mimeType }), filename); + } + + return formData; +} + +/** + * Build the fetch body + headers for a run request. + * - file paths → multipart FormData + * - base64 objects → JSON with `files` array + * - no files → plain JSON + */ +async function buildRunRequest( + baseHeaders: Record, + requestBody: Record, + files?: PromptFiles, +): Promise<{ body: string | FormData; headers: Record }> { + if (files?.length) { + if (isFilePaths(files)) { + return { + body: await buildMultipartBody(requestBody, files), + headers: { ...baseHeaders }, + }; + } + requestBody.files = files.map((f) => ({ + data: f.data, + media_type: f.mediaType, + filename: f.filename, + })); + } + return { + body: JSON.stringify(requestBody), + headers: { ...baseHeaders, "Content-Type": "application/json" }, + }; +} + /** @internal */ export async function parseErrorResponse(response: Response): Promise { try { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1c2639..ae3989d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -18,10 +18,17 @@ export { export type { Runtime, + BoxSize, AgentConfig, + AgentOptions, + ClaudeCodeAgentOptions, + CodexAgentOptions, + OpenCodeAgentOptions, BoxConfig, + BoxConnectionOptions, BoxGetOptions, ListOptions, + PromptFiles, RunOptions, StreamOptions, Chunk, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index e0a7bd5..12c435a 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -5,6 +5,17 @@ import type { ZodType } from "zod/v3"; */ export type Runtime = "node" | "python" | "golang" | "ruby" | "rust"; +/** + * Resource size presets for boxes. + * + * | Size | CPU | Memory | + * |----------|----------|--------| + * | `small` | 2 cores | 2 GB | + * | `medium` | 4 cores | 8 GB | + * | `large` | 8 cores | 16 GB | + */ +export type BoxSize = "small" | "medium" | "large"; + /** * Agent SDKs available for boxes */ @@ -136,6 +147,74 @@ export type AgentConfig = { } ); +// ==================== Agent Options ==================== + +/** + * SDK-specific options forwarded to the Claude Code agent. + */ +export interface ClaudeCodeAgentOptions { + /** Max conversation turns */ + maxTurns?: number; + /** Max budget in USD */ + maxBudgetUsd?: number; + /** Thinking depth */ + effort?: "low" | "medium" | "high" | "max"; + /** Thinking configuration */ + thinking?: + | { type: "adaptive" } + | { type: "enabled"; budgetTokens: number } + | { type: "disabled" }; + /** Tools to deny */ + disallowedTools?: string[]; + /** Custom subagent definitions */ + agents?: Record; + /** Enable prompt suggestions */ + promptSuggestions?: boolean; + /** Fallback model */ + fallbackModel?: string; + /** Custom system prompt */ + systemPrompt?: string | Record; +} + +/** + * SDK-specific options forwarded to the Codex agent. + */ +export interface CodexAgentOptions { + /** Reasoning effort */ + modelReasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + /** Summary style */ + modelReasoningSummary?: "auto" | "concise" | "detailed" | "none"; + /** Agent personality */ + personality?: "friendly" | "pragmatic" | "none"; + /** Web search */ + webSearch?: "live" | boolean; +} + +/** + * SDK-specific options forwarded to the OpenCode agent. + */ +export interface OpenCodeAgentOptions { + /** Reasoning effort */ + reasoningEffort?: "low" | "medium" | "high"; + /** Output verbosity */ + textVerbosity?: "low" | "medium" | "high"; + /** Summary mode */ + reasoningSummary?: "auto" | "concise" | "detailed" | "none"; + /** Thinking configuration for Anthropic models */ + thinking?: { type: "enabled"; budgetTokens: number }; +} + +/** + * Resolves the correct agent options type based on the provider. + */ +export type AgentOptions = TProvider extends Agent.ClaudeCode + ? ClaudeCodeAgentOptions + : TProvider extends Agent.Codex + ? CodexAgentOptions + : TProvider extends Agent.OpenCode + ? OpenCodeAgentOptions + : Record; + /** * Network access policy for a box. * @@ -162,11 +241,12 @@ export type NetworkPolicy = deniedCidrs?: string[]; }; -export interface BoxConfig { - apiKey?: string; +export interface BoxConfig extends BoxConnectionOptions { /** Human-readable name for the box */ name?: string; runtime?: Runtime; + /** Resource size for the box. Defaults to `"small"`. */ + size?: BoxSize; agent?: AgentConfig; git?: { token?: string; @@ -209,7 +289,6 @@ export interface BoxConfig { */ skills?: string[]; mcpServers?: McpServerConfig[]; - baseUrl?: string; timeout?: number; debug?: boolean; } @@ -221,13 +300,13 @@ export interface BoxConfig { * exec and file operations. They are created synchronously (no polling) * and auto-delete after the configured TTL. */ -export interface EphemeralBoxConfig { - /** Upstash Box API key. Falls back to UPSTASH_BOX_API_KEY env var. */ - apiKey?: string; +export interface EphemeralBoxConfig extends BoxConnectionOptions { /** Human-readable name for the box */ name?: string; /** Runtime environment for the box. */ runtime?: Runtime; + /** Resource size for the box. Defaults to `"small"`. */ + size?: BoxSize; /** Time-to-live in seconds. Max 259200 (3 days). Defaults to 259200 if omitted. */ ttl?: number; /** Environment variables to inject into the box. */ @@ -242,8 +321,6 @@ export interface EphemeralBoxConfig { attachHeaders?: Record>; /** Network access policy — controls outbound connectivity */ networkPolicy?: NetworkPolicy; - /** Base URL of the Box API (defaults to https://us-east-1.box.upstash.com) */ - baseUrl?: string; /** Request timeout in milliseconds (defaults to 600000) */ timeout?: number; /** Enable debug logging */ @@ -321,12 +398,36 @@ export type Chunk = | { type: "stats"; cpuNs: number; memoryPeakBytes: number } | { type: "unknown"; event: string; data: unknown }; +/** + * Files to attach to a prompt. Two formats: + * + * - **Local file paths** (`string[]`) — read from disk and sent as multipart form data + * - **Base64 data** — sent inline as JSON + * + * Max 10 files, 10 MB each. + * + * @example Local files (multipart) + * ```ts + * { files: ["./screenshot.png", "./report.pdf"] } + * ``` + * + * @example Base64 (JSON) + * ```ts + * { files: [{ data: "iVBORw0KGgo...", mediaType: "image/png", filename: "screenshot.png" }] } + * ``` + */ +export type PromptFiles = string[] | { data: string; mediaType: string; filename?: string }[]; + /** * Options for streaming agent output */ -export interface StreamOptions { +export interface StreamOptions { /** The prompt/task for the AI agent */ prompt: string; + /** Files to attach to the prompt (images, PDFs, etc.) */ + files?: PromptFiles; + /** SDK-specific options forwarded to the underlying agent */ + options?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */ @@ -336,11 +437,15 @@ export interface StreamOptions { /** * Options for running a prompt */ -export interface RunOptions { +export interface RunOptions { /** The prompt/task for the AI agent */ prompt: string; /** Zod schema for structured output — typed, validated results */ responseSchema?: ZodType; + /** Files to attach to the prompt (images, PDFs, etc.) */ + files?: PromptFiles; + /** SDK-specific options forwarded to the underlying agent */ + options?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Retries with exponential backoff on transient failures */ @@ -414,30 +519,29 @@ export interface Snapshot { name: string; box_id: string; size_bytes: number; - image_url?: string; - s3_key?: string; status: "creating" | "ready" | "error" | "deleted"; created_at: number; } /** - * Options for listing boxes + * Shared connection options for static Box methods. */ -export interface ListOptions { +export interface BoxConnectionOptions { /** Upstash Box API key. Falls back to UPSTASH_BOX_API_KEY env var. */ apiKey?: string; - /** Base URL of the Box API (defaults to https://box.api.upstashdev.com) */ + /** Base URL of the Box API (defaults to https://us-east-1.box.upstash.com) */ baseUrl?: string; } +/** + * Options for listing boxes + */ +export interface ListOptions extends BoxConnectionOptions {} + /** * Options for getting/reconnecting to an existing box */ -export interface BoxGetOptions { - /** Upstash Box API key. Falls back to UPSTASH_BOX_API_KEY env var. */ - apiKey?: string; - /** Base URL of the Box API (defaults to https://box.api.upstashdev.com) */ - baseUrl?: string; +export interface BoxGetOptions extends BoxConnectionOptions { /** GitHub personal access token */ gitToken?: string; /** Request timeout in milliseconds (defaults to 600000) */ @@ -489,8 +593,10 @@ export type BoxData = { id: string; customer_id?: string; name?: string; + size?: BoxSize; model?: string; agent?: Agent; + enabled_skills?: string[]; runtime?: string; status: BoxStatus; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aa81a8..0ba5abf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: specifier: ^17.3.1 version: 17.3.1 vitest: - specifier: ^4.1.1 + specifier: ^4.1.2 version: 4.1.2(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)) packages/cli: