diff --git a/.changeset/sandbox-no-workspace-provides.md b/.changeset/sandbox-no-workspace-provides.md new file mode 100644 index 0000000000..eb2cc8b7da --- /dev/null +++ b/.changeset/sandbox-no-workspace-provides.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox': patch +--- + +`withSandbox` no longer fails the first `chat()` call with `declares it provides "sandbox-projection" but never called provide() in setup()` when the sandbox definition has no `workspace`. `sandbox-projection` is listed in `provides` only when a workspace is declared, which is when `setup` calls `provide()`. A sandbox with a workspace still satisfies `requires: [ProjectionCapability]`. diff --git a/packages/ai-sandbox/src/middleware.ts b/packages/ai-sandbox/src/middleware.ts index 5c5e951a63..052e00d95a 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -496,17 +496,18 @@ export function withSandbox( ): DefinedChatMiddleware< unknown, readonly [], - readonly [typeof SandboxCapability, typeof ProjectionCapability] + | readonly [typeof SandboxCapability, typeof ProjectionCapability] + | readonly [typeof SandboxCapability] > { return defineChatMiddleware({ name: 'sandbox', - provides: [SandboxCapability, ProjectionCapability], - // SandboxPolicyCapability is provided conditionally (only when the - // definition has a policy), so it is intentionally NOT declared here — - // consumers read it via `getOptional`. SandboxDurabilityCapability and - // DetachableRunCapability are conditional for the same reason (only when - // `runs` + `durability` are both wired), so they are intentionally NOT - // declared here either. + // Listed only when setup will call provide(). chat() checks `requires` + // against this array before setup. Policy, durability, and detach stay + // off the list: they depend on other options and are read optionally. + provides: + definition.workspace !== undefined + ? [SandboxCapability, ProjectionCapability] + : [SandboxCapability], optionalRequires: [SandboxInstanceStoreCapability, LocksCapability], async setup(ctx) { diff --git a/packages/ai-sandbox/tests/tool-capability.test.ts b/packages/ai-sandbox/tests/tool-capability.test.ts index 9667d51404..d5e9b3c009 100644 --- a/packages/ai-sandbox/tests/tool-capability.test.ts +++ b/packages/ai-sandbox/tests/tool-capability.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest' -import { EventType, chat, toolDefinition } from '@tanstack/ai' +import { + EventType, + chat, + defineChatMiddleware, + toolDefinition, +} from '@tanstack/ai' import { InMemorySandboxInstanceStore, + ProjectionCapability, SandboxCapability, defineSandbox, defineWorkspace, @@ -79,12 +85,54 @@ describe('withSandbox + a server tool', () => { fileEvents: false, }) let seen: SandboxHandle | undefined + let projected: boolean | undefined + const whereAmI = toolDefinition({ + name: 'whereAmI', + description: 'Return the sandbox id', + inputSchema: { type: 'object', properties: {} }, + }).server((_args, context) => { + seen = context?.get?.(SandboxCapability) + projected = context?.getOptional?.(ProjectionCapability) !== undefined + return seen?.id ?? 'none' + }) + + const stream = chat({ + adapter: toolThenAnswer(), + messages: [{ role: 'user', content: 'hi' }], + runId: 'run-1', + threadId: 'thread-1', + tools: [whereAmI], + middleware: [ + withSandbox(sandbox, { instances: new InMemorySandboxInstanceStore() }), + defineChatMiddleware({ + name: 'needs-projection', + requires: [ProjectionCapability], + }), + ], + }) + for await (const _chunk of stream) void _chunk + + expect(provider.created).toHaveLength(1) + expect(seen).toBe(provider.created[0]) + expect(projected).toBe(true) + }) + + it('runs setup when the definition has no workspace (no projection provided)', async () => { + const provider = makeFakeProvider({ caps: FULL_CAPS }) + const sandbox = defineSandbox({ + id: 'repo', + provider, + fileEvents: false, + }) + let seen: SandboxHandle | undefined + let projected: boolean | undefined const whereAmI = toolDefinition({ name: 'whereAmI', description: 'Return the sandbox id', inputSchema: { type: 'object', properties: {} }, }).server((_args, context) => { seen = context?.get?.(SandboxCapability) + projected = context?.getOptional?.(ProjectionCapability) !== undefined return seen?.id ?? 'none' }) @@ -102,5 +150,6 @@ describe('withSandbox + a server tool', () => { expect(provider.created).toHaveLength(1) expect(seen).toBe(provider.created[0]) + expect(projected).toBe(false) }) })