@tanstack/ai-sandbox 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/esm/approvals.js +5 -4
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/bridge-events.js +7 -6
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/checkpoint-store.d.ts +147 -0
- package/dist/esm/checkpoint-store.js +267 -0
- package/dist/esm/checkpoint-store.js.map +1 -0
- package/dist/esm/chunk-identity.d.ts +0 -17
- package/dist/esm/chunk-identity.js +28 -5
- package/dist/esm/chunk-identity.js.map +1 -1
- package/dist/esm/contracts.d.ts +19 -0
- package/dist/esm/index.d.ts +11 -1
- package/dist/esm/index.js +12 -7
- package/dist/esm/memory-snapshot-types.d.ts +129 -0
- package/dist/esm/memory-snapshots.d.ts +6 -0
- package/dist/esm/memory-snapshots.js +490 -0
- package/dist/esm/memory-snapshots.js.map +1 -0
- package/dist/esm/middleware.d.ts +33 -1
- package/dist/esm/middleware.js +339 -94
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.d.ts +1 -1
- package/dist/esm/sandbox.d.ts +16 -0
- package/dist/esm/sandbox.js +62 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/snapshot-operations.d.ts +65 -0
- package/dist/esm/snapshot-operations.js +317 -0
- package/dist/esm/snapshot-operations.js.map +1 -0
- package/dist/esm/snapshot-tools.d.ts +185 -0
- package/dist/esm/snapshot-tools.js +160 -0
- package/dist/esm/snapshot-tools.js.map +1 -0
- package/dist/esm/snapshots.d.ts +51 -0
- package/dist/esm/snapshots.js +350 -0
- package/dist/esm/snapshots.js.map +1 -0
- package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
- package/dist/esm/testkit/checkpoint-conformance.js +453 -0
- package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -0
- package/dist/esm/testkit/conformance.js +3 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/tool-history.js +4 -5
- package/dist/esm/tool-history.js.map +1 -1
- package/package.json +8 -3
- package/skills/ai-sandbox/SKILL.md +96 -8
- package/src/approvals.ts +13 -12
- package/src/bridge-events.ts +16 -10
- package/src/checkpoint-store.ts +652 -0
- package/src/chunk-identity.ts +36 -5
- package/src/contracts.ts +12 -0
- package/src/index.ts +56 -0
- package/src/memory-snapshot-types.ts +167 -0
- package/src/memory-snapshots.ts +936 -0
- package/src/middleware.ts +610 -160
- package/src/sandbox.ts +107 -6
- package/src/snapshot-operations.ts +540 -0
- package/src/snapshot-tools.ts +208 -0
- package/src/snapshots.ts +711 -0
- package/src/testkit/checkpoint-conformance.ts +472 -0
- package/src/testkit/checkpoint-fork-conformance.ts +299 -0
- package/src/testkit/conformance.ts +7 -0
- package/src/tool-history.ts +9 -13
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { ModelMessage } from '@tanstack/ai'
|
|
3
|
+
import type {
|
|
4
|
+
ForkCapableSandboxCheckpointStore,
|
|
5
|
+
SandboxCheckpoint,
|
|
6
|
+
SandboxCheckpointForkInput,
|
|
7
|
+
SandboxCheckpointWriter,
|
|
8
|
+
SandboxCheckpointWriterLease,
|
|
9
|
+
} from '../checkpoint-store'
|
|
10
|
+
import { InMemorySandboxCheckpointStore } from '../checkpoint-store'
|
|
11
|
+
|
|
12
|
+
/** Combined stores required to exercise an atomic checkpoint fork. */
|
|
13
|
+
export interface SandboxCheckpointForkConformanceInput {
|
|
14
|
+
persistence: {
|
|
15
|
+
stores: {
|
|
16
|
+
messages: {
|
|
17
|
+
loadThread: (threadId: string) => Promise<Array<ModelMessage>>
|
|
18
|
+
saveThread: (
|
|
19
|
+
threadId: string,
|
|
20
|
+
messages: Array<ModelMessage>,
|
|
21
|
+
) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
checkpoints: ForkCapableSandboxCheckpointStore
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SandboxCheckpointForkConformanceFactory {
|
|
29
|
+
():
|
|
30
|
+
| SandboxCheckpointForkConformanceInput
|
|
31
|
+
| Promise<SandboxCheckpointForkConformanceInput>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ForkInputWithoutWriter = Omit<SandboxCheckpointForkInput, 'writer'>
|
|
35
|
+
|
|
36
|
+
function sourceCheckpoint(): SandboxCheckpoint {
|
|
37
|
+
return {
|
|
38
|
+
id: 'source-root',
|
|
39
|
+
threadId: 'source',
|
|
40
|
+
parentCheckpointId: null,
|
|
41
|
+
createdAt: 10,
|
|
42
|
+
reason: 'named',
|
|
43
|
+
label: 'source label',
|
|
44
|
+
sourceRunId: 'run-1',
|
|
45
|
+
files: [
|
|
46
|
+
{
|
|
47
|
+
path: 'a.txt',
|
|
48
|
+
kind: 'file',
|
|
49
|
+
blobKey: `sandbox-files/sha256/${'a'.repeat(64)}`,
|
|
50
|
+
size: 1,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
conversation: [{ role: 'user', content: 'checkpoint conversation' }],
|
|
54
|
+
artifacts: [],
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function appendSource(
|
|
59
|
+
checkpoints: ForkCapableSandboxCheckpointStore,
|
|
60
|
+
): Promise<{
|
|
61
|
+
source: SandboxCheckpoint
|
|
62
|
+
sourceWriter: SandboxCheckpointWriterLease
|
|
63
|
+
}> {
|
|
64
|
+
const sourceWriter = await checkpoints.acquireWriter('source')
|
|
65
|
+
const source = sourceCheckpoint()
|
|
66
|
+
await checkpoints.append({
|
|
67
|
+
checkpoint: source,
|
|
68
|
+
expectedHeadId: null,
|
|
69
|
+
writer: sourceWriter,
|
|
70
|
+
})
|
|
71
|
+
return { source, sourceWriter }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function forkInput(
|
|
75
|
+
writer: SandboxCheckpointWriter,
|
|
76
|
+
overrides: Partial<ForkInputWithoutWriter> = {},
|
|
77
|
+
): SandboxCheckpointForkInput {
|
|
78
|
+
return {
|
|
79
|
+
sourceThreadId: 'source',
|
|
80
|
+
sourceCheckpointId: 'source-root',
|
|
81
|
+
destinationThreadId: 'destination',
|
|
82
|
+
destinationCheckpointId: 'fork-root',
|
|
83
|
+
createdAt: 20,
|
|
84
|
+
writer,
|
|
85
|
+
...overrides,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function destinationState(
|
|
90
|
+
persistence: SandboxCheckpointForkConformanceInput['persistence'],
|
|
91
|
+
checkpoints: ForkCapableSandboxCheckpointStore,
|
|
92
|
+
) {
|
|
93
|
+
return {
|
|
94
|
+
transcript: await persistence.stores.messages.loadThread('destination'),
|
|
95
|
+
head: await checkpoints.getHead('destination'),
|
|
96
|
+
checkpoints: await checkpoints.list('destination'),
|
|
97
|
+
references: await checkpoints.listBlobReferences(),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function expectRejectedWithoutDestinationChanges(
|
|
102
|
+
persistence: SandboxCheckpointForkConformanceInput['persistence'],
|
|
103
|
+
checkpoints: ForkCapableSandboxCheckpointStore,
|
|
104
|
+
operation: Promise<unknown>,
|
|
105
|
+
code: string,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const before = await destinationState(persistence, checkpoints)
|
|
108
|
+
await expect(operation).rejects.toMatchObject({ code })
|
|
109
|
+
expect(await destinationState(persistence, checkpoints)).toEqual(before)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function runSandboxCheckpointForkConformance(
|
|
113
|
+
name: string,
|
|
114
|
+
makeSnapshots: SandboxCheckpointForkConformanceFactory,
|
|
115
|
+
): void {
|
|
116
|
+
describe(`Sandbox checkpoint fork conformance: ${name}`, () => {
|
|
117
|
+
it('copies a selected historical checkpoint and creates an exact fork root', async () => {
|
|
118
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
119
|
+
const { source, sourceWriter } = await appendSource(checkpoints)
|
|
120
|
+
await checkpoints.append({
|
|
121
|
+
checkpoint: {
|
|
122
|
+
id: 'source-head',
|
|
123
|
+
threadId: 'source',
|
|
124
|
+
parentCheckpointId: source.id,
|
|
125
|
+
createdAt: 11,
|
|
126
|
+
reason: 'automatic',
|
|
127
|
+
files: [],
|
|
128
|
+
conversation: [{ role: 'user', content: 'newer conversation' }],
|
|
129
|
+
artifacts: [],
|
|
130
|
+
},
|
|
131
|
+
expectedHeadId: source.id,
|
|
132
|
+
writer: sourceWriter,
|
|
133
|
+
})
|
|
134
|
+
await persistence.stores.messages.saveThread('source', [
|
|
135
|
+
{ role: 'user', content: 'current source message' },
|
|
136
|
+
])
|
|
137
|
+
const destinationWriter = await checkpoints.acquireWriter('destination')
|
|
138
|
+
const result = await checkpoints.forkFromCheckpoint(
|
|
139
|
+
forkInput(destinationWriter),
|
|
140
|
+
)
|
|
141
|
+
expect(result.checkpoint).toEqual({
|
|
142
|
+
id: 'fork-root',
|
|
143
|
+
threadId: 'destination',
|
|
144
|
+
parentCheckpointId: null,
|
|
145
|
+
createdAt: 20,
|
|
146
|
+
reason: 'fork-root',
|
|
147
|
+
files: source.files,
|
|
148
|
+
conversation: source.conversation,
|
|
149
|
+
artifacts: [],
|
|
150
|
+
})
|
|
151
|
+
expect(
|
|
152
|
+
await persistence.stores.messages.loadThread('destination'),
|
|
153
|
+
).toEqual(source.conversation)
|
|
154
|
+
expect(await checkpoints.getHead('source')).toBe('source-head')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('rejects a plain checkpoint store because it has no fork capability', () => {
|
|
158
|
+
const store = new InMemorySandboxCheckpointStore()
|
|
159
|
+
expect('forkFromCheckpoint' in store).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('rejects a missing source without changing the destination', async () => {
|
|
163
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
164
|
+
const writer = await checkpoints.acquireWriter('destination')
|
|
165
|
+
await expectRejectedWithoutDestinationChanges(
|
|
166
|
+
persistence,
|
|
167
|
+
checkpoints,
|
|
168
|
+
checkpoints.forkFromCheckpoint(
|
|
169
|
+
forkInput(writer, { sourceCheckpointId: 'missing' }),
|
|
170
|
+
),
|
|
171
|
+
'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND',
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('rejects a source thread mismatch without changing the destination', async () => {
|
|
176
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
177
|
+
await appendSource(checkpoints)
|
|
178
|
+
const writer = await checkpoints.acquireWriter('destination')
|
|
179
|
+
await expectRejectedWithoutDestinationChanges(
|
|
180
|
+
persistence,
|
|
181
|
+
checkpoints,
|
|
182
|
+
checkpoints.forkFromCheckpoint(
|
|
183
|
+
forkInput(writer, { sourceThreadId: 'another-source' }),
|
|
184
|
+
),
|
|
185
|
+
'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('rejects equal source and destination threads without changing the source', async () => {
|
|
190
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
191
|
+
const { source, sourceWriter } = await appendSource(checkpoints)
|
|
192
|
+
const before = {
|
|
193
|
+
transcript: await persistence.stores.messages.loadThread('source'),
|
|
194
|
+
head: await checkpoints.getHead('source'),
|
|
195
|
+
checkpoints: await checkpoints.list('source'),
|
|
196
|
+
references: await checkpoints.listBlobReferences(),
|
|
197
|
+
}
|
|
198
|
+
await expect(
|
|
199
|
+
checkpoints.forkFromCheckpoint(
|
|
200
|
+
forkInput(sourceWriter, {
|
|
201
|
+
destinationThreadId: source.threadId,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
).rejects.toMatchObject({
|
|
205
|
+
code: 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',
|
|
206
|
+
})
|
|
207
|
+
expect({
|
|
208
|
+
transcript: await persistence.stores.messages.loadThread('source'),
|
|
209
|
+
head: await checkpoints.getHead('source'),
|
|
210
|
+
checkpoints: await checkpoints.list('source'),
|
|
211
|
+
references: await checkpoints.listBlobReferences(),
|
|
212
|
+
}).toEqual(before)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('rejects a missing destination writer without changing the destination', async () => {
|
|
216
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
217
|
+
await appendSource(checkpoints)
|
|
218
|
+
const missingWriter: SandboxCheckpointWriter = {
|
|
219
|
+
threadId: 'destination',
|
|
220
|
+
ownerToken: 'missing-owner',
|
|
221
|
+
fence: 1,
|
|
222
|
+
}
|
|
223
|
+
await expectRejectedWithoutDestinationChanges(
|
|
224
|
+
persistence,
|
|
225
|
+
checkpoints,
|
|
226
|
+
checkpoints.forkFromCheckpoint(forkInput(missingWriter)),
|
|
227
|
+
'SANDBOX_SNAPSHOT_WRITER_LOST',
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('rejects a wrong destination writer without changing the destination', async () => {
|
|
232
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
233
|
+
await appendSource(checkpoints)
|
|
234
|
+
const wrongWriter = await checkpoints.acquireWriter('another-thread')
|
|
235
|
+
await expectRejectedWithoutDestinationChanges(
|
|
236
|
+
persistence,
|
|
237
|
+
checkpoints,
|
|
238
|
+
checkpoints.forkFromCheckpoint(forkInput(wrongWriter)),
|
|
239
|
+
'SANDBOX_SNAPSHOT_WRITER_LOST',
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('rejects a stale destination writer without changing the destination', async () => {
|
|
244
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
245
|
+
await appendSource(checkpoints)
|
|
246
|
+
const staleWriter = await checkpoints.acquireWriter('destination')
|
|
247
|
+
await staleWriter.release()
|
|
248
|
+
await expectRejectedWithoutDestinationChanges(
|
|
249
|
+
persistence,
|
|
250
|
+
checkpoints,
|
|
251
|
+
checkpoints.forkFromCheckpoint(forkInput(staleWriter)),
|
|
252
|
+
'SANDBOX_SNAPSHOT_WRITER_LOST',
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('rejects a destination transcript without changing its checkpoint state', async () => {
|
|
257
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
258
|
+
await appendSource(checkpoints)
|
|
259
|
+
await persistence.stores.messages.saveThread('destination', [
|
|
260
|
+
{ role: 'user', content: 'already here' },
|
|
261
|
+
])
|
|
262
|
+
const writer = await checkpoints.acquireWriter('destination')
|
|
263
|
+
await expectRejectedWithoutDestinationChanges(
|
|
264
|
+
persistence,
|
|
265
|
+
checkpoints,
|
|
266
|
+
checkpoints.forkFromCheckpoint(forkInput(writer)),
|
|
267
|
+
'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('rejects an orphaned checkpoint id without changing the destination', async () => {
|
|
272
|
+
const { persistence, checkpoints } = await makeSnapshots()
|
|
273
|
+
const { source } = await appendSource(checkpoints)
|
|
274
|
+
const orphanWriter = await checkpoints.acquireWriter('orphan-thread')
|
|
275
|
+
await checkpoints.append({
|
|
276
|
+
checkpoint: {
|
|
277
|
+
...source,
|
|
278
|
+
id: 'orphaned-id',
|
|
279
|
+
threadId: 'orphan-thread',
|
|
280
|
+
parentCheckpointId: null,
|
|
281
|
+
createdAt: 11,
|
|
282
|
+
},
|
|
283
|
+
expectedHeadId: null,
|
|
284
|
+
writer: orphanWriter,
|
|
285
|
+
})
|
|
286
|
+
const destinationWriter = await checkpoints.acquireWriter('destination')
|
|
287
|
+
await expectRejectedWithoutDestinationChanges(
|
|
288
|
+
persistence,
|
|
289
|
+
checkpoints,
|
|
290
|
+
checkpoints.forkFromCheckpoint(
|
|
291
|
+
forkInput(destinationWriter, {
|
|
292
|
+
destinationCheckpointId: 'orphaned-id',
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
})
|
|
299
|
+
}
|
|
@@ -29,6 +29,13 @@ export type { ReaperConformanceConfig } from './reaper-conformance'
|
|
|
29
29
|
export { runDurableRunFieldsConformance } from './durable-run-fields-conformance'
|
|
30
30
|
export type { MakeRunStore } from './durable-run-fields-conformance'
|
|
31
31
|
export { makeFakeShellSpawn } from './shell-spawn'
|
|
32
|
+
export { runSandboxCheckpointStoreConformance } from './checkpoint-conformance'
|
|
33
|
+
export type { SandboxCheckpointStoreOptions } from '../checkpoint-store'
|
|
34
|
+
export { runSandboxCheckpointForkConformance } from './checkpoint-fork-conformance'
|
|
35
|
+
export type {
|
|
36
|
+
SandboxCheckpointForkConformanceInput,
|
|
37
|
+
SandboxCheckpointForkConformanceFactory,
|
|
38
|
+
} from './checkpoint-fork-conformance'
|
|
32
39
|
|
|
33
40
|
function makeRecord(
|
|
34
41
|
overrides?: Partial<SandboxInstanceRecord>,
|
package/src/tool-history.ts
CHANGED
|
@@ -39,7 +39,7 @@ interface TranscriptTarget {
|
|
|
39
39
|
|
|
40
40
|
interface OpenCall {
|
|
41
41
|
name: string
|
|
42
|
-
/** Accumulated `TOOL_CALL_ARGS` deltas
|
|
42
|
+
/** Accumulated `TOOL_CALL_ARGS` deltas. */
|
|
43
43
|
args: string
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -157,9 +157,7 @@ export function createToolHistoryRecorder(): ToolHistoryRecorder {
|
|
|
157
157
|
// to satisfy the exhaustiveness lint.
|
|
158
158
|
observe(chunk, target) {
|
|
159
159
|
if (chunk.type === EventType.TOOL_CALL_START) {
|
|
160
|
-
|
|
161
|
-
// that alias is what several harness adapters still emit.
|
|
162
|
-
const name = chunk.toolCallName ?? chunk.toolName
|
|
160
|
+
const name = chunk.toolCallName
|
|
163
161
|
if (!name) return
|
|
164
162
|
open.set(chunk.toolCallId, { name, args: '' })
|
|
165
163
|
return
|
|
@@ -167,21 +165,19 @@ export function createToolHistoryRecorder(): ToolHistoryRecorder {
|
|
|
167
165
|
if (chunk.type === EventType.TOOL_CALL_ARGS) {
|
|
168
166
|
const call = open.get(chunk.toolCallId)
|
|
169
167
|
if (!call) return
|
|
170
|
-
|
|
171
|
-
// an adapter that sends both would otherwise double the arguments.
|
|
172
|
-
call.args = chunk.args ?? call.args + chunk.delta
|
|
168
|
+
call.args += chunk.delta
|
|
173
169
|
return
|
|
174
170
|
}
|
|
175
171
|
if (chunk.type === EventType.TOOL_CALL_END) {
|
|
176
172
|
const call = open.get(chunk.toolCallId)
|
|
177
173
|
if (!call) return
|
|
178
174
|
open.delete(chunk.toolCallId)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
appendCall(target, chunk.toolCallId, call.name, args)
|
|
175
|
+
recorded.push({
|
|
176
|
+
id: chunk.toolCallId,
|
|
177
|
+
name: call.name,
|
|
178
|
+
args: call.args,
|
|
179
|
+
})
|
|
180
|
+
appendCall(target, chunk.toolCallId, call.name, call.args)
|
|
185
181
|
return
|
|
186
182
|
}
|
|
187
183
|
if (chunk.type === EventType.TOOL_CALL_RESULT) {
|