From 99c3a6c78cc7bce72d78983fc6ddb9c6607556cf Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 4 Nov 2026 17:12:15 -0400 Subject: [PATCH 1/3] fix(ai-sandbox): don't declare projection capability when no workspace Co-Authored-By: Claude Sonnet 5.5 --- .changeset/sandbox-no-workspace-provides.md | 5 +++ packages/ai-sandbox/src/middleware.ts | 11 ++++--- .../ai-sandbox/tests/tool-capability.test.ts | 33 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 .changeset/sandbox-no-workspace-provides.md diff --git a/.changeset/sandbox-no-workspace-provides.md b/.changeset/sandbox-no-workspace-provides.md new file mode 100644 index 0000000000..cc48413d0e --- /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`. The projection capability is only provided when a workspace is declared, so it is no longer listed in the middleware's static `provides`; adapters already read it with `optional: true`. diff --git a/packages/ai-sandbox/src/middleware.ts b/packages/ai-sandbox/src/middleware.ts index 6ffcc014e7..8c7a085f32 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -41,7 +41,7 @@ import { import { SandboxInstanceStoreCapability } from './instance-store' import { computeWorkspaceHash } from './key' import { buildFileHookEvent, resolveFileEvents } from './file-diff' -import { ProjectionCapability, provideWorkspaceProjection } from './projection' +import { provideWorkspaceProjection } from './projection' import { resolveAllSecrets, resolveSecret } from './secrets' import { createToolHistoryRecorder, @@ -495,13 +495,14 @@ export function withSandbox( ): DefinedChatMiddleware< unknown, readonly [], - 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 — + provides: [SandboxCapability], + // ProjectionCapability is provided conditionally (only when the definition + // has a workspace) and SandboxPolicyCapability only when the definition has + // a policy, so they are 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 diff --git a/packages/ai-sandbox/tests/tool-capability.test.ts b/packages/ai-sandbox/tests/tool-capability.test.ts index 9667d51404..4e8d8fd322 100644 --- a/packages/ai-sandbox/tests/tool-capability.test.ts +++ b/packages/ai-sandbox/tests/tool-capability.test.ts @@ -103,4 +103,37 @@ describe('withSandbox + a server tool', () => { expect(provider.created).toHaveLength(1) expect(seen).toBe(provider.created[0]) }) + + 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 + const whereAmI = toolDefinition({ + name: 'whereAmI', + description: 'Return the sandbox id', + inputSchema: { type: 'object', properties: {} }, + }).server((_args, context) => { + seen = context?.get?.(SandboxCapability) + 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() }), + ], + }) + for await (const _chunk of stream) void _chunk + + expect(provider.created).toHaveLength(1) + expect(seen).toBe(provider.created[0]) + }) }) From db9b71866a12ea65aef792596213a469f5aa8f60 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:33:13 +1100 Subject: [PATCH 2/3] fix(ai-sandbox): list projection in provides only with a workspace chat() checks requires against the static provides list before setup. Omit sandbox-projection when the definition has no workspace, and keep it when setup will call provide(). --- .changeset/sandbox-no-workspace-provides.md | 2 +- packages/ai-sandbox/src/middleware.ts | 23 +++++++++++-------- .../ai-sandbox/tests/tool-capability.test.ts | 13 ++++++++++- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.changeset/sandbox-no-workspace-provides.md b/.changeset/sandbox-no-workspace-provides.md index cc48413d0e..eb2cc8b7da 100644 --- a/.changeset/sandbox-no-workspace-provides.md +++ b/.changeset/sandbox-no-workspace-provides.md @@ -2,4 +2,4 @@ '@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`. The projection capability is only provided when a workspace is declared, so it is no longer listed in the middleware's static `provides`; adapters already read it with `optional: true`. +`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 ed0fdd776d..452b48fa47 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -42,7 +42,10 @@ import { import { SandboxInstanceStoreCapability } from './instance-store' import { computeWorkspaceHash } from './key' import { buildFileHookEvent, resolveFileEvents } from './file-diff' -import { provideWorkspaceProjection } from './projection' +import { + ProjectionCapability, + provideWorkspaceProjection, +} from './projection' import { resolveAllSecrets, resolveSecret } from './secrets' import { createToolHistoryRecorder, @@ -496,18 +499,18 @@ export function withSandbox( ): DefinedChatMiddleware< unknown, readonly [], - readonly [typeof SandboxCapability] + | readonly [typeof SandboxCapability, typeof ProjectionCapability] + | readonly [typeof SandboxCapability] > { return defineChatMiddleware({ name: 'sandbox', - provides: [SandboxCapability], - // ProjectionCapability is provided conditionally (only when the definition - // has a workspace) and SandboxPolicyCapability only when the definition has - // a policy, so they are 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 4e8d8fd322..8f18cfdada 100644 --- a/packages/ai-sandbox/tests/tool-capability.test.ts +++ b/packages/ai-sandbox/tests/tool-capability.test.ts @@ -1,7 +1,8 @@ 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 +80,14 @@ 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' }) @@ -96,12 +99,17 @@ describe('withSandbox + a server tool', () => { 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 () => { @@ -112,12 +120,14 @@ 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' }) @@ -135,5 +145,6 @@ describe('withSandbox + a server tool', () => { expect(provider.created).toHaveLength(1) expect(seen).toBe(provider.created[0]) + expect(projected).toBe(false) }) }) From 2438fd6eece69e7bf80c842dc3dd6f70fb7597a6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:39:37 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/ai-sandbox/src/middleware.ts | 5 +---- packages/ai-sandbox/tests/tool-capability.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/ai-sandbox/src/middleware.ts b/packages/ai-sandbox/src/middleware.ts index 452b48fa47..052e00d95a 100644 --- a/packages/ai-sandbox/src/middleware.ts +++ b/packages/ai-sandbox/src/middleware.ts @@ -42,10 +42,7 @@ import { import { SandboxInstanceStoreCapability } from './instance-store' import { computeWorkspaceHash } from './key' import { buildFileHookEvent, resolveFileEvents } from './file-diff' -import { - ProjectionCapability, - provideWorkspaceProjection, -} from './projection' +import { ProjectionCapability, provideWorkspaceProjection } from './projection' import { resolveAllSecrets, resolveSecret } from './secrets' import { createToolHistoryRecorder, diff --git a/packages/ai-sandbox/tests/tool-capability.test.ts b/packages/ai-sandbox/tests/tool-capability.test.ts index 8f18cfdada..d5e9b3c009 100644 --- a/packages/ai-sandbox/tests/tool-capability.test.ts +++ b/packages/ai-sandbox/tests/tool-capability.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { EventType, chat, defineChatMiddleware, toolDefinition } from '@tanstack/ai' +import { + EventType, + chat, + defineChatMiddleware, + toolDefinition, +} from '@tanstack/ai' import { InMemorySandboxInstanceStore, ProjectionCapability,