@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.
Files changed (64) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/approvals.js +5 -4
  3. package/dist/esm/approvals.js.map +1 -1
  4. package/dist/esm/bridge-events.js +7 -6
  5. package/dist/esm/bridge-events.js.map +1 -1
  6. package/dist/esm/checkpoint-store.d.ts +147 -0
  7. package/dist/esm/checkpoint-store.js +267 -0
  8. package/dist/esm/checkpoint-store.js.map +1 -0
  9. package/dist/esm/chunk-identity.d.ts +0 -17
  10. package/dist/esm/chunk-identity.js +28 -5
  11. package/dist/esm/chunk-identity.js.map +1 -1
  12. package/dist/esm/contracts.d.ts +19 -0
  13. package/dist/esm/index.d.ts +11 -1
  14. package/dist/esm/index.js +12 -7
  15. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  16. package/dist/esm/memory-snapshots.d.ts +6 -0
  17. package/dist/esm/memory-snapshots.js +490 -0
  18. package/dist/esm/memory-snapshots.js.map +1 -0
  19. package/dist/esm/middleware.d.ts +33 -1
  20. package/dist/esm/middleware.js +339 -94
  21. package/dist/esm/middleware.js.map +1 -1
  22. package/dist/esm/ngrok.d.ts +1 -1
  23. package/dist/esm/sandbox.d.ts +16 -0
  24. package/dist/esm/sandbox.js +62 -9
  25. package/dist/esm/sandbox.js.map +1 -1
  26. package/dist/esm/snapshot-operations.d.ts +65 -0
  27. package/dist/esm/snapshot-operations.js +317 -0
  28. package/dist/esm/snapshot-operations.js.map +1 -0
  29. package/dist/esm/snapshot-tools.d.ts +185 -0
  30. package/dist/esm/snapshot-tools.js +160 -0
  31. package/dist/esm/snapshot-tools.js.map +1 -0
  32. package/dist/esm/snapshots.d.ts +51 -0
  33. package/dist/esm/snapshots.js +350 -0
  34. package/dist/esm/snapshots.js.map +1 -0
  35. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  36. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  37. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  38. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  39. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  40. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  41. package/dist/esm/testkit/conformance.d.ts +4 -0
  42. package/dist/esm/testkit/conformance.js +3 -1
  43. package/dist/esm/testkit/conformance.js.map +1 -1
  44. package/dist/esm/tool-history.js +4 -5
  45. package/dist/esm/tool-history.js.map +1 -1
  46. package/package.json +8 -3
  47. package/skills/ai-sandbox/SKILL.md +96 -8
  48. package/src/approvals.ts +13 -12
  49. package/src/bridge-events.ts +16 -10
  50. package/src/checkpoint-store.ts +652 -0
  51. package/src/chunk-identity.ts +36 -5
  52. package/src/contracts.ts +12 -0
  53. package/src/index.ts +56 -0
  54. package/src/memory-snapshot-types.ts +167 -0
  55. package/src/memory-snapshots.ts +936 -0
  56. package/src/middleware.ts +610 -160
  57. package/src/sandbox.ts +107 -6
  58. package/src/snapshot-operations.ts +540 -0
  59. package/src/snapshot-tools.ts +208 -0
  60. package/src/snapshots.ts +711 -0
  61. package/src/testkit/checkpoint-conformance.ts +472 -0
  62. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  63. package/src/testkit/conformance.ts +7 -0
  64. 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>,
@@ -39,7 +39,7 @@ interface TranscriptTarget {
39
39
 
40
40
  interface OpenCall {
41
41
  name: string
42
- /** Accumulated `TOOL_CALL_ARGS` deltas; superseded by `input` when the adapter sends it. */
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
- // `toolCallName` is the AG-UI field; `toolName` is its deprecated alias, and
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
- // `args` is the accumulated-so-far field. Prefer it over stitching deltas:
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
- // `input` is the final PARSED input, so it beats the streamed string, which
180
- // can be a truncated fragment if the arguments stream was cut short.
181
- const args =
182
- chunk.input !== undefined ? JSON.stringify(chunk.input) : call.args
183
- recorded.push({ id: chunk.toolCallId, name: call.name, args })
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) {