From e39f7b3dbe6e8a97429306249036733dbf11df2a Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 27 Mar 2026 16:06:46 +0300 Subject: [PATCH 01/14] feat: add configurable box sizes (small, medium, large) Boxes can now be created with a `size` option that controls CPU and memory allocation. Supported in Box.create() and Box.fromSnapshot(). The size is exposed as a readonly property on the Box instance and defaults to "small". Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/box-sizes.md | 5 ++ packages/sdk/README.md | 25 ++++++++++ packages/sdk/src/__tests__/box-create.test.ts | 29 +++++++++++ .../src/__tests__/box-from-snapshot.test.ts | 21 ++++++++ .../integration/box-size.integration.test.ts | 50 +++++++++++++++++++ packages/sdk/src/client.ts | 7 +++ packages/sdk/src/index.ts | 1 + packages/sdk/src/types.ts | 18 +++++-- 8 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 .changeset/box-sizes.md create mode 100644 packages/sdk/src/__tests__/integration/box-size.integration.test.ts 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/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-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-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__/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/client.ts b/packages/sdk/src/client.ts index a990717..9aab953 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -4,6 +4,7 @@ import { type BoxData, type BoxGetOptions, type BoxRunData, + type BoxSize, type ListOptions, type RunOptions, type StreamOptions, @@ -231,6 +232,9 @@ export class StreamRun extends Run implements AsyncIte 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; @@ -359,6 +363,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; @@ -454,6 +459,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; @@ -1561,6 +1567,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; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1c2639..1d8c4a7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -18,6 +18,7 @@ export { export type { Runtime, + BoxSize, AgentConfig, BoxConfig, BoxGetOptions, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index e0a7bd5..9a922a7 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 */ @@ -167,6 +178,8 @@ export interface BoxConfig { /** 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; @@ -414,13 +427,11 @@ 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; } -/** +/**ListOptions * Options for listing boxes */ export interface ListOptions { @@ -489,6 +500,7 @@ export type BoxData = { id: string; customer_id?: string; name?: string; + size?: BoxSize; model?: string; agent?: Agent; runtime?: string; From 8d3d907c1f6978cdc713412a35cc126a022b6c15 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 27 Mar 2026 16:26:40 +0300 Subject: [PATCH 02/14] feat: add Box.delete() and Box.deleteAll() static methods Extract BoxConnectionOptions as shared base for BoxConfig, EphemeralBoxConfig, ListOptions, and BoxGetOptions. Add static delete()/deleteAll() to both Box and EphemeralBox for bulk deletion of boxes by ID or all at once. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/delete-boxes.md | 5 + packages/sdk/src/__tests__/box-delete.test.ts | 99 +++++++++++++++++++ .../sdk/src/__tests__/ephemeral-box.test.ts | 54 ++++++++++ .../box-delete.integration.test.ts | 42 ++++++++ packages/sdk/src/client.ts | 74 ++++++++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/src/types.ts | 29 +++--- 7 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 .changeset/delete-boxes.md create mode 100644 packages/sdk/src/__tests__/box-delete.test.ts create mode 100644 packages/sdk/src/__tests__/integration/box-delete.integration.test.ts diff --git a/.changeset/delete-boxes.md b/.changeset/delete-boxes.md new file mode 100644 index 0000000..b1591a5 --- /dev/null +++ b/.changeset/delete-boxes.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add Box.delete() and Box.deleteAll() static methods for bulk box deletion 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..00ccebe --- /dev/null +++ b/packages/sdk/src/__tests__/box-delete.test.ts @@ -0,0 +1,99 @@ +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"); + }); +}); + +describe("Box.deleteAll", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + delete process.env.UPSTASH_BOX_API_KEY; + }); + afterEach(() => vi.restoreAllMocks()); + + it("sends DELETE without body", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); + + await Box.deleteAll({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]!; + expect(url).toBe(`${TEST_CONFIG.baseUrl}/v2/box`); + expect(init?.method).toBe("DELETE"); + expect(init?.body).toBeUndefined(); + }); + + it("throws when apiKey is missing", async () => { + await expect(Box.deleteAll()).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.deleteAll(); + + 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: "unauthorized" }, 401)); + + await expect( + Box.deleteAll({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl }), + ).rejects.toThrow("unauthorized"); + }); +}); diff --git a/packages/sdk/src/__tests__/ephemeral-box.test.ts b/packages/sdk/src/__tests__/ephemeral-box.test.ts index 4c4397a..9181e75 100644 --- a/packages/sdk/src/__tests__/ephemeral-box.test.ts +++ b/packages/sdk/src/__tests__/ephemeral-box.test.ts @@ -283,3 +283,57 @@ 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"); + }); +}); + +describe("EphemeralBox.deleteAll", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + delete process.env.UPSTASH_BOX_API_KEY; + }); + afterEach(() => vi.restoreAllMocks()); + + it("sends DELETE without body", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); + + await EphemeralBox.deleteAll({ + apiKey: EPHEMERAL_CONFIG.apiKey, + baseUrl: EPHEMERAL_CONFIG.baseUrl, + }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]!; + expect(url).toBe(`${EPHEMERAL_CONFIG.baseUrl}/v2/box`); + expect(init?.method).toBe("DELETE"); + expect(init?.body).toBeUndefined(); + }); + + it("throws when apiKey is missing", async () => { + await expect(EphemeralBox.deleteAll()).rejects.toThrow("apiKey is required"); + }); +}); 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..51f9715 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts @@ -0,0 +1,42 @@ +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); +}); + +describe.skipIf(!UPSTASH_BOX_API_KEY)("Box.deleteAll", () => { + it("deletes all boxes", async () => { + // Create a couple of throwaway boxes + await Promise.all([ + EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), + EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), + ]); + + await Box.deleteAll({ apiKey: UPSTASH_BOX_API_KEY! }); + + const boxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); + expect(boxes).toHaveLength(0); + }, 60000); +}); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 9aab953..c79b688 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,6 +1,7 @@ import { zodToJsonSchema as zodToJsonSchemaLib } from "zod-to-json-schema"; import { type BoxConfig, + type BoxConnectionOptions, type BoxData, type BoxGetOptions, type BoxRunData, @@ -552,6 +553,69 @@ 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); + } + } + + /** + * Delete all boxes for the authenticated user. + */ + static async deleteAll(options?: BoxConnectionOptions): 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 }; + + const response = await fetch(`${baseUrl}/v2/box`, { + method: "DELETE", + headers, + }); + if (!response.ok) { + const msg = await parseErrorResponse(response); + throw new BoxError(msg, response.status); + } + } + /** * Get an existing box by ID */ @@ -2151,6 +2215,16 @@ export class EphemeralBox { * Get an existing ephemeral box by name */ static getByName = Box.get; + + /** + * Delete specific boxes by ID. + */ + static delete = Box.delete; + + /** + * Delete all boxes for the authenticated user. + */ + static deleteAll = Box.deleteAll; } // ==================== Helpers ==================== diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 1d8c4a7..5e6fa91 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -21,6 +21,7 @@ export type { BoxSize, AgentConfig, BoxConfig, + BoxConnectionOptions, BoxGetOptions, ListOptions, RunOptions, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 9a922a7..f0321ad 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -173,8 +173,7 @@ 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; @@ -222,7 +221,6 @@ export interface BoxConfig { */ skills?: string[]; mcpServers?: McpServerConfig[]; - baseUrl?: string; timeout?: number; debug?: boolean; } @@ -234,9 +232,7 @@ 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. */ @@ -255,8 +251,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 */ @@ -431,24 +425,25 @@ export interface Snapshot { created_at: number; } -/**ListOptions - * 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) */ From 633234d4ee4be850705d78970db7828e62a10211 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Fri, 27 Mar 2026 18:01:00 +0300 Subject: [PATCH 03/14] feat: add type-safe agentOptions for run and stream Box is now generic on TProvider (Box, Box, Box) so agentOptions in run/stream are type-checked per provider. Options are forwarded as agent_options in the API request body. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/agent-options.md | 5 + .../memory/MEMORY.md | 1 + .../memory/feedback_api_design.md | 12 ++ .../sdk/src/__tests__/box-agent-run.test.ts | 164 ++++++++++++++++++ packages/sdk/src/__tests__/helpers.ts | 6 +- .../agent-options.integration.test.ts | 53 ++++++ packages/sdk/src/client.ts | 22 ++- packages/sdk/src/index.ts | 4 + packages/sdk/src/types.ts | 76 +++++++- 9 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 .changeset/agent-options.md create mode 100644 .claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md create mode 100644 .claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md create mode 100644 packages/sdk/src/__tests__/integration/agent-options.integration.test.ts diff --git a/.changeset/agent-options.md b/.changeset/agent-options.md new file mode 100644 index 0000000..1811724 --- /dev/null +++ b/.changeset/agent-options.md @@ -0,0 +1,5 @@ +--- +"@upstash/box": patch +--- + +Add type-safe agentOptions to RunOptions and StreamOptions for passing SDK-specific options to Claude Code, Codex, and OpenCode agents diff --git a/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md b/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md new file mode 100644 index 0000000..176f544 --- /dev/null +++ b/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md @@ -0,0 +1 @@ +- [API design preferences](feedback_api_design.md) — Separate methods over overloads; single merged param object diff --git a/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md b/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md new file mode 100644 index 0000000..f84070a --- /dev/null +++ b/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md @@ -0,0 +1,12 @@ +--- +name: API design preferences +description: User prefers separate methods over overloaded signatures, and merged parameter objects over multiple params +type: feedback +--- + +Prefer separate methods over overloaded/union parameter signatures. e.g. `delete()` + `deleteAll()` rather than `delete({ all: true } | { boxIds: ... })`. + +Also prefer merging target and options into a single parameter object rather than having `(target, options?)` signatures. + +**Why:** Cleaner API surface, easier to understand at call site. +**How to apply:** When designing SDK methods, use distinct method names for distinct behaviors. Combine all options into one parameter object. diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index 88e3066..cfef0be 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"; @@ -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", + agentOptions: { + 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(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + await box.agent.run({ + prompt: "test", + agentOptions: { + model_reasoning_effort: "high", + personality: "pragmatic", + web_search: 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", + agentOptions: { + 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", + agentOptions: { 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,50 @@ 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", + agentOptions: { 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(); + + fetchMock.mockResolvedValueOnce( + mockSSEResponse([ + { event: "run_start", data: { run_id: "r1" } }, + { event: "done", data: { output: "ok" } }, + ]), + ); + + const run = await box.agent.stream({ + prompt: "test", + agentOptions: { model_reasoning_effort: "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__/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..0bbd13f --- /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)("agentOptions", () => { + 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 agentOptions to a run", async () => { + const run = await box.agent.run({ + prompt: "Reply with exactly: AGENT_OPTIONS_TEST", + agentOptions: { + maxTurns: 2, + effort: "low", + }, + }); + + expect(run.status).toBe("completed"); + expect(run.result).toContain("AGENT_OPTIONS_TEST"); + }, 120000); + + it("passes agentOptions to a stream", async () => { + const run = await box.agent.stream({ + prompt: "Reply with exactly: STREAM_OPTIONS_TEST", + agentOptions: { + 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/client.ts b/packages/sdk/src/client.ts index c79b688..95dced0 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -230,7 +230,7 @@ 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"`). */ @@ -244,10 +244,10 @@ 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 */ @@ -437,7 +437,7 @@ export class Box { /** * 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( @@ -619,7 +619,7 @@ export class Box { /** * 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( @@ -689,6 +689,7 @@ export class Box { requestBody.json_schema = jsonSchema; } } + if (options.agentOptions) requestBody.agent_options = options.agentOptions; requestBody.webhook = options.webhook.headers ? { url: options.webhook.url, headers: options.webhook.headers } : { url: options.webhook.url }; @@ -749,6 +750,7 @@ export class Box { requestBody.json_schema = jsonSchema; } } + if (options.agentOptions) requestBody.agent_options = options.agentOptions; const url = `${this._baseUrl}/v2/box/${this.id}/run/stream`; const response = await fetch(url, { @@ -883,11 +885,15 @@ export class Box { } const folder = this._getFolder(); + const requestBody: Record = { prompt: options.prompt }; + if (folder) requestBody.folder = folder; + if (options.agentOptions) requestBody.agent_options = options.agentOptions; + const url = `${this._baseUrl}/v2/box/${this.id}/run/stream`; const response = await fetch(url, { method: "POST", headers: { ...this._headers, "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: options.prompt, ...(folder ? { folder } : {}) }), + body: JSON.stringify(requestBody), signal: abortController.signal, }); @@ -1611,7 +1617,7 @@ 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( diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5e6fa91..865e8d5 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,6 +20,10 @@ export type { Runtime, BoxSize, AgentConfig, + AgentOptions, + ClaudeCodeAgentOptions, + CodexAgentOptions, + OpenCodeAgentOptions, BoxConfig, BoxConnectionOptions, BoxGetOptions, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index f0321ad..84508dc 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -147,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 */ + model_reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + /** Summary style */ + model_reasoning_summary?: "auto" | "concise" | "detailed" | "none"; + /** Agent personality */ + personality?: "friendly" | "pragmatic" | "none"; + /** Web search */ + web_search?: "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. * @@ -331,9 +399,11 @@ export type Chunk = /** * Options for streaming agent output */ -export interface StreamOptions { +export interface StreamOptions { /** The prompt/task for the AI agent */ prompt: string; + /** SDK-specific options forwarded to the underlying agent */ + agentOptions?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */ @@ -343,11 +413,13 @@ 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; + /** SDK-specific options forwarded to the underlying agent */ + agentOptions?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Retries with exponential backoff on transient failures */ From f982d5c940da569cde144df31ca577640bf41c58 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Mon, 30 Mar 2026 17:47:53 +0300 Subject: [PATCH 04/14] feat: add box.skills namespace for platform skills API Add skills.add(), skills.remove(), and skills.list() methods for managing Context7 registry skills on a box. Skills are added via POST, removed via DELETE, and listed by fetching the box's enabled_skills from the API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/skills-api.md | 5 + .../sdk/src/__tests__/box-agent-run.test.ts | 5 +- packages/sdk/src/__tests__/box-skills.test.ts | 97 +++++++++++++++++++ .../skills-api.integration.test.ts | 48 +++++++++ packages/sdk/src/client.ts | 48 ++++++++- packages/sdk/src/types.ts | 1 + 6 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 .changeset/skills-api.md create mode 100644 packages/sdk/src/__tests__/box-skills.test.ts create mode 100644 packages/sdk/src/__tests__/integration/skills-api.integration.test.ts 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/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index cfef0be..03ac5ef 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -623,6 +623,9 @@ describe("box.agent.stream", () => { 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" }); + expect(body.agent_options).toEqual({ + model_reasoning_effort: "medium", + personality: "friendly", + }); }); }); 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__/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/client.ts b/packages/sdk/src/client.ts index 95dced0..8ce8eec 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -244,7 +244,9 @@ 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>; @@ -306,6 +308,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. @@ -432,6 +444,12 @@ 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(), + }; } /** @@ -619,7 +637,10 @@ export class Box { /** * 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( @@ -1617,7 +1638,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( @@ -1902,6 +1926,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( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 84508dc..28d8803 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -570,6 +570,7 @@ export type BoxData = { size?: BoxSize; model?: string; agent?: Agent; + enabled_skills?: string[]; runtime?: string; status: BoxStatus; /** From 15ee04e70ff6437c9ca88b7254d662c6b1f10509 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Mon, 30 Mar 2026 20:31:38 +0300 Subject: [PATCH 05/14] feat: add multi-modal prompt files for run and stream Support attaching files to prompts via two formats: - Local file paths (string[]) sent as multipart form data - Base64 data objects sent inline as JSON with media_type wire format Both formats work with box.agent.run() and box.agent.stream(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/prompt-files.md | 5 + .../src/__tests__/box-prompt-files.test.ts | 228 ++++++++++++++++++ .../__tests__/integration/fixtures/sample.csv | 4 + .../prompt-files.integration.test.ts | 108 +++++++++ packages/sdk/src/client.ts | 98 +++++++- packages/sdk/src/index.ts | 1 + packages/sdk/src/types.ts | 24 ++ 7 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 .changeset/prompt-files.md create mode 100644 packages/sdk/src/__tests__/box-prompt-files.test.ts create mode 100644 packages/sdk/src/__tests__/integration/fixtures/sample.csv create mode 100644 packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts 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/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..c9c8ae3 --- /dev/null +++ b/packages/sdk/src/__tests__/box-prompt-files.test.ts @@ -0,0 +1,228 @@ +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); + }); + + 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], + agentOptions: { maxTurns: 3 }, + }); + + const formData = fetchMock.mock.calls[1]![1].body as FormData; + expect(formData.get("agent_options")).toBe(JSON.stringify({ maxTurns: 3 })); + }); + + 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__/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/prompt-files.integration.test.ts b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts new file mode 100644 index 0000000..2ceda7b --- /dev/null +++ b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts @@ -0,0 +1,108 @@ +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"); + +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" }], + agentOptions: { 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" }], + agentOptions: { 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], + agentOptions: { 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], + agentOptions: { 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/client.ts b/packages/sdk/src/client.ts index 8ce8eec..f67cf7d 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -7,6 +7,7 @@ import { type BoxRunData, type BoxSize, type ListOptions, + type PromptFiles, type RunOptions, type StreamOptions, type Chunk, @@ -716,10 +717,16 @@ export class Box { : { 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) { @@ -774,10 +781,16 @@ export class Box { if (options.agentOptions) requestBody.agent_options = options.agentOptions; 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, }); @@ -911,10 +924,16 @@ export class Box { if (options.agentOptions) requestBody.agent_options = options.agentOptions; 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, }); @@ -2321,6 +2340,73 @@ 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"; +} + +/** + * 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); + formData.append("files", new Blob([buffer]), 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 865e8d5..ae3989d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -28,6 +28,7 @@ export type { BoxConnectionOptions, BoxGetOptions, ListOptions, + PromptFiles, RunOptions, StreamOptions, Chunk, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 28d8803..8aabe19 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -396,12 +396,34 @@ 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 { /** 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 */ agentOptions?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ @@ -418,6 +440,8 @@ export interface RunOptions { 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 */ agentOptions?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ From b82ab80164a423ce63530f034ab033e7a1cae64e Mon Sep 17 00:00:00 2001 From: CahidArda Date: Mon, 30 Mar 2026 21:08:13 +0300 Subject: [PATCH 06/14] feat: add size option to EphemeralBox creation Support size parameter in EphemeralBox.create() and EphemeralBox.fromSnapshot() for configuring CPU and memory allocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/src/__tests__/ephemeral-box.test.ts | 18 ++++++++ .../ephemeral-size.integration.test.ts | 43 +++++++++++++++++++ packages/sdk/src/client.ts | 2 + packages/sdk/src/types.ts | 2 + 4 files changed, 65 insertions(+) create mode 100644 packages/sdk/src/__tests__/integration/ephemeral-size.integration.test.ts diff --git a/packages/sdk/src/__tests__/ephemeral-box.test.ts b/packages/sdk/src/__tests__/ephemeral-box.test.ts index 9181e75..7da0709 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)); 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/client.ts b/packages/sdk/src/client.ts index f67cf7d..a6ed7d5 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2182,6 +2182,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; @@ -2249,6 +2250,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; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 8aabe19..de7ddf9 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -305,6 +305,8 @@ export interface EphemeralBoxConfig extends BoxConnectionOptions { 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. */ From 4a3483e7113af9e5d9162db0373f276855c34535 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Mon, 30 Mar 2026 21:46:10 +0300 Subject: [PATCH 07/14] chore: run deleteAll integration test separately before other tests Split Box.deleteAll test into its own file and vitest config so it runs first via a separate vitest invocation, preventing it from wiping boxes that other parallel integration tests depend on. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../memory/MEMORY.md | 1 - .../memory/feedback_api_design.md | 12 ------------ .github/workflows/integration.yml | 2 +- package.json | 2 +- packages/sdk/package.json | 2 +- .../integration/box-delete.integration.test.ts | 15 --------------- .../integration/delete-all.integration.test.ts | 18 ++++++++++++++++++ packages/sdk/vitest.delete-all.config.ts | 12 ++++++++++++ packages/sdk/vitest.integration.config.ts | 1 + pnpm-lock.yaml | 2 +- 10 files changed, 35 insertions(+), 32 deletions(-) delete mode 100644 .claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md delete mode 100644 .claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md create mode 100644 packages/sdk/src/__tests__/integration/delete-all.integration.test.ts create mode 100644 packages/sdk/vitest.delete-all.config.ts diff --git a/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md b/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md deleted file mode 100644 index 176f544..0000000 --- a/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/MEMORY.md +++ /dev/null @@ -1 +0,0 @@ -- [API design preferences](feedback_api_design.md) — Separate methods over overloads; single merged param object diff --git a/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md b/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md deleted file mode 100644 index f84070a..0000000 --- a/.claude/projects/-Users-ardaoz-Desktop-sdk-box-sdk-sdk/memory/feedback_api_design.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: API design preferences -description: User prefers separate methods over overloaded signatures, and merged parameter objects over multiple params -type: feedback ---- - -Prefer separate methods over overloaded/union parameter signatures. e.g. `delete()` + `deleteAll()` rather than `delete({ all: true } | { boxIds: ... })`. - -Also prefer merging target and options into a single parameter object rather than having `(target, options?)` signatures. - -**Why:** Cleaner API surface, easier to understand at call site. -**How to apply:** When designing SDK methods, use distinct method names for distinct behaviors. Combine all options into one parameter object. 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/package.json b/packages/sdk/package.json index 9fc88ca..e6df0fe 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -19,7 +19,7 @@ "build": "tsc", "dev": "tsc --watch", "test": "vitest run", - "test:integration": "vitest run -c vitest.integration.config.ts", + "test:integration": "vitest run -c vitest.delete-all.config.ts && vitest run -c vitest.integration.config.ts", "example": "tsx examples/basic.ts", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts b/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts index 51f9715..1cecb05 100644 --- a/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/box-delete.integration.test.ts @@ -25,18 +25,3 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("Box.delete (static)", () => { expect(boxes.find((b) => b.id === b2.id)).toBeUndefined(); }, 60000); }); - -describe.skipIf(!UPSTASH_BOX_API_KEY)("Box.deleteAll", () => { - it("deletes all boxes", async () => { - // Create a couple of throwaway boxes - await Promise.all([ - EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), - EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), - ]); - - await Box.deleteAll({ apiKey: UPSTASH_BOX_API_KEY! }); - - const boxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); - expect(boxes).toHaveLength(0); - }, 60000); -}); diff --git a/packages/sdk/src/__tests__/integration/delete-all.integration.test.ts b/packages/sdk/src/__tests__/integration/delete-all.integration.test.ts new file mode 100644 index 0000000..cc41a36 --- /dev/null +++ b/packages/sdk/src/__tests__/integration/delete-all.integration.test.ts @@ -0,0 +1,18 @@ +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.deleteAll", () => { + it("deletes all boxes", async () => { + // Create a couple of throwaway boxes + await Promise.all([ + EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), + EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), + ]); + + await Box.deleteAll({ apiKey: UPSTASH_BOX_API_KEY! }); + + const boxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); + expect(boxes).toHaveLength(0); + }, 60000); +}); diff --git a/packages/sdk/vitest.delete-all.config.ts b/packages/sdk/vitest.delete-all.config.ts new file mode 100644 index 0000000..2b9f701 --- /dev/null +++ b/packages/sdk/vitest.delete-all.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + include: [resolve(__dirname, "src/**/delete-all.integration.test.ts")], + testTimeout: 120000, + }, +}); diff --git a/packages/sdk/vitest.integration.config.ts b/packages/sdk/vitest.integration.config.ts index e4e9b2a..47c23e7 100644 --- a/packages/sdk/vitest.integration.config.ts +++ b/packages/sdk/vitest.integration.config.ts @@ -7,6 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { include: [resolve(__dirname, "src/**/*.integration.test.ts")], + exclude: [resolve(__dirname, "src/**/delete-all.integration.test.ts")], testTimeout: 120000, }, }); 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: From ca7f324656afb37fcd456081e5aefe7f577f7a9b Mon Sep 17 00:00:00 2001 From: CahidArda Date: Tue, 31 Mar 2026 15:17:28 +0300 Subject: [PATCH 08/14] fix: rm ping --- .../sdk/src/__tests__/integration/exec.integration.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 () => { From c0d4dfc6fd522d03bbc9f0a5e762a3d2833c3f16 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Tue, 31 Mar 2026 16:08:41 +0300 Subject: [PATCH 09/14] feat: add sample TXT file and tests for uploading in run and stream --- .../__tests__/integration/fixtures/sample.txt | 4 ++ .../prompt-files.integration.test.ts | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/sdk/src/__tests__/integration/fixtures/sample.txt 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 index 2ceda7b..102eef4 100644 --- a/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts @@ -6,6 +6,7 @@ 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; @@ -106,3 +107,50 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (multipart)", 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], + agentOptions: { 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], + agentOptions: { 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); +}); From 1ca635d67aac7a11d203f396e99d5afc914c36d4 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Tue, 31 Mar 2026 20:20:47 +0300 Subject: [PATCH 10/14] fix: set correct MIME type on multipart file uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blob was created without a type, causing Node.js to send Content-Type: application/octet-stream — which the server silently ignores. Now infers the MIME type from the file extension. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/__tests__/box-prompt-files.test.ts | 2 ++ packages/sdk/src/client.ts | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/__tests__/box-prompt-files.test.ts b/packages/sdk/src/__tests__/box-prompt-files.test.ts index c9c8ae3..1f91801 100644 --- a/packages/sdk/src/__tests__/box-prompt-files.test.ts +++ b/packages/sdk/src/__tests__/box-prompt-files.test.ts @@ -153,6 +153,8 @@ describe("box.agent.run — files (multipart file paths)", () => { 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 () => { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index a6ed7d5..2556163 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2347,6 +2347,25 @@ 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. @@ -2373,7 +2392,9 @@ async function buildMultipartBody( for (const filePath of filePaths) { const buffer = await fs.readFile(filePath); const filename = path.basename(filePath); - formData.append("files", new Blob([buffer]), filename); + 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; From 0a1205431537a813beb8f6fa1388bfb3c5a78290 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Tue, 31 Mar 2026 22:25:04 +0300 Subject: [PATCH 11/14] refactor: remove deleteAll method and rename agentOptions to options Remove Box.deleteAll/EphemeralBox.deleteAll and all associated tests, config, and changeset references. Rename agentOptions to options in RunOptions and StreamOptions interfaces. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/agent-options.md | 2 +- .changeset/delete-boxes.md | 2 +- packages/sdk/package.json | 2 +- .../sdk/src/__tests__/box-agent-run.test.ts | 12 +++--- packages/sdk/src/__tests__/box-delete.test.ts | 41 ------------------- .../src/__tests__/box-prompt-files.test.ts | 2 +- .../sdk/src/__tests__/ephemeral-box.test.ts | 26 ------------ .../agent-options.integration.test.ts | 10 ++--- .../delete-all.integration.test.ts | 18 -------- .../prompt-files.integration.test.ts | 12 +++--- packages/sdk/src/client.ts | 39 ++---------------- packages/sdk/src/types.ts | 4 +- packages/sdk/vitest.delete-all.config.ts | 12 ------ packages/sdk/vitest.integration.config.ts | 1 - 14 files changed, 26 insertions(+), 157 deletions(-) delete mode 100644 packages/sdk/src/__tests__/integration/delete-all.integration.test.ts delete mode 100644 packages/sdk/vitest.delete-all.config.ts diff --git a/.changeset/agent-options.md b/.changeset/agent-options.md index 1811724..010c8fe 100644 --- a/.changeset/agent-options.md +++ b/.changeset/agent-options.md @@ -2,4 +2,4 @@ "@upstash/box": patch --- -Add type-safe agentOptions to RunOptions and StreamOptions for passing SDK-specific options to Claude Code, Codex, and OpenCode agents +Add type-safe options to RunOptions and StreamOptions for passing SDK-specific options to Claude Code, Codex, and OpenCode agents diff --git a/.changeset/delete-boxes.md b/.changeset/delete-boxes.md index b1591a5..c98d436 100644 --- a/.changeset/delete-boxes.md +++ b/.changeset/delete-boxes.md @@ -2,4 +2,4 @@ "@upstash/box": patch --- -Add Box.delete() and Box.deleteAll() static methods for bulk box deletion +Add Box.delete() static method for bulk box deletion diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e6df0fe..9fc88ca 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -19,7 +19,7 @@ "build": "tsc", "dev": "tsc --watch", "test": "vitest run", - "test:integration": "vitest run -c vitest.delete-all.config.ts && vitest run -c vitest.integration.config.ts", + "test:integration": "vitest run -c vitest.integration.config.ts", "example": "tsx examples/basic.ts", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index 03ac5ef..f7e4dbf 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -299,7 +299,7 @@ describe("box.agent.run", () => { await box.agent.run({ prompt: "test", - agentOptions: { + options: { maxTurns: 5, effort: "max", thinking: { type: "enabled", budgetTokens: 16000 }, @@ -327,7 +327,7 @@ describe("box.agent.run", () => { await box.agent.run({ prompt: "test", - agentOptions: { + options: { model_reasoning_effort: "high", personality: "pragmatic", web_search: true, @@ -355,7 +355,7 @@ describe("box.agent.run", () => { await box.agent.run({ prompt: "test", - agentOptions: { + options: { reasoningEffort: "high", textVerbosity: "low", reasoningSummary: "concise", @@ -395,7 +395,7 @@ describe("box.agent.run", () => { await box.agent.run({ prompt: "test", - agentOptions: { maxTurns: 3 }, + options: { maxTurns: 3 }, webhook: { url: "https://example.com/hook" }, }); @@ -592,7 +592,7 @@ describe("box.agent.stream", () => { const run = await box.agent.stream({ prompt: "test", - agentOptions: { reasoningEffort: "high", textVerbosity: "low" }, + options: { reasoningEffort: "high", textVerbosity: "low" }, }); for await (const _ of run) { // consume @@ -615,7 +615,7 @@ describe("box.agent.stream", () => { const run = await box.agent.stream({ prompt: "test", - agentOptions: { model_reasoning_effort: "medium", personality: "friendly" }, + options: { model_reasoning_effort: "medium", personality: "friendly" }, }); for await (const _ of run) { // consume diff --git a/packages/sdk/src/__tests__/box-delete.test.ts b/packages/sdk/src/__tests__/box-delete.test.ts index 00ccebe..8fe2328 100644 --- a/packages/sdk/src/__tests__/box-delete.test.ts +++ b/packages/sdk/src/__tests__/box-delete.test.ts @@ -56,44 +56,3 @@ describe("Box.delete (static)", () => { ).rejects.toThrow("not found"); }); }); - -describe("Box.deleteAll", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - delete process.env.UPSTASH_BOX_API_KEY; - }); - afterEach(() => vi.restoreAllMocks()); - - it("sends DELETE without body", async () => { - vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); - - await Box.deleteAll({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl }); - - const [url, init] = vi.mocked(fetch).mock.calls[0]!; - expect(url).toBe(`${TEST_CONFIG.baseUrl}/v2/box`); - expect(init?.method).toBe("DELETE"); - expect(init?.body).toBeUndefined(); - }); - - it("throws when apiKey is missing", async () => { - await expect(Box.deleteAll()).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.deleteAll(); - - 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: "unauthorized" }, 401)); - - await expect( - Box.deleteAll({ apiKey: TEST_CONFIG.apiKey, baseUrl: TEST_CONFIG.baseUrl }), - ).rejects.toThrow("unauthorized"); - }); -}); diff --git a/packages/sdk/src/__tests__/box-prompt-files.test.ts b/packages/sdk/src/__tests__/box-prompt-files.test.ts index 1f91801..690f7fa 100644 --- a/packages/sdk/src/__tests__/box-prompt-files.test.ts +++ b/packages/sdk/src/__tests__/box-prompt-files.test.ts @@ -170,7 +170,7 @@ describe("box.agent.run — files (multipart file paths)", () => { await box.agent.run({ prompt: "Analyze", files: [FIXTURE_CSV], - agentOptions: { maxTurns: 3 }, + options: { maxTurns: 3 }, }); const formData = fetchMock.mock.calls[1]![1].body as FormData; diff --git a/packages/sdk/src/__tests__/ephemeral-box.test.ts b/packages/sdk/src/__tests__/ephemeral-box.test.ts index 7da0709..62e251c 100644 --- a/packages/sdk/src/__tests__/ephemeral-box.test.ts +++ b/packages/sdk/src/__tests__/ephemeral-box.test.ts @@ -329,29 +329,3 @@ describe("EphemeralBox.delete (static)", () => { await expect(EphemeralBox.delete({ boxIds: "box-1" })).rejects.toThrow("apiKey is required"); }); }); - -describe("EphemeralBox.deleteAll", () => { - beforeEach(() => { - vi.stubGlobal("fetch", vi.fn()); - delete process.env.UPSTASH_BOX_API_KEY; - }); - afterEach(() => vi.restoreAllMocks()); - - it("sends DELETE without body", async () => { - vi.mocked(fetch).mockResolvedValueOnce(mockResponse({})); - - await EphemeralBox.deleteAll({ - apiKey: EPHEMERAL_CONFIG.apiKey, - baseUrl: EPHEMERAL_CONFIG.baseUrl, - }); - - const [url, init] = vi.mocked(fetch).mock.calls[0]!; - expect(url).toBe(`${EPHEMERAL_CONFIG.baseUrl}/v2/box`); - expect(init?.method).toBe("DELETE"); - expect(init?.body).toBeUndefined(); - }); - - it("throws when apiKey is missing", async () => { - await expect(EphemeralBox.deleteAll()).rejects.toThrow("apiKey is required"); - }); -}); diff --git a/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts b/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts index 0bbd13f..ea6d907 100644 --- a/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/agent-options.integration.test.ts @@ -2,7 +2,7 @@ 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)("agentOptions", () => { +describe.skipIf(!UPSTASH_BOX_API_KEY)("options", () => { let box: Box; beforeAll(async () => { @@ -20,10 +20,10 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("agentOptions", () => { } }, 30000); - it("passes agentOptions to a run", async () => { + it("passes options to a run", async () => { const run = await box.agent.run({ prompt: "Reply with exactly: AGENT_OPTIONS_TEST", - agentOptions: { + options: { maxTurns: 2, effort: "low", }, @@ -33,10 +33,10 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("agentOptions", () => { expect(run.result).toContain("AGENT_OPTIONS_TEST"); }, 120000); - it("passes agentOptions to a stream", async () => { + it("passes options to a stream", async () => { const run = await box.agent.stream({ prompt: "Reply with exactly: STREAM_OPTIONS_TEST", - agentOptions: { + options: { maxTurns: 2, effort: "low", }, diff --git a/packages/sdk/src/__tests__/integration/delete-all.integration.test.ts b/packages/sdk/src/__tests__/integration/delete-all.integration.test.ts deleted file mode 100644 index cc41a36..0000000 --- a/packages/sdk/src/__tests__/integration/delete-all.integration.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -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.deleteAll", () => { - it("deletes all boxes", async () => { - // Create a couple of throwaway boxes - await Promise.all([ - EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), - EphemeralBox.create({ apiKey: UPSTASH_BOX_API_KEY!, ttl: 300 }), - ]); - - await Box.deleteAll({ apiKey: UPSTASH_BOX_API_KEY! }); - - const boxes = await Box.list({ apiKey: UPSTASH_BOX_API_KEY! }); - expect(boxes).toHaveLength(0); - }, 60000); -}); diff --git a/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts index 102eef4..6465ae0 100644 --- a/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/prompt-files.integration.test.ts @@ -34,7 +34,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — base64 JSON", () => { 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" }], - agentOptions: { maxTurns: 1 }, + options: { maxTurns: 1 }, }); expect(run.status).toBe("completed"); @@ -48,7 +48,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — base64 JSON", () => { const run = await box.agent.stream({ prompt: "What color is this image? Reply with ONLY the color name.", files: [{ data: tinyPng, mediaType: "image/png" }], - agentOptions: { maxTurns: 1 }, + options: { maxTurns: 1 }, }); let output = ""; @@ -84,7 +84,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (multipart)", prompt: "How many rows of data are in this CSV (excluding the header)? Reply with ONLY the number.", files: [SAMPLE_CSV_PATH], - agentOptions: { maxTurns: 1 }, + options: { maxTurns: 1 }, }); expect(run.status).toBe("completed"); @@ -95,7 +95,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (multipart)", 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], - agentOptions: { maxTurns: 1 }, + options: { maxTurns: 1 }, }); let output = ""; @@ -131,7 +131,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (txt)", () => prompt: "How many rows of data are in this file (excluding the header)? Reply with ONLY the number.", files: [SAMPLE_TXT_PATH], - agentOptions: { maxTurns: 1 }, + options: { maxTurns: 1 }, }); expect(run.status).toBe("completed"); @@ -142,7 +142,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("prompt files — file paths (txt)", () => 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], - agentOptions: { maxTurns: 1 }, + options: { maxTurns: 1 }, }); let output = ""; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 2556163..88377dc 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -607,34 +607,6 @@ export class Box { } } - /** - * Delete all boxes for the authenticated user. - */ - static async deleteAll(options?: BoxConnectionOptions): 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 }; - - const response = await fetch(`${baseUrl}/v2/box`, { - method: "DELETE", - headers, - }); - if (!response.ok) { - const msg = await parseErrorResponse(response); - throw new BoxError(msg, response.status); - } - } - /** * Get an existing box by ID */ @@ -711,7 +683,7 @@ export class Box { requestBody.json_schema = jsonSchema; } } - if (options.agentOptions) requestBody.agent_options = options.agentOptions; + if (options.options) requestBody.agent_options = options.options; requestBody.webhook = options.webhook.headers ? { url: options.webhook.url, headers: options.webhook.headers } : { url: options.webhook.url }; @@ -778,7 +750,7 @@ export class Box { requestBody.json_schema = jsonSchema; } } - if (options.agentOptions) requestBody.agent_options = options.agentOptions; + if (options.options) requestBody.agent_options = options.options; const url = `${this._baseUrl}/v2/box/${this.id}/run/stream`; const { body: fetchBody, headers: fetchHeaders } = await buildRunRequest( @@ -921,7 +893,7 @@ export class Box { const folder = this._getFolder(); const requestBody: Record = { prompt: options.prompt }; if (folder) requestBody.folder = folder; - if (options.agentOptions) requestBody.agent_options = options.agentOptions; + if (options.options) requestBody.agent_options = options.options; const url = `${this._baseUrl}/v2/box/${this.id}/run/stream`; const { body: fetchBody, headers: fetchHeaders } = await buildRunRequest( @@ -2289,11 +2261,6 @@ export class EphemeralBox { * Delete specific boxes by ID. */ static delete = Box.delete; - - /** - * Delete all boxes for the authenticated user. - */ - static deleteAll = Box.deleteAll; } // ==================== Helpers ==================== diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index de7ddf9..402577b 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -427,7 +427,7 @@ export interface StreamOptions { /** Files to attach to the prompt (images, PDFs, etc.) */ files?: PromptFiles; /** SDK-specific options forwarded to the underlying agent */ - agentOptions?: AgentOptions; + options?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Tool use callback — called when the agent invokes a tool (Read, Write, Bash, etc.) */ @@ -445,7 +445,7 @@ export interface RunOptions { /** Files to attach to the prompt (images, PDFs, etc.) */ files?: PromptFiles; /** SDK-specific options forwarded to the underlying agent */ - agentOptions?: AgentOptions; + options?: AgentOptions; /** Timeout in milliseconds — aborts if exceeded */ timeout?: number; /** Retries with exponential backoff on transient failures */ diff --git a/packages/sdk/vitest.delete-all.config.ts b/packages/sdk/vitest.delete-all.config.ts deleted file mode 100644 index 2b9f701..0000000 --- a/packages/sdk/vitest.delete-all.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "vitest/config"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - test: { - include: [resolve(__dirname, "src/**/delete-all.integration.test.ts")], - testTimeout: 120000, - }, -}); diff --git a/packages/sdk/vitest.integration.config.ts b/packages/sdk/vitest.integration.config.ts index 47c23e7..e4e9b2a 100644 --- a/packages/sdk/vitest.integration.config.ts +++ b/packages/sdk/vitest.integration.config.ts @@ -7,7 +7,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { include: [resolve(__dirname, "src/**/*.integration.test.ts")], - exclude: [resolve(__dirname, "src/**/delete-all.integration.test.ts")], testTimeout: 120000, }, }); From c63842e21d640729c397cdefee998db52d3ca7d3 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Wed, 1 Apr 2026 00:04:00 +0300 Subject: [PATCH 12/14] refactor: convert CodexAgentOptions properties to camelCase Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/src/__tests__/box-agent-run.test.ts | 10 ++--- packages/sdk/src/client.ts | 37 +++++++++++++++++-- packages/sdk/src/types.ts | 6 +-- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index f7e4dbf..8426055 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -316,7 +316,7 @@ describe("box.agent.run", () => { }); it("sends Codex agent_options in request body", async () => { - const { box, fetchMock } = await createTestBox(); + const { box, fetchMock } = await createTestBox({ agent: Agent.Codex }); fetchMock.mockResolvedValueOnce( mockSSEResponse([ @@ -328,9 +328,9 @@ describe("box.agent.run", () => { await box.agent.run({ prompt: "test", options: { - model_reasoning_effort: "high", + modelReasoningEffort: "high", personality: "pragmatic", - web_search: true, + webSearch: true, }, }); @@ -604,7 +604,7 @@ describe("box.agent.stream", () => { }); it("sends agent_options in stream request body (Codex)", async () => { - const { box, fetchMock } = await createTestBox(); + const { box, fetchMock } = await createTestBox({ agent: Agent.Codex }); fetchMock.mockResolvedValueOnce( mockSSEResponse([ @@ -615,7 +615,7 @@ describe("box.agent.stream", () => { const run = await box.agent.stream({ prompt: "test", - options: { model_reasoning_effort: "medium", personality: "friendly" }, + options: { modelReasoningEffort: "medium", personality: "friendly" }, }); for await (const _ of run) { // consume diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 88377dc..88cabd0 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -40,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"; @@ -57,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 */ @@ -683,7 +711,8 @@ export class Box { requestBody.json_schema = jsonSchema; } } - if (options.options) requestBody.agent_options = options.options; + 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 }; @@ -750,7 +779,8 @@ export class Box { requestBody.json_schema = jsonSchema; } } - if (options.options) requestBody.agent_options = options.options; + 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( @@ -893,7 +923,8 @@ export class Box { const folder = this._getFolder(); const requestBody: Record = { prompt: options.prompt }; if (folder) requestBody.folder = folder; - if (options.options) requestBody.agent_options = options.options; + 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( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 402577b..12c435a 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -181,13 +181,13 @@ export interface ClaudeCodeAgentOptions { */ export interface CodexAgentOptions { /** Reasoning effort */ - model_reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + modelReasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; /** Summary style */ - model_reasoning_summary?: "auto" | "concise" | "detailed" | "none"; + modelReasoningSummary?: "auto" | "concise" | "detailed" | "none"; /** Agent personality */ personality?: "friendly" | "pragmatic" | "none"; /** Web search */ - web_search?: "live" | boolean; + webSearch?: "live" | boolean; } /** From 8a46624ba3b51d98b38b249831a09d0d38362ffd Mon Sep 17 00:00:00 2001 From: CahidArda Date: Wed, 1 Apr 2026 15:51:43 +0300 Subject: [PATCH 13/14] feat: add tests for multipart FormData with webhook fields and update run ID handling in webhook run --- .../src/__tests__/box-prompt-files.test.ts | 20 ++++++++++++++ .../integration/webhook.integration.test.ts | 27 ++++++++++++++++--- packages/sdk/src/client.ts | 4 +-- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/__tests__/box-prompt-files.test.ts b/packages/sdk/src/__tests__/box-prompt-files.test.ts index 690f7fa..8ed61ae 100644 --- a/packages/sdk/src/__tests__/box-prompt-files.test.ts +++ b/packages/sdk/src/__tests__/box-prompt-files.test.ts @@ -177,6 +177,26 @@ describe("box.agent.run — files (multipart file paths)", () => { 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" }, + }); + + 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" })); + }); + it("sends multiple file paths", async () => { const { box, fetchMock } = await createTestBox(); 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 88cabd0..65660cf 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -735,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; } From 1e599bf995c1078d4ea4ebc3d15f1fb5e1b3820f Mon Sep 17 00:00:00 2001 From: CahidArda Date: Wed, 1 Apr 2026 15:57:49 +0300 Subject: [PATCH 14/14] test: update webhook handling in box.agent.run tests to include headers --- packages/sdk/src/__tests__/box-agent-run.test.ts | 4 ++-- packages/sdk/src/__tests__/box-prompt-files.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/__tests__/box-agent-run.test.ts b/packages/sdk/src/__tests__/box-agent-run.test.ts index 8426055..e1b01e5 100644 --- a/packages/sdk/src/__tests__/box-agent-run.test.ts +++ b/packages/sdk/src/__tests__/box-agent-run.test.ts @@ -214,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); @@ -241,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); diff --git a/packages/sdk/src/__tests__/box-prompt-files.test.ts b/packages/sdk/src/__tests__/box-prompt-files.test.ts index 8ed61ae..5767f10 100644 --- a/packages/sdk/src/__tests__/box-prompt-files.test.ts +++ b/packages/sdk/src/__tests__/box-prompt-files.test.ts @@ -185,7 +185,7 @@ describe("box.agent.run — files (multipart file paths)", () => { await box.agent.run({ prompt: "Analyze", files: [FIXTURE_CSV], - webhook: { url: "https://example.com/hook" }, + webhook: { url: "https://example.com/hook", headers: { "X-Test": "value" } }, }); const [, runCall] = fetchMock.mock.calls; @@ -194,7 +194,9 @@ describe("box.agent.run — files (multipart file paths)", () => { 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" })); + expect(formData.get("webhook")).toBe( + JSON.stringify({ url: "https://example.com/hook", headers: { "X-Test": "value" } }), + ); }); it("sends multiple file paths", async () => {