Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sandbox-no-workspace-provides.md
Original file line number Diff line number Diff line change
@@ -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]`.
17 changes: 9 additions & 8 deletions packages/ai-sandbox/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,17 +496,18 @@ export function withSandbox<TOffset extends string = string>(
): 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) {
Expand Down
51 changes: 50 additions & 1 deletion packages/ai-sandbox/tests/tool-capability.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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'
})

Expand All @@ -102,5 +150,6 @@ describe('withSandbox + a server tool', () => {

expect(provider.created).toHaveLength(1)
expect(seen).toBe(provider.created[0])
expect(projected).toBe(false)
})
})
Loading