@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,191 @@
|
|
|
1
|
+
import { InMemorySandboxCheckpointStore } from "../checkpoint-store.js";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
//#region src/testkit/checkpoint-fork-conformance.ts
|
|
4
|
+
function sourceCheckpoint() {
|
|
5
|
+
return {
|
|
6
|
+
id: "source-root",
|
|
7
|
+
threadId: "source",
|
|
8
|
+
parentCheckpointId: null,
|
|
9
|
+
createdAt: 10,
|
|
10
|
+
reason: "named",
|
|
11
|
+
label: "source label",
|
|
12
|
+
sourceRunId: "run-1",
|
|
13
|
+
files: [{
|
|
14
|
+
path: "a.txt",
|
|
15
|
+
kind: "file",
|
|
16
|
+
blobKey: `sandbox-files/sha256/${"a".repeat(64)}`,
|
|
17
|
+
size: 1
|
|
18
|
+
}],
|
|
19
|
+
conversation: [{
|
|
20
|
+
role: "user",
|
|
21
|
+
content: "checkpoint conversation"
|
|
22
|
+
}],
|
|
23
|
+
artifacts: []
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async function appendSource(checkpoints) {
|
|
27
|
+
const sourceWriter = await checkpoints.acquireWriter("source");
|
|
28
|
+
const source = sourceCheckpoint();
|
|
29
|
+
await checkpoints.append({
|
|
30
|
+
checkpoint: source,
|
|
31
|
+
expectedHeadId: null,
|
|
32
|
+
writer: sourceWriter
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
source,
|
|
36
|
+
sourceWriter
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function forkInput(writer, overrides = {}) {
|
|
40
|
+
return {
|
|
41
|
+
sourceThreadId: "source",
|
|
42
|
+
sourceCheckpointId: "source-root",
|
|
43
|
+
destinationThreadId: "destination",
|
|
44
|
+
destinationCheckpointId: "fork-root",
|
|
45
|
+
createdAt: 20,
|
|
46
|
+
writer,
|
|
47
|
+
...overrides
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function destinationState(persistence, checkpoints) {
|
|
51
|
+
return {
|
|
52
|
+
transcript: await persistence.stores.messages.loadThread("destination"),
|
|
53
|
+
head: await checkpoints.getHead("destination"),
|
|
54
|
+
checkpoints: await checkpoints.list("destination"),
|
|
55
|
+
references: await checkpoints.listBlobReferences()
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
async function expectRejectedWithoutDestinationChanges(persistence, checkpoints, operation, code) {
|
|
59
|
+
const before = await destinationState(persistence, checkpoints);
|
|
60
|
+
await expect(operation).rejects.toMatchObject({ code });
|
|
61
|
+
expect(await destinationState(persistence, checkpoints)).toEqual(before);
|
|
62
|
+
}
|
|
63
|
+
function runSandboxCheckpointForkConformance(name, makeSnapshots) {
|
|
64
|
+
describe(`Sandbox checkpoint fork conformance: ${name}`, () => {
|
|
65
|
+
it("copies a selected historical checkpoint and creates an exact fork root", async () => {
|
|
66
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
67
|
+
const { source, sourceWriter } = await appendSource(checkpoints);
|
|
68
|
+
await checkpoints.append({
|
|
69
|
+
checkpoint: {
|
|
70
|
+
id: "source-head",
|
|
71
|
+
threadId: "source",
|
|
72
|
+
parentCheckpointId: source.id,
|
|
73
|
+
createdAt: 11,
|
|
74
|
+
reason: "automatic",
|
|
75
|
+
files: [],
|
|
76
|
+
conversation: [{
|
|
77
|
+
role: "user",
|
|
78
|
+
content: "newer conversation"
|
|
79
|
+
}],
|
|
80
|
+
artifacts: []
|
|
81
|
+
},
|
|
82
|
+
expectedHeadId: source.id,
|
|
83
|
+
writer: sourceWriter
|
|
84
|
+
});
|
|
85
|
+
await persistence.stores.messages.saveThread("source", [{
|
|
86
|
+
role: "user",
|
|
87
|
+
content: "current source message"
|
|
88
|
+
}]);
|
|
89
|
+
const destinationWriter = await checkpoints.acquireWriter("destination");
|
|
90
|
+
const result = await checkpoints.forkFromCheckpoint(forkInput(destinationWriter));
|
|
91
|
+
expect(result.checkpoint).toEqual({
|
|
92
|
+
id: "fork-root",
|
|
93
|
+
threadId: "destination",
|
|
94
|
+
parentCheckpointId: null,
|
|
95
|
+
createdAt: 20,
|
|
96
|
+
reason: "fork-root",
|
|
97
|
+
files: source.files,
|
|
98
|
+
conversation: source.conversation,
|
|
99
|
+
artifacts: []
|
|
100
|
+
});
|
|
101
|
+
expect(await persistence.stores.messages.loadThread("destination")).toEqual(source.conversation);
|
|
102
|
+
expect(await checkpoints.getHead("source")).toBe("source-head");
|
|
103
|
+
});
|
|
104
|
+
it("rejects a plain checkpoint store because it has no fork capability", () => {
|
|
105
|
+
const store = new InMemorySandboxCheckpointStore();
|
|
106
|
+
expect("forkFromCheckpoint" in store).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
it("rejects a missing source without changing the destination", async () => {
|
|
109
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
110
|
+
const writer = await checkpoints.acquireWriter("destination");
|
|
111
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(writer, { sourceCheckpointId: "missing" })), "SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND");
|
|
112
|
+
});
|
|
113
|
+
it("rejects a source thread mismatch without changing the destination", async () => {
|
|
114
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
115
|
+
await appendSource(checkpoints);
|
|
116
|
+
const writer = await checkpoints.acquireWriter("destination");
|
|
117
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(writer, { sourceThreadId: "another-source" })), "SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH");
|
|
118
|
+
});
|
|
119
|
+
it("rejects equal source and destination threads without changing the source", async () => {
|
|
120
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
121
|
+
const { source, sourceWriter } = await appendSource(checkpoints);
|
|
122
|
+
const before = {
|
|
123
|
+
transcript: await persistence.stores.messages.loadThread("source"),
|
|
124
|
+
head: await checkpoints.getHead("source"),
|
|
125
|
+
checkpoints: await checkpoints.list("source"),
|
|
126
|
+
references: await checkpoints.listBlobReferences()
|
|
127
|
+
};
|
|
128
|
+
await expect(checkpoints.forkFromCheckpoint(forkInput(sourceWriter, { destinationThreadId: source.threadId }))).rejects.toMatchObject({ code: "SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH" });
|
|
129
|
+
expect({
|
|
130
|
+
transcript: await persistence.stores.messages.loadThread("source"),
|
|
131
|
+
head: await checkpoints.getHead("source"),
|
|
132
|
+
checkpoints: await checkpoints.list("source"),
|
|
133
|
+
references: await checkpoints.listBlobReferences()
|
|
134
|
+
}).toEqual(before);
|
|
135
|
+
});
|
|
136
|
+
it("rejects a missing destination writer without changing the destination", async () => {
|
|
137
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
138
|
+
await appendSource(checkpoints);
|
|
139
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput({
|
|
140
|
+
threadId: "destination",
|
|
141
|
+
ownerToken: "missing-owner",
|
|
142
|
+
fence: 1
|
|
143
|
+
})), "SANDBOX_SNAPSHOT_WRITER_LOST");
|
|
144
|
+
});
|
|
145
|
+
it("rejects a wrong destination writer without changing the destination", async () => {
|
|
146
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
147
|
+
await appendSource(checkpoints);
|
|
148
|
+
const wrongWriter = await checkpoints.acquireWriter("another-thread");
|
|
149
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(wrongWriter)), "SANDBOX_SNAPSHOT_WRITER_LOST");
|
|
150
|
+
});
|
|
151
|
+
it("rejects a stale destination writer without changing the destination", async () => {
|
|
152
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
153
|
+
await appendSource(checkpoints);
|
|
154
|
+
const staleWriter = await checkpoints.acquireWriter("destination");
|
|
155
|
+
await staleWriter.release();
|
|
156
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(staleWriter)), "SANDBOX_SNAPSHOT_WRITER_LOST");
|
|
157
|
+
});
|
|
158
|
+
it("rejects a destination transcript without changing its checkpoint state", async () => {
|
|
159
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
160
|
+
await appendSource(checkpoints);
|
|
161
|
+
await persistence.stores.messages.saveThread("destination", [{
|
|
162
|
+
role: "user",
|
|
163
|
+
content: "already here"
|
|
164
|
+
}]);
|
|
165
|
+
const writer = await checkpoints.acquireWriter("destination");
|
|
166
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(writer)), "SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY");
|
|
167
|
+
});
|
|
168
|
+
it("rejects an orphaned checkpoint id without changing the destination", async () => {
|
|
169
|
+
const { persistence, checkpoints } = await makeSnapshots();
|
|
170
|
+
const { source } = await appendSource(checkpoints);
|
|
171
|
+
const orphanWriter = await checkpoints.acquireWriter("orphan-thread");
|
|
172
|
+
await checkpoints.append({
|
|
173
|
+
checkpoint: {
|
|
174
|
+
...source,
|
|
175
|
+
id: "orphaned-id",
|
|
176
|
+
threadId: "orphan-thread",
|
|
177
|
+
parentCheckpointId: null,
|
|
178
|
+
createdAt: 11
|
|
179
|
+
},
|
|
180
|
+
expectedHeadId: null,
|
|
181
|
+
writer: orphanWriter
|
|
182
|
+
});
|
|
183
|
+
const destinationWriter = await checkpoints.acquireWriter("destination");
|
|
184
|
+
await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(destinationWriter, { destinationCheckpointId: "orphaned-id" })), "SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
export { runSandboxCheckpointForkConformance };
|
|
190
|
+
|
|
191
|
+
//# sourceMappingURL=checkpoint-fork-conformance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoint-fork-conformance.js","names":[],"sources":["../../../src/testkit/checkpoint-fork-conformance.ts"],"sourcesContent":["import { describe, expect, it } from 'vitest'\nimport type { ModelMessage } from '@tanstack/ai'\nimport type {\n ForkCapableSandboxCheckpointStore,\n SandboxCheckpoint,\n SandboxCheckpointForkInput,\n SandboxCheckpointWriter,\n SandboxCheckpointWriterLease,\n} from '../checkpoint-store'\nimport { InMemorySandboxCheckpointStore } from '../checkpoint-store'\n\n/** Combined stores required to exercise an atomic checkpoint fork. */\nexport interface SandboxCheckpointForkConformanceInput {\n persistence: {\n stores: {\n messages: {\n loadThread: (threadId: string) => Promise<Array<ModelMessage>>\n saveThread: (\n threadId: string,\n messages: Array<ModelMessage>,\n ) => Promise<void>\n }\n }\n }\n checkpoints: ForkCapableSandboxCheckpointStore\n}\n\nexport interface SandboxCheckpointForkConformanceFactory {\n ():\n | SandboxCheckpointForkConformanceInput\n | Promise<SandboxCheckpointForkConformanceInput>\n}\n\ntype ForkInputWithoutWriter = Omit<SandboxCheckpointForkInput, 'writer'>\n\nfunction sourceCheckpoint(): SandboxCheckpoint {\n return {\n id: 'source-root',\n threadId: 'source',\n parentCheckpointId: null,\n createdAt: 10,\n reason: 'named',\n label: 'source label',\n sourceRunId: 'run-1',\n files: [\n {\n path: 'a.txt',\n kind: 'file',\n blobKey: `sandbox-files/sha256/${'a'.repeat(64)}`,\n size: 1,\n },\n ],\n conversation: [{ role: 'user', content: 'checkpoint conversation' }],\n artifacts: [],\n }\n}\n\nasync function appendSource(\n checkpoints: ForkCapableSandboxCheckpointStore,\n): Promise<{\n source: SandboxCheckpoint\n sourceWriter: SandboxCheckpointWriterLease\n}> {\n const sourceWriter = await checkpoints.acquireWriter('source')\n const source = sourceCheckpoint()\n await checkpoints.append({\n checkpoint: source,\n expectedHeadId: null,\n writer: sourceWriter,\n })\n return { source, sourceWriter }\n}\n\nfunction forkInput(\n writer: SandboxCheckpointWriter,\n overrides: Partial<ForkInputWithoutWriter> = {},\n): SandboxCheckpointForkInput {\n return {\n sourceThreadId: 'source',\n sourceCheckpointId: 'source-root',\n destinationThreadId: 'destination',\n destinationCheckpointId: 'fork-root',\n createdAt: 20,\n writer,\n ...overrides,\n }\n}\n\nasync function destinationState(\n persistence: SandboxCheckpointForkConformanceInput['persistence'],\n checkpoints: ForkCapableSandboxCheckpointStore,\n) {\n return {\n transcript: await persistence.stores.messages.loadThread('destination'),\n head: await checkpoints.getHead('destination'),\n checkpoints: await checkpoints.list('destination'),\n references: await checkpoints.listBlobReferences(),\n }\n}\n\nasync function expectRejectedWithoutDestinationChanges(\n persistence: SandboxCheckpointForkConformanceInput['persistence'],\n checkpoints: ForkCapableSandboxCheckpointStore,\n operation: Promise<unknown>,\n code: string,\n): Promise<void> {\n const before = await destinationState(persistence, checkpoints)\n await expect(operation).rejects.toMatchObject({ code })\n expect(await destinationState(persistence, checkpoints)).toEqual(before)\n}\n\nexport function runSandboxCheckpointForkConformance(\n name: string,\n makeSnapshots: SandboxCheckpointForkConformanceFactory,\n): void {\n describe(`Sandbox checkpoint fork conformance: ${name}`, () => {\n it('copies a selected historical checkpoint and creates an exact fork root', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const { source, sourceWriter } = await appendSource(checkpoints)\n await checkpoints.append({\n checkpoint: {\n id: 'source-head',\n threadId: 'source',\n parentCheckpointId: source.id,\n createdAt: 11,\n reason: 'automatic',\n files: [],\n conversation: [{ role: 'user', content: 'newer conversation' }],\n artifacts: [],\n },\n expectedHeadId: source.id,\n writer: sourceWriter,\n })\n await persistence.stores.messages.saveThread('source', [\n { role: 'user', content: 'current source message' },\n ])\n const destinationWriter = await checkpoints.acquireWriter('destination')\n const result = await checkpoints.forkFromCheckpoint(\n forkInput(destinationWriter),\n )\n expect(result.checkpoint).toEqual({\n id: 'fork-root',\n threadId: 'destination',\n parentCheckpointId: null,\n createdAt: 20,\n reason: 'fork-root',\n files: source.files,\n conversation: source.conversation,\n artifacts: [],\n })\n expect(\n await persistence.stores.messages.loadThread('destination'),\n ).toEqual(source.conversation)\n expect(await checkpoints.getHead('source')).toBe('source-head')\n })\n\n it('rejects a plain checkpoint store because it has no fork capability', () => {\n const store = new InMemorySandboxCheckpointStore()\n expect('forkFromCheckpoint' in store).toBe(false)\n })\n\n it('rejects a missing source without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const writer = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(\n forkInput(writer, { sourceCheckpointId: 'missing' }),\n ),\n 'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND',\n )\n })\n\n it('rejects a source thread mismatch without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const writer = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(\n forkInput(writer, { sourceThreadId: 'another-source' }),\n ),\n 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',\n )\n })\n\n it('rejects equal source and destination threads without changing the source', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const { source, sourceWriter } = await appendSource(checkpoints)\n const before = {\n transcript: await persistence.stores.messages.loadThread('source'),\n head: await checkpoints.getHead('source'),\n checkpoints: await checkpoints.list('source'),\n references: await checkpoints.listBlobReferences(),\n }\n await expect(\n checkpoints.forkFromCheckpoint(\n forkInput(sourceWriter, {\n destinationThreadId: source.threadId,\n }),\n ),\n ).rejects.toMatchObject({\n code: 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',\n })\n expect({\n transcript: await persistence.stores.messages.loadThread('source'),\n head: await checkpoints.getHead('source'),\n checkpoints: await checkpoints.list('source'),\n references: await checkpoints.listBlobReferences(),\n }).toEqual(before)\n })\n\n it('rejects a missing destination writer without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const missingWriter: SandboxCheckpointWriter = {\n threadId: 'destination',\n ownerToken: 'missing-owner',\n fence: 1,\n }\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(missingWriter)),\n 'SANDBOX_SNAPSHOT_WRITER_LOST',\n )\n })\n\n it('rejects a wrong destination writer without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const wrongWriter = await checkpoints.acquireWriter('another-thread')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(wrongWriter)),\n 'SANDBOX_SNAPSHOT_WRITER_LOST',\n )\n })\n\n it('rejects a stale destination writer without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const staleWriter = await checkpoints.acquireWriter('destination')\n await staleWriter.release()\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(staleWriter)),\n 'SANDBOX_SNAPSHOT_WRITER_LOST',\n )\n })\n\n it('rejects a destination transcript without changing its checkpoint state', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n await persistence.stores.messages.saveThread('destination', [\n { role: 'user', content: 'already here' },\n ])\n const writer = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(writer)),\n 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',\n )\n })\n\n it('rejects an orphaned checkpoint id without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const { source } = await appendSource(checkpoints)\n const orphanWriter = await checkpoints.acquireWriter('orphan-thread')\n await checkpoints.append({\n checkpoint: {\n ...source,\n id: 'orphaned-id',\n threadId: 'orphan-thread',\n parentCheckpointId: null,\n createdAt: 11,\n },\n expectedHeadId: null,\n writer: orphanWriter,\n })\n const destinationWriter = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(\n forkInput(destinationWriter, {\n destinationCheckpointId: 'orphaned-id',\n }),\n ),\n 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',\n )\n })\n })\n}\n"],"mappings":";;;AAmCA,SAAS,mBAAsC;CAC7C,OAAO;EACL,IAAI;EACJ,UAAU;EACV,oBAAoB;EACpB,WAAW;EACX,QAAQ;EACR,OAAO;EACP,aAAa;EACb,OAAO,CACL;GACE,MAAM;GACN,MAAM;GACN,SAAS,wBAAwB,IAAI,OAAO,EAAE;GAC9C,MAAM;EACR,CACF;EACA,cAAc,CAAC;GAAE,MAAM;GAAQ,SAAS;EAA0B,CAAC;EACnE,WAAW,CAAC;CACd;AACF;AAEA,eAAe,aACb,aAIC;CACD,MAAM,eAAe,MAAM,YAAY,cAAc,QAAQ;CAC7D,MAAM,SAAS,iBAAiB;CAChC,MAAM,YAAY,OAAO;EACvB,YAAY;EACZ,gBAAgB;EAChB,QAAQ;CACV,CAAC;CACD,OAAO;EAAE;EAAQ;CAAa;AAChC;AAEA,SAAS,UACP,QACA,YAA6C,CAAC,GAClB;CAC5B,OAAO;EACL,gBAAgB;EAChB,oBAAoB;EACpB,qBAAqB;EACrB,yBAAyB;EACzB,WAAW;EACX;EACA,GAAG;CACL;AACF;AAEA,eAAe,iBACb,aACA,aACA;CACA,OAAO;EACL,YAAY,MAAM,YAAY,OAAO,SAAS,WAAW,aAAa;EACtE,MAAM,MAAM,YAAY,QAAQ,aAAa;EAC7C,aAAa,MAAM,YAAY,KAAK,aAAa;EACjD,YAAY,MAAM,YAAY,mBAAmB;CACnD;AACF;AAEA,eAAe,wCACb,aACA,aACA,WACA,MACe;CACf,MAAM,SAAS,MAAM,iBAAiB,aAAa,WAAW;CAC9D,MAAM,OAAO,SAAS,CAAC,CAAC,QAAQ,cAAc,EAAE,KAAK,CAAC;CACtD,OAAO,MAAM,iBAAiB,aAAa,WAAW,CAAC,CAAC,CAAC,QAAQ,MAAM;AACzE;AAEA,SAAgB,oCACd,MACA,eACM;CACN,SAAS,wCAAwC,cAAc;EAC7D,GAAG,0EAA0E,YAAY;GACvF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,EAAE,QAAQ,iBAAiB,MAAM,aAAa,WAAW;GAC/D,MAAM,YAAY,OAAO;IACvB,YAAY;KACV,IAAI;KACJ,UAAU;KACV,oBAAoB,OAAO;KAC3B,WAAW;KACX,QAAQ;KACR,OAAO,CAAC;KACR,cAAc,CAAC;MAAE,MAAM;MAAQ,SAAS;KAAqB,CAAC;KAC9D,WAAW,CAAC;IACd;IACA,gBAAgB,OAAO;IACvB,QAAQ;GACV,CAAC;GACD,MAAM,YAAY,OAAO,SAAS,WAAW,UAAU,CACrD;IAAE,MAAM;IAAQ,SAAS;GAAyB,CACpD,CAAC;GACD,MAAM,oBAAoB,MAAM,YAAY,cAAc,aAAa;GACvE,MAAM,SAAS,MAAM,YAAY,mBAC/B,UAAU,iBAAiB,CAC7B;GACA,OAAO,OAAO,UAAU,CAAC,CAAC,QAAQ;IAChC,IAAI;IACJ,UAAU;IACV,oBAAoB;IACpB,WAAW;IACX,QAAQ;IACR,OAAO,OAAO;IACd,cAAc,OAAO;IACrB,WAAW,CAAC;GACd,CAAC;GACD,OACE,MAAM,YAAY,OAAO,SAAS,WAAW,aAAa,CAC5D,CAAC,CAAC,QAAQ,OAAO,YAAY;GAC7B,OAAO,MAAM,YAAY,QAAQ,QAAQ,CAAC,CAAC,CAAC,KAAK,aAAa;EAChE,CAAC;EAED,GAAG,4EAA4E;GAC7E,MAAM,QAAQ,IAAI,+BAA+B;GACjD,OAAO,wBAAwB,KAAK,CAAC,CAAC,KAAK,KAAK;EAClD,CAAC;EAED,GAAG,6DAA6D,YAAY;GAC1E,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,SAAS,MAAM,YAAY,cAAc,aAAa;GAC5D,MAAM,wCACJ,aACA,aACA,YAAY,mBACV,UAAU,QAAQ,EAAE,oBAAoB,UAAU,CAAC,CACrD,GACA,wCACF;EACF,CAAC;EAED,GAAG,qEAAqE,YAAY;GAClF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,aAAa,WAAW;GAC9B,MAAM,SAAS,MAAM,YAAY,cAAc,aAAa;GAC5D,MAAM,wCACJ,aACA,aACA,YAAY,mBACV,UAAU,QAAQ,EAAE,gBAAgB,iBAAiB,CAAC,CACxD,GACA,8CACF;EACF,CAAC;EAED,GAAG,4EAA4E,YAAY;GACzF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,EAAE,QAAQ,iBAAiB,MAAM,aAAa,WAAW;GAC/D,MAAM,SAAS;IACb,YAAY,MAAM,YAAY,OAAO,SAAS,WAAW,QAAQ;IACjE,MAAM,MAAM,YAAY,QAAQ,QAAQ;IACxC,aAAa,MAAM,YAAY,KAAK,QAAQ;IAC5C,YAAY,MAAM,YAAY,mBAAmB;GACnD;GACA,MAAM,OACJ,YAAY,mBACV,UAAU,cAAc,EACtB,qBAAqB,OAAO,SAC9B,CAAC,CACH,CACF,CAAC,CAAC,QAAQ,cAAc,EACtB,MAAM,+CACR,CAAC;GACD,OAAO;IACL,YAAY,MAAM,YAAY,OAAO,SAAS,WAAW,QAAQ;IACjE,MAAM,MAAM,YAAY,QAAQ,QAAQ;IACxC,aAAa,MAAM,YAAY,KAAK,QAAQ;IAC5C,YAAY,MAAM,YAAY,mBAAmB;GACnD,CAAC,CAAC,CAAC,QAAQ,MAAM;EACnB,CAAC;EAED,GAAG,yEAAyE,YAAY;GACtF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,aAAa,WAAW;GAM9B,MAAM,wCACJ,aACA,aACA,YAAY,mBAAmB,UAAU;IAPzC,UAAU;IACV,YAAY;IACZ,OAAO;GAKkC,CAAa,CAAC,GACvD,8BACF;EACF,CAAC;EAED,GAAG,uEAAuE,YAAY;GACpF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,aAAa,WAAW;GAC9B,MAAM,cAAc,MAAM,YAAY,cAAc,gBAAgB;GACpE,MAAM,wCACJ,aACA,aACA,YAAY,mBAAmB,UAAU,WAAW,CAAC,GACrD,8BACF;EACF,CAAC;EAED,GAAG,uEAAuE,YAAY;GACpF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,aAAa,WAAW;GAC9B,MAAM,cAAc,MAAM,YAAY,cAAc,aAAa;GACjE,MAAM,YAAY,QAAQ;GAC1B,MAAM,wCACJ,aACA,aACA,YAAY,mBAAmB,UAAU,WAAW,CAAC,GACrD,8BACF;EACF,CAAC;EAED,GAAG,0EAA0E,YAAY;GACvF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,aAAa,WAAW;GAC9B,MAAM,YAAY,OAAO,SAAS,WAAW,eAAe,CAC1D;IAAE,MAAM;IAAQ,SAAS;GAAe,CAC1C,CAAC;GACD,MAAM,SAAS,MAAM,YAAY,cAAc,aAAa;GAC5D,MAAM,wCACJ,aACA,aACA,YAAY,mBAAmB,UAAU,MAAM,CAAC,GAChD,6CACF;EACF,CAAC;EAED,GAAG,sEAAsE,YAAY;GACnF,MAAM,EAAE,aAAa,gBAAgB,MAAM,cAAc;GACzD,MAAM,EAAE,WAAW,MAAM,aAAa,WAAW;GACjD,MAAM,eAAe,MAAM,YAAY,cAAc,eAAe;GACpE,MAAM,YAAY,OAAO;IACvB,YAAY;KACV,GAAG;KACH,IAAI;KACJ,UAAU;KACV,oBAAoB;KACpB,WAAW;IACb;IACA,gBAAgB;IAChB,QAAQ;GACV,CAAC;GACD,MAAM,oBAAoB,MAAM,YAAY,cAAc,aAAa;GACvE,MAAM,wCACJ,aACA,aACA,YAAY,mBACV,UAAU,mBAAmB,EAC3B,yBAAyB,cAC3B,CAAC,CACH,GACA,6CACF;EACF,CAAC;CACH,CAAC;AACH"}
|
|
@@ -8,6 +8,10 @@ export type { ReaperConformanceConfig } from './reaper-conformance.js';
|
|
|
8
8
|
export { runDurableRunFieldsConformance } from './durable-run-fields-conformance.js';
|
|
9
9
|
export type { MakeRunStore } from './durable-run-fields-conformance.js';
|
|
10
10
|
export { makeFakeShellSpawn } from './shell-spawn.js';
|
|
11
|
+
export { runSandboxCheckpointStoreConformance } from './checkpoint-conformance.js';
|
|
12
|
+
export type { SandboxCheckpointStoreOptions } from '../checkpoint-store.js';
|
|
13
|
+
export { runSandboxCheckpointForkConformance } from './checkpoint-fork-conformance.js';
|
|
14
|
+
export type { SandboxCheckpointForkConformanceInput, SandboxCheckpointForkConformanceFactory, } from './checkpoint-fork-conformance.js';
|
|
11
15
|
/**
|
|
12
16
|
* Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each
|
|
13
17
|
* `it` gets a fresh store, so implementations may share process state across
|
|
@@ -3,6 +3,8 @@ import { runTakeoverConformance } from "./takeover-conformance.js";
|
|
|
3
3
|
import { runReaperConformance } from "./reaper-conformance.js";
|
|
4
4
|
import { runDurableRunFieldsConformance } from "./durable-run-fields-conformance.js";
|
|
5
5
|
import { makeFakeShellSpawn } from "./shell-spawn.js";
|
|
6
|
+
import { runSandboxCheckpointStoreConformance } from "./checkpoint-conformance.js";
|
|
7
|
+
import { runSandboxCheckpointForkConformance } from "./checkpoint-fork-conformance.js";
|
|
6
8
|
import { describe, expect, it } from "vitest";
|
|
7
9
|
//#region src/testkit/conformance.ts
|
|
8
10
|
/**
|
|
@@ -94,6 +96,6 @@ function runSandboxInstanceStoreConformance(name, makeStore) {
|
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
//#endregion
|
|
97
|
-
export { makeFakeShellSpawn, runDurableRunFieldsConformance, runJournalConformance, runReaperConformance, runSandboxInstanceStoreConformance, runTakeoverConformance };
|
|
99
|
+
export { makeFakeShellSpawn, runDurableRunFieldsConformance, runJournalConformance, runReaperConformance, runSandboxCheckpointForkConformance, runSandboxCheckpointStoreConformance, runSandboxInstanceStoreConformance, runTakeoverConformance };
|
|
98
100
|
|
|
99
101
|
//# sourceMappingURL=conformance.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conformance.js","names":[],"sources":["../../../src/testkit/conformance.ts"],"sourcesContent":["/**\n * Conformance suite for a {@link SandboxInstanceStore} implementation.\n *\n * Run this against a fresh BYO store (or the in-memory reference) to prove it\n * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure\n * algorithm relies on — including insert-vs-overwrite and optional-field\n * handling (full replace must clear omitted optionals).\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport type {\n SandboxInstanceRecord,\n SandboxInstanceStore,\n} from '../instance-store'\n\n// The `./testkit` package.json subpath resolves to THIS module's build output\n// only (`dist/esm/testkit/conformance.js`), not a barrel over `src/testkit/`.\n// Re-export the journal, takeover, and reaper conformance suites here so they\n// are reachable from `@tanstack/ai-sandbox/testkit` without changing that\n// mapping.\nexport { runJournalConformance } from './journal-conformance'\nexport type { JournalConformanceConfig } from './journal-conformance'\nexport { runTakeoverConformance } from './takeover-conformance'\nexport type { TakeoverConformanceConfig } from './takeover-conformance'\nexport { runReaperConformance } from './reaper-conformance'\nexport type { ReaperConformanceConfig } from './reaper-conformance'\nexport { runDurableRunFieldsConformance } from './durable-run-fields-conformance'\nexport type { MakeRunStore } from './durable-run-fields-conformance'\nexport { makeFakeShellSpawn } from './shell-spawn'\n\nfunction makeRecord(\n overrides?: Partial<SandboxInstanceRecord>,\n): SandboxInstanceRecord {\n return {\n key: 'thread-1',\n provider: 'fake',\n providerSandboxId: 'sb-1',\n threadId: 'thread-1',\n updatedAt: 1,\n ...overrides,\n }\n}\n\n/**\n * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each\n * `it` gets a fresh store, so implementations may share process state across\n * calls without cross-test bleed only if `makeStore` returns an isolated store.\n */\nexport function runSandboxInstanceStoreConformance(\n name: string,\n makeStore: () => SandboxInstanceStore | Promise<SandboxInstanceStore>,\n): void {\n describe(`SandboxInstanceStore conformance: ${name}`, () => {\n it('returns null for a missing key', async () => {\n const store = await makeStore()\n expect(await store.get('absent')).toBeNull()\n })\n\n it('round-trips an upserted record with all fields', async () => {\n const store = await makeStore()\n const record = makeRecord({\n latestSnapshotId: 'snap-1',\n latestRunId: 'run-1',\n })\n await store.upsert(record)\n expect(await store.get(record.key)).toEqual(record)\n })\n\n it('omits absent optional fields on read', async () => {\n const store = await makeStore()\n const record = makeRecord()\n await store.upsert(record)\n const loaded = await store.get(record.key)\n expect(loaded).toEqual(record)\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n expect(loaded && 'latestRunId' in loaded).toBe(false)\n })\n\n it('overwrites an existing record on re-upsert', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' }))\n await store.upsert(\n makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }),\n )\n const loaded = await store.get('thread-1')\n expect(loaded?.providerSandboxId).toBe('sb-2')\n expect(loaded?.updatedAt).toBe(2)\n // The overwrite dropped latestSnapshotId — a durable store must clear it,\n // not retain the prior value.\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n })\n\n it('isolates records by key', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ key: 'a', threadId: 'a' }))\n await store.upsert(\n makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }),\n )\n expect((await store.get('a'))?.providerSandboxId).toBe('sb-1')\n expect((await store.get('b'))?.providerSandboxId).toBe('sb-b')\n })\n\n it('deletes a record', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord())\n await store.delete('thread-1')\n expect(await store.get('thread-1')).toBeNull()\n })\n\n it('delete of a missing key is a no-op', async () => {\n const store = await makeStore()\n await expect(store.delete('absent')).resolves.toBeUndefined()\n })\n })\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"conformance.js","names":[],"sources":["../../../src/testkit/conformance.ts"],"sourcesContent":["/**\n * Conformance suite for a {@link SandboxInstanceStore} implementation.\n *\n * Run this against a fresh BYO store (or the in-memory reference) to prove it\n * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure\n * algorithm relies on — including insert-vs-overwrite and optional-field\n * handling (full replace must clear omitted optionals).\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport type {\n SandboxInstanceRecord,\n SandboxInstanceStore,\n} from '../instance-store'\n\n// The `./testkit` package.json subpath resolves to THIS module's build output\n// only (`dist/esm/testkit/conformance.js`), not a barrel over `src/testkit/`.\n// Re-export the journal, takeover, and reaper conformance suites here so they\n// are reachable from `@tanstack/ai-sandbox/testkit` without changing that\n// mapping.\nexport { runJournalConformance } from './journal-conformance'\nexport type { JournalConformanceConfig } from './journal-conformance'\nexport { runTakeoverConformance } from './takeover-conformance'\nexport type { TakeoverConformanceConfig } from './takeover-conformance'\nexport { runReaperConformance } from './reaper-conformance'\nexport type { ReaperConformanceConfig } from './reaper-conformance'\nexport { runDurableRunFieldsConformance } from './durable-run-fields-conformance'\nexport type { MakeRunStore } from './durable-run-fields-conformance'\nexport { makeFakeShellSpawn } from './shell-spawn'\nexport { runSandboxCheckpointStoreConformance } from './checkpoint-conformance'\nexport type { SandboxCheckpointStoreOptions } from '../checkpoint-store'\nexport { runSandboxCheckpointForkConformance } from './checkpoint-fork-conformance'\nexport type {\n SandboxCheckpointForkConformanceInput,\n SandboxCheckpointForkConformanceFactory,\n} from './checkpoint-fork-conformance'\n\nfunction makeRecord(\n overrides?: Partial<SandboxInstanceRecord>,\n): SandboxInstanceRecord {\n return {\n key: 'thread-1',\n provider: 'fake',\n providerSandboxId: 'sb-1',\n threadId: 'thread-1',\n updatedAt: 1,\n ...overrides,\n }\n}\n\n/**\n * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each\n * `it` gets a fresh store, so implementations may share process state across\n * calls without cross-test bleed only if `makeStore` returns an isolated store.\n */\nexport function runSandboxInstanceStoreConformance(\n name: string,\n makeStore: () => SandboxInstanceStore | Promise<SandboxInstanceStore>,\n): void {\n describe(`SandboxInstanceStore conformance: ${name}`, () => {\n it('returns null for a missing key', async () => {\n const store = await makeStore()\n expect(await store.get('absent')).toBeNull()\n })\n\n it('round-trips an upserted record with all fields', async () => {\n const store = await makeStore()\n const record = makeRecord({\n latestSnapshotId: 'snap-1',\n latestRunId: 'run-1',\n })\n await store.upsert(record)\n expect(await store.get(record.key)).toEqual(record)\n })\n\n it('omits absent optional fields on read', async () => {\n const store = await makeStore()\n const record = makeRecord()\n await store.upsert(record)\n const loaded = await store.get(record.key)\n expect(loaded).toEqual(record)\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n expect(loaded && 'latestRunId' in loaded).toBe(false)\n })\n\n it('overwrites an existing record on re-upsert', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' }))\n await store.upsert(\n makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }),\n )\n const loaded = await store.get('thread-1')\n expect(loaded?.providerSandboxId).toBe('sb-2')\n expect(loaded?.updatedAt).toBe(2)\n // The overwrite dropped latestSnapshotId — a durable store must clear it,\n // not retain the prior value.\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n })\n\n it('isolates records by key', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ key: 'a', threadId: 'a' }))\n await store.upsert(\n makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }),\n )\n expect((await store.get('a'))?.providerSandboxId).toBe('sb-1')\n expect((await store.get('b'))?.providerSandboxId).toBe('sb-b')\n })\n\n it('deletes a record', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord())\n await store.delete('thread-1')\n expect(await store.get('thread-1')).toBeNull()\n })\n\n it('delete of a missing key is a no-op', async () => {\n const store = await makeStore()\n await expect(store.delete('absent')).resolves.toBeUndefined()\n })\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuCA,SAAS,WACP,WACuB;CACvB,OAAO;EACL,KAAK;EACL,UAAU;EACV,mBAAmB;EACnB,UAAU;EACV,WAAW;EACX,GAAG;CACL;AACF;;;;;;AAOA,SAAgB,mCACd,MACA,WACM;CACN,SAAS,qCAAqC,cAAc;EAC1D,GAAG,kCAAkC,YAAY;GAC/C,MAAM,QAAQ,MAAM,UAAU;GAC9B,OAAO,MAAM,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAC,SAAS;EAC7C,CAAC;EAED,GAAG,kDAAkD,YAAY;GAC/D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;IACxB,kBAAkB;IAClB,aAAa;GACf,CAAC;GACD,MAAM,MAAM,OAAO,MAAM;GACzB,OAAO,MAAM,MAAM,IAAI,OAAO,GAAG,CAAC,CAAC,CAAC,QAAQ,MAAM;EACpD,CAAC;EAED,GAAG,wCAAwC,YAAY;GACrD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;GAC1B,MAAM,MAAM,OAAO,MAAM;GACzB,MAAM,SAAS,MAAM,MAAM,IAAI,OAAO,GAAG;GACzC,OAAO,MAAM,CAAC,CAAC,QAAQ,MAAM;GAC7B,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;GACzD,OAAO,UAAU,iBAAiB,MAAM,CAAC,CAAC,KAAK,KAAK;EACtD,CAAC;EAED,GAAG,8CAA8C,YAAY;GAC3D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,EAAE,kBAAkB,SAAS,CAAC,CAAC;GAC7D,MAAM,MAAM,OACV,WAAW;IAAE,mBAAmB;IAAQ,WAAW;GAAE,CAAC,CACxD;GACA,MAAM,SAAS,MAAM,MAAM,IAAI,UAAU;GACzC,OAAO,QAAQ,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7C,OAAO,QAAQ,SAAS,CAAC,CAAC,KAAK,CAAC;GAGhC,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;EAC3D,CAAC;EAED,GAAG,2BAA2B,YAAY;GACxC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW;IAAE,KAAK;IAAK,UAAU;GAAI,CAAC,CAAC;GAC1D,MAAM,MAAM,OACV,WAAW;IAAE,KAAK;IAAK,UAAU;IAAK,mBAAmB;GAAO,CAAC,CACnE;GACA,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7D,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;EAC/D,CAAC;EAED,GAAG,oBAAoB,YAAY;GACjC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,CAAC;GAC/B,MAAM,MAAM,OAAO,UAAU;GAC7B,OAAO,MAAM,MAAM,IAAI,UAAU,CAAC,CAAC,CAAC,SAAS;EAC/C,CAAC;EAED,GAAG,sCAAsC,YAAY;GACnD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,CAAC,SAAS,cAAc;EAC9D,CAAC;CACH,CAAC;AACH"}
|
package/dist/esm/tool-history.js
CHANGED
|
@@ -96,7 +96,7 @@ function createToolHistoryRecorder() {
|
|
|
96
96
|
return {
|
|
97
97
|
observe(chunk, target) {
|
|
98
98
|
if (chunk.type === EventType.TOOL_CALL_START) {
|
|
99
|
-
const name = chunk.toolCallName
|
|
99
|
+
const name = chunk.toolCallName;
|
|
100
100
|
if (!name) return;
|
|
101
101
|
open.set(chunk.toolCallId, {
|
|
102
102
|
name,
|
|
@@ -107,20 +107,19 @@ function createToolHistoryRecorder() {
|
|
|
107
107
|
if (chunk.type === EventType.TOOL_CALL_ARGS) {
|
|
108
108
|
const call = open.get(chunk.toolCallId);
|
|
109
109
|
if (!call) return;
|
|
110
|
-
call.args
|
|
110
|
+
call.args += chunk.delta;
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
if (chunk.type === EventType.TOOL_CALL_END) {
|
|
114
114
|
const call = open.get(chunk.toolCallId);
|
|
115
115
|
if (!call) return;
|
|
116
116
|
open.delete(chunk.toolCallId);
|
|
117
|
-
const args = chunk.input !== void 0 ? JSON.stringify(chunk.input) : call.args;
|
|
118
117
|
recorded.push({
|
|
119
118
|
id: chunk.toolCallId,
|
|
120
119
|
name: call.name,
|
|
121
|
-
args
|
|
120
|
+
args: call.args
|
|
122
121
|
});
|
|
123
|
-
appendCall(target, chunk.toolCallId, call.name, args);
|
|
122
|
+
appendCall(target, chunk.toolCallId, call.name, call.args);
|
|
124
123
|
return;
|
|
125
124
|
}
|
|
126
125
|
if (chunk.type === EventType.TOOL_CALL_RESULT) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-history.js","names":[],"sources":["../../src/tool-history.ts"],"sourcesContent":["/**\n * Turn a harness's PASSTHROUGH tool-call chunks into transcript messages, so a\n * finished run's tool cards survive a reload.\n *\n * Why this is needed at all: a harness executes its tools INSIDE the sandbox, so\n * `chat()` only relays its `TOOL_CALL_*` chunks — it never writes an assistant\n * message for them (`addAssistantToolCallMessage` is gated on the engine having\n * executed the tool itself). Chat persistence stores `ctx.messages`, so the whole\n * tool history existed only in the delivery log. Replaying that log is what makes\n * \"switch away and come back\" show everything; a FINISHED thread has no run to\n * rejoin, hydrates from the message store instead, and so came back as nothing but\n * the prompt and the final answer.\n *\n * Recording the calls as ordinary `toolCalls` + `role: 'tool'` messages needs no new\n * wire format and no client change: `modelMessagesToUIMessages` already merges a tool\n * result into the call it belongs to and marks the part complete, and\n * `reconstructChat` already runs that converter.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\n\n/**\n * Metadata key set on every tool call recorded here.\n *\n * INTERNAL, and deliberately not exported: an app asks {@link isSandboxToolCall}\n * instead of knowing the key. Renaming it is a storage-visible change, because it ends\n * up inside stored `toolCalls[].metadata`, so the recorder test pins the literal.\n */\nconst SANDBOX_OBSERVED = 'sandboxObserved'\n\n/**\n * What the recorder writes into. `ChatMiddlewareContext.messages` is a\n * `ReadonlyArray`, so the transcript grows by REPLACING the array — the same way the\n * engine itself syncs `middlewareCtx.messages`.\n */\ninterface TranscriptTarget {\n messages: ReadonlyArray<ModelMessage>\n}\n\ninterface OpenCall {\n name: string\n /** Accumulated `TOOL_CALL_ARGS` deltas; superseded by `input` when the adapter sends it. */\n args: string\n}\n\nexport interface ToolHistoryRecorder {\n /** Feed every chunk. Observes only — never transforms or drops. */\n observe: (chunk: StreamChunk, target: TranscriptTarget) => void\n /**\n * Re-append anything missing from the transcript.\n *\n * The engine reassigns `middlewareCtx.messages` from its own array whenever it\n * syncs config (once per agent iteration), which discards writes made during the\n * previous iteration's stream. Reconciling at each iteration boundary and again at\n * finish makes the result independent of that, and independent of where this\n * middleware sits relative to persistence in the middleware array.\n */\n reconcile: (target: TranscriptTarget) => void\n}\n\n/**\n * True when this tool call was executed by the HARNESS inside the sandbox, and\n * recorded into the transcript for display, rather than executed by the agent loop.\n *\n * Use it to decide what your own `MessageStore` keeps — these calls are display\n * history, so dropping or capping them is safe (they are already stripped from the\n * request to the model on the next turn). Also works on a `tool-call` UI part, whose\n * `metadata` is copied straight from the model message.\n *\n * `metadata` is `unknown` on both, so the key can only be read behind a typeof/`in`\n * check; this mirrors the core `isProviderExecutedToolCall` convention.\n *\n * ```ts\n * import { isSandboxToolCall } from '@tanstack/ai-sandbox'\n *\n * const kept = messages.filter(\n * (message) => !message.toolCalls?.every(isSandboxToolCall),\n * )\n * ```\n */\nexport function isSandboxToolCall(\n toolCall: { metadata?: unknown } | null | undefined,\n): boolean {\n const metadata = toolCall?.metadata\n return (\n typeof metadata === 'object' &&\n metadata !== null &&\n SANDBOX_OBSERVED in metadata &&\n metadata[SANDBOX_OBSERVED] === true\n )\n}\n\n/** Does the transcript already carry this tool call, from any source? */\nfunction hasCall(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some((message) =>\n message.toolCalls?.some((call) => call.id === id),\n )\n}\n\n/** Does the transcript already carry this tool result? */\nfunction hasResult(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some(\n (message) => message.role === 'tool' && message.toolCallId === id,\n )\n}\n\nfunction callMessage(id: string, name: string, args: string): ModelMessage {\n return {\n role: 'assistant',\n content: null,\n toolCalls: [\n {\n id,\n type: 'function',\n function: { name, arguments: args },\n metadata: { [SANDBOX_OBSERVED]: true },\n },\n ],\n }\n}\n\nfunction resultMessage(id: string, content: string): ModelMessage {\n return { role: 'tool', toolCallId: id, content }\n}\n\nexport function createToolHistoryRecorder(): ToolHistoryRecorder {\n const open = new Map<string, OpenCall>()\n /** Completed calls in the order they ran — the order `reconcile` restores. */\n const recorded: Array<{ id: string; name: string; args: string }> = []\n const results = new Map<string, string>()\n\n function appendCall(\n target: TranscriptTarget,\n id: string,\n name: string,\n args: string,\n ): void {\n // An id already present is either the engine's own (it executed the tool itself)\n // or a chunk seen before — a journal replay on takeover re-emits the whole\n // stream. Either way a second write would duplicate the card.\n if (hasCall(target.messages, id)) return\n target.messages = [...target.messages, callMessage(id, name, args)]\n }\n\n function appendResult(\n target: TranscriptTarget,\n id: string,\n content: string,\n ): void {\n if (hasResult(target.messages, id)) return\n target.messages = [...target.messages, resultMessage(id, content)]\n }\n\n const recorder: ToolHistoryRecorder = {\n // An if/else chain rather than a `switch`: only four of the ~20 chunk types are\n // interesting here, and a `switch` on `chunk.type` has to enumerate all of them\n // to satisfy the exhaustiveness lint.\n observe(chunk, target) {\n if (chunk.type === EventType.TOOL_CALL_START) {\n // `toolCallName` is the AG-UI field; `toolName` is its deprecated alias, and\n // that alias is what several harness adapters still emit.\n const name = chunk.toolCallName ?? chunk.toolName\n if (!name) return\n open.set(chunk.toolCallId, { name, args: '' })\n return\n }\n if (chunk.type === EventType.TOOL_CALL_ARGS) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n // `args` is the accumulated-so-far field. Prefer it over stitching deltas:\n // an adapter that sends both would otherwise double the arguments.\n call.args = chunk.args ?? call.args + chunk.delta\n return\n }\n if (chunk.type === EventType.TOOL_CALL_END) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n open.delete(chunk.toolCallId)\n // `input` is the final PARSED input, so it beats the streamed string, which\n // can be a truncated fragment if the arguments stream was cut short.\n const args =\n chunk.input !== undefined ? JSON.stringify(chunk.input) : call.args\n recorded.push({ id: chunk.toolCallId, name: call.name, args })\n appendCall(target, chunk.toolCallId, call.name, args)\n return\n }\n if (chunk.type === EventType.TOOL_CALL_RESULT) {\n // AG-UI types `content` as a string; anything else is not a result we can\n // store as a `role: 'tool'` message.\n if (typeof chunk.content !== 'string') return\n results.set(chunk.toolCallId, chunk.content)\n appendResult(target, chunk.toolCallId, chunk.content)\n }\n },\n\n reconcile(target) {\n for (const { id, name, args } of recorded) {\n appendCall(target, id, name, args)\n const result = results.get(id)\n // The result goes straight after its own call, so a restored transcript reads\n // in the order the tools actually ran.\n if (result !== undefined) appendResult(target, id, result)\n }\n },\n }\n return recorder\n}\n\n/**\n * Drop recorded harness tool calls from a list of messages bound for the model.\n *\n * A stored transcript becomes the history for the NEXT turn. These calls name tools\n * the provider was never given, and one triage-sized run is hundreds of kilobytes of\n * tool output — so replaying them is wasteful at best and rejected at worst. They stay\n * in `ctx.messages` (which is what gets stored and rendered); only the request to the\n * model loses them.\n *\n * An assistant message is dropped only when EVERY call on it is observed, so a mixed\n * message — one engine tool call plus one harness tool call — is left alone rather than\n * silently losing the engine's half.\n */\nexport function stripObservedToolCalls(\n messages: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const dropped = new Set<string>()\n const kept: Array<ModelMessage> = []\n for (const message of messages) {\n const calls = message.toolCalls\n if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {\n for (const call of calls) dropped.add(call.id)\n continue\n }\n // Orphaning a result is worse than keeping it: a provider rejects a tool result\n // whose call is not in the history.\n if (\n message.role === 'tool' &&\n message.toolCallId !== undefined &&\n dropped.has(message.toolCallId)\n ) {\n continue\n }\n kept.push(message)\n }\n return kept\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tool-history.js","names":[],"sources":["../../src/tool-history.ts"],"sourcesContent":["/**\n * Turn a harness's PASSTHROUGH tool-call chunks into transcript messages, so a\n * finished run's tool cards survive a reload.\n *\n * Why this is needed at all: a harness executes its tools INSIDE the sandbox, so\n * `chat()` only relays its `TOOL_CALL_*` chunks — it never writes an assistant\n * message for them (`addAssistantToolCallMessage` is gated on the engine having\n * executed the tool itself). Chat persistence stores `ctx.messages`, so the whole\n * tool history existed only in the delivery log. Replaying that log is what makes\n * \"switch away and come back\" show everything; a FINISHED thread has no run to\n * rejoin, hydrates from the message store instead, and so came back as nothing but\n * the prompt and the final answer.\n *\n * Recording the calls as ordinary `toolCalls` + `role: 'tool'` messages needs no new\n * wire format and no client change: `modelMessagesToUIMessages` already merges a tool\n * result into the call it belongs to and marks the part complete, and\n * `reconstructChat` already runs that converter.\n */\nimport { EventType } from '@tanstack/ai'\nimport type { ModelMessage, StreamChunk } from '@tanstack/ai'\n\n/**\n * Metadata key set on every tool call recorded here.\n *\n * INTERNAL, and deliberately not exported: an app asks {@link isSandboxToolCall}\n * instead of knowing the key. Renaming it is a storage-visible change, because it ends\n * up inside stored `toolCalls[].metadata`, so the recorder test pins the literal.\n */\nconst SANDBOX_OBSERVED = 'sandboxObserved'\n\n/**\n * What the recorder writes into. `ChatMiddlewareContext.messages` is a\n * `ReadonlyArray`, so the transcript grows by REPLACING the array — the same way the\n * engine itself syncs `middlewareCtx.messages`.\n */\ninterface TranscriptTarget {\n messages: ReadonlyArray<ModelMessage>\n}\n\ninterface OpenCall {\n name: string\n /** Accumulated `TOOL_CALL_ARGS` deltas. */\n args: string\n}\n\nexport interface ToolHistoryRecorder {\n /** Feed every chunk. Observes only — never transforms or drops. */\n observe: (chunk: StreamChunk, target: TranscriptTarget) => void\n /**\n * Re-append anything missing from the transcript.\n *\n * The engine reassigns `middlewareCtx.messages` from its own array whenever it\n * syncs config (once per agent iteration), which discards writes made during the\n * previous iteration's stream. Reconciling at each iteration boundary and again at\n * finish makes the result independent of that, and independent of where this\n * middleware sits relative to persistence in the middleware array.\n */\n reconcile: (target: TranscriptTarget) => void\n}\n\n/**\n * True when this tool call was executed by the HARNESS inside the sandbox, and\n * recorded into the transcript for display, rather than executed by the agent loop.\n *\n * Use it to decide what your own `MessageStore` keeps — these calls are display\n * history, so dropping or capping them is safe (they are already stripped from the\n * request to the model on the next turn). Also works on a `tool-call` UI part, whose\n * `metadata` is copied straight from the model message.\n *\n * `metadata` is `unknown` on both, so the key can only be read behind a typeof/`in`\n * check; this mirrors the core `isProviderExecutedToolCall` convention.\n *\n * ```ts\n * import { isSandboxToolCall } from '@tanstack/ai-sandbox'\n *\n * const kept = messages.filter(\n * (message) => !message.toolCalls?.every(isSandboxToolCall),\n * )\n * ```\n */\nexport function isSandboxToolCall(\n toolCall: { metadata?: unknown } | null | undefined,\n): boolean {\n const metadata = toolCall?.metadata\n return (\n typeof metadata === 'object' &&\n metadata !== null &&\n SANDBOX_OBSERVED in metadata &&\n metadata[SANDBOX_OBSERVED] === true\n )\n}\n\n/** Does the transcript already carry this tool call, from any source? */\nfunction hasCall(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some((message) =>\n message.toolCalls?.some((call) => call.id === id),\n )\n}\n\n/** Does the transcript already carry this tool result? */\nfunction hasResult(messages: ReadonlyArray<ModelMessage>, id: string): boolean {\n return messages.some(\n (message) => message.role === 'tool' && message.toolCallId === id,\n )\n}\n\nfunction callMessage(id: string, name: string, args: string): ModelMessage {\n return {\n role: 'assistant',\n content: null,\n toolCalls: [\n {\n id,\n type: 'function',\n function: { name, arguments: args },\n metadata: { [SANDBOX_OBSERVED]: true },\n },\n ],\n }\n}\n\nfunction resultMessage(id: string, content: string): ModelMessage {\n return { role: 'tool', toolCallId: id, content }\n}\n\nexport function createToolHistoryRecorder(): ToolHistoryRecorder {\n const open = new Map<string, OpenCall>()\n /** Completed calls in the order they ran — the order `reconcile` restores. */\n const recorded: Array<{ id: string; name: string; args: string }> = []\n const results = new Map<string, string>()\n\n function appendCall(\n target: TranscriptTarget,\n id: string,\n name: string,\n args: string,\n ): void {\n // An id already present is either the engine's own (it executed the tool itself)\n // or a chunk seen before — a journal replay on takeover re-emits the whole\n // stream. Either way a second write would duplicate the card.\n if (hasCall(target.messages, id)) return\n target.messages = [...target.messages, callMessage(id, name, args)]\n }\n\n function appendResult(\n target: TranscriptTarget,\n id: string,\n content: string,\n ): void {\n if (hasResult(target.messages, id)) return\n target.messages = [...target.messages, resultMessage(id, content)]\n }\n\n const recorder: ToolHistoryRecorder = {\n // An if/else chain rather than a `switch`: only four of the ~20 chunk types are\n // interesting here, and a `switch` on `chunk.type` has to enumerate all of them\n // to satisfy the exhaustiveness lint.\n observe(chunk, target) {\n if (chunk.type === EventType.TOOL_CALL_START) {\n const name = chunk.toolCallName\n if (!name) return\n open.set(chunk.toolCallId, { name, args: '' })\n return\n }\n if (chunk.type === EventType.TOOL_CALL_ARGS) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n call.args += chunk.delta\n return\n }\n if (chunk.type === EventType.TOOL_CALL_END) {\n const call = open.get(chunk.toolCallId)\n if (!call) return\n open.delete(chunk.toolCallId)\n recorded.push({\n id: chunk.toolCallId,\n name: call.name,\n args: call.args,\n })\n appendCall(target, chunk.toolCallId, call.name, call.args)\n return\n }\n if (chunk.type === EventType.TOOL_CALL_RESULT) {\n // AG-UI types `content` as a string; anything else is not a result we can\n // store as a `role: 'tool'` message.\n if (typeof chunk.content !== 'string') return\n results.set(chunk.toolCallId, chunk.content)\n appendResult(target, chunk.toolCallId, chunk.content)\n }\n },\n\n reconcile(target) {\n for (const { id, name, args } of recorded) {\n appendCall(target, id, name, args)\n const result = results.get(id)\n // The result goes straight after its own call, so a restored transcript reads\n // in the order the tools actually ran.\n if (result !== undefined) appendResult(target, id, result)\n }\n },\n }\n return recorder\n}\n\n/**\n * Drop recorded harness tool calls from a list of messages bound for the model.\n *\n * A stored transcript becomes the history for the NEXT turn. These calls name tools\n * the provider was never given, and one triage-sized run is hundreds of kilobytes of\n * tool output — so replaying them is wasteful at best and rejected at worst. They stay\n * in `ctx.messages` (which is what gets stored and rendered); only the request to the\n * model loses them.\n *\n * An assistant message is dropped only when EVERY call on it is observed, so a mixed\n * message — one engine tool call plus one harness tool call — is left alone rather than\n * silently losing the engine's half.\n */\nexport function stripObservedToolCalls(\n messages: ReadonlyArray<ModelMessage>,\n): Array<ModelMessage> {\n const dropped = new Set<string>()\n const kept: Array<ModelMessage> = []\n for (const message of messages) {\n const calls = message.toolCalls\n if (calls && calls.length > 0 && calls.every(isSandboxToolCall)) {\n for (const call of calls) dropped.add(call.id)\n continue\n }\n // Orphaning a result is worse than keeping it: a provider rejects a tool result\n // whose call is not in the history.\n if (\n message.role === 'tool' &&\n message.toolCallId !== undefined &&\n dropped.has(message.toolCallId)\n ) {\n continue\n }\n kept.push(message)\n }\n return kept\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,12 +60,16 @@
|
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@ngrok/ngrok": "^1.0.0",
|
|
62
62
|
"vitest": "^4.1.10",
|
|
63
|
-
"@tanstack/ai": "^0.
|
|
63
|
+
"@tanstack/ai": "^0.48.0",
|
|
64
|
+
"@tanstack/ai-persistence": "^0.5.0"
|
|
64
65
|
},
|
|
65
66
|
"peerDependenciesMeta": {
|
|
66
67
|
"@ngrok/ngrok": {
|
|
67
68
|
"optional": true
|
|
68
69
|
},
|
|
70
|
+
"@tanstack/ai-persistence": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
69
73
|
"vitest": {
|
|
70
74
|
"optional": true
|
|
71
75
|
}
|
|
@@ -74,7 +78,8 @@
|
|
|
74
78
|
"@ngrok/ngrok": "^1.7.0",
|
|
75
79
|
"@vitest/coverage-v8": "4.1.10",
|
|
76
80
|
"vitest": "^4.1.10",
|
|
77
|
-
"@tanstack/ai": "0.
|
|
81
|
+
"@tanstack/ai": "0.48.0",
|
|
82
|
+
"@tanstack/ai-persistence": "0.5.0"
|
|
78
83
|
},
|
|
79
84
|
"scripts": {
|
|
80
85
|
"build": "vite build",
|
|
@@ -8,7 +8,13 @@ description: >
|
|
|
8
8
|
fileSkill), plugins, instructions → canonical AGENTS.md + symlinks projected
|
|
9
9
|
per harness; shallow-clone default with depth opt-out; serial/parallel setup
|
|
10
10
|
callback over a persistent shell; snapshot-after-setup default with
|
|
11
|
-
snapshotMaxAge TTL
|
|
11
|
+
snapshotMaxAge TTL. It also covers portable snapshots after a successful
|
|
12
|
+
terminal run with withPersistence before withSandbox and
|
|
13
|
+
memorySandboxSnapshots for local examples. It covers named saves with
|
|
14
|
+
snapshots.save, selected-checkpoint forks with snapshots.fork, and
|
|
15
|
+
authorized artifact reads with snapshots.readArtifact. See
|
|
16
|
+
docs/sandbox/portable-snapshots.md. It covers defineWorkspace
|
|
17
|
+
(git/setup/scripts/skills/secrets/
|
|
12
18
|
instructions/plugins), defineSandboxPolicy (allow/ask/deny), lifecycle/resume,
|
|
13
19
|
the SandboxHandle (fs/git/process/ports), capability tokens, defineSandbox
|
|
14
20
|
hooks (onFile/onFileCreate/onFileChange/onFileDelete/onReady/onError/
|
|
@@ -185,6 +191,87 @@ lifecycle: {
|
|
|
185
191
|
|
|
186
192
|
Providers without snapshot support skip the step silently.
|
|
187
193
|
|
|
194
|
+
### Portable sandbox snapshots
|
|
195
|
+
|
|
196
|
+
Portable snapshots keep completed workspace files in application persistence.
|
|
197
|
+
They are separate from provider-native bootstrap snapshots. Configure the
|
|
198
|
+
middleware in this order, with the same persistence value in both places:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { withPersistence } from '@tanstack/ai-persistence'
|
|
202
|
+
import { memorySandboxSnapshots, withSandbox } from '@tanstack/ai-sandbox'
|
|
203
|
+
|
|
204
|
+
const snapshots = await memorySandboxSnapshots({ sandbox, instances })
|
|
205
|
+
|
|
206
|
+
const middleware = [
|
|
207
|
+
withPersistence(snapshots.persistence),
|
|
208
|
+
withSandbox(sandbox, { instances, snapshots }),
|
|
209
|
+
]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Each successful terminal run saves regular files, empty directories, durable
|
|
213
|
+
conversation data, and persisted thread artifacts. A later run restores the
|
|
214
|
+
latest checkpoint only into a new private sandbox. A live resumed sandbox is
|
|
215
|
+
never overwritten. The default policy excludes `.git`, `node_modules`, and
|
|
216
|
+
`.env*` path segments at every depth. It excludes the exact projection marker
|
|
217
|
+
only at the workspace root. It also excludes root `CLAUDE.md` and `GEMINI.md`,
|
|
218
|
+
plus direct `.claude/skills/<name>`, `.codex/skills/<name>`, and
|
|
219
|
+
`.grok/skills/<name>` paths. These exclusions use paths even for regular files
|
|
220
|
+
or copies. If you pass only `include` or only `redact`, the default
|
|
221
|
+
exclusions stay in place. If you pass `exclude`, that function replaces
|
|
222
|
+
the default exclusions, except for exact projection-marker protection.
|
|
223
|
+
Copy `defaultSandboxSnapshotPolicy()` first when you write `exclude`.
|
|
224
|
+
Pass `include` and `exclude` functions on `policy` to store only some files,
|
|
225
|
+
including one file. There is no `save({ files })` list. See
|
|
226
|
+
`docs/sandbox/portable-snapshots-files.md`. Resolved secrets are redacted before the data is stored. Symlinks,
|
|
227
|
+
executables, and special filesystem entries fail the capture or restore. Each
|
|
228
|
+
thread has one writer lease. Pause and detach release the lease without a
|
|
229
|
+
partial checkpoint. Blob retention is manual because there is no automatic
|
|
230
|
+
garbage collection yet.
|
|
231
|
+
|
|
232
|
+
Read these pages for the server-only setup:
|
|
233
|
+
|
|
234
|
+
- `docs/sandbox/portable-snapshots.md`
|
|
235
|
+
- `docs/sandbox/portable-snapshots-configure.md`
|
|
236
|
+
- `docs/sandbox/portable-snapshots-save.md`
|
|
237
|
+
- `docs/sandbox/portable-snapshots-fork.md`
|
|
238
|
+
- `docs/sandbox/portable-snapshots-artifacts.md`
|
|
239
|
+
- `docs/sandbox/portable-snapshots-tools.md`
|
|
240
|
+
- `docs/sandbox/portable-snapshots-files.md`
|
|
241
|
+
- `docs/sandbox/portable-snapshots-safety.md`
|
|
242
|
+
|
|
243
|
+
For a user-marked workspace state, call `snapshots.save` on the server. Bind
|
|
244
|
+
`sandbox` and `instances` at create time, or pass them on `save`. The call
|
|
245
|
+
needs `threadId`, `runId`, and a label. It requires a live reusable sandbox.
|
|
246
|
+
`reuse: 'none'` cannot save a named checkpoint.
|
|
247
|
+
|
|
248
|
+
To branch from a selected checkpoint, call `snapshots.fork` with the thread id,
|
|
249
|
+
checkpoint id, and destination thread id. The store must implement atomic
|
|
250
|
+
`forkFromCheckpoint`. The destination thread must be empty. A fork copies the
|
|
251
|
+
selected snapshot, not the latest snapshot.
|
|
252
|
+
|
|
253
|
+
To send a checkpoint artifact, call `snapshots.readArtifact` on the server.
|
|
254
|
+
First authorize the caller for the supplied thread. The method makes sure that
|
|
255
|
+
the checkpoint belongs to that thread, then returns its metadata and bytes. It
|
|
256
|
+
does not authorize a caller or create an HTTP response.
|
|
257
|
+
|
|
258
|
+
For a SQLite checkpoint store, use one transaction for a checkpoint write, its
|
|
259
|
+
head update, and every blob reference update. Use one transaction for a fork,
|
|
260
|
+
including its copied conversation. A partial transaction breaks snapshot
|
|
261
|
+
consistency.
|
|
262
|
+
|
|
263
|
+
Snapshot capture supports regular files and empty directories only. It excludes
|
|
264
|
+
`.git`, `node_modules`, and `.env*` path segments at every depth. It excludes
|
|
265
|
+
the exact projection marker only at the workspace root. It also excludes root
|
|
266
|
+
`CLAUDE.md` and `GEMINI.md`, plus direct `.claude/skills/<name>`,
|
|
267
|
+
`.codex/skills/<name>`, and `.grok/skills/<name>` paths. These exclusions use
|
|
268
|
+
paths even for regular files or copies. If you pass only `include` or only
|
|
269
|
+
`redact`, the default exclusions stay in place. If you pass `exclude`, that
|
|
270
|
+
function replaces the default exclusions, except for exact projection-marker
|
|
271
|
+
protection. It rejects symlinks, executable files, and special filesystem
|
|
272
|
+
entries. Restore verifies the manifest and blobs before
|
|
273
|
+
it changes a new private sandbox. It never writes into a live resumed sandbox.
|
|
274
|
+
|
|
188
275
|
## Providers
|
|
189
276
|
|
|
190
277
|
- `localProcessSandbox()` — runs on the host (no isolation; dev loop only).
|
|
@@ -983,14 +1070,15 @@ including the client `joinRun` side, is in `docs/sandbox/takeover.md`.
|
|
|
983
1070
|
|
|
984
1071
|
- **Harness adapters require a sandbox.** Always include `withSandbox(...)` in
|
|
985
1072
|
`middleware` — without it `chat()` throws a missing-capability error.
|
|
986
|
-
- **Secrets** (`workspace.secrets`) are injected into the sandbox env
|
|
987
|
-
persisted
|
|
988
|
-
with `createSecrets(...)` so the values stay hidden
|
|
989
|
-
The agent binary (`claude`) must exist in the
|
|
990
|
-
`setup` or bake it into the image).
|
|
1073
|
+
- **Secrets** (`workspace.secrets`) are injected into the sandbox env. Their
|
|
1074
|
+
raw values are never persisted in snapshots, the sandbox store, or the event
|
|
1075
|
+
log. Always create them with `createSecrets(...)` so the values stay hidden
|
|
1076
|
+
behind `SecretRef` tokens. The agent binary (`claude`) must exist in the
|
|
1077
|
+
sandbox image (install it in `setup` or bake it into the image).
|
|
991
1078
|
- **Secret-bearing projected files** (e.g. MCP config with resolved header
|
|
992
|
-
values)
|
|
993
|
-
|
|
1079
|
+
values) can be included by default capture. Capture replaces resolved secret
|
|
1080
|
+
bytes with zero bytes before it hashes or writes snapshot blobs. Restore runs
|
|
1081
|
+
before projection, so projection writes current secret values after restore.
|
|
994
1082
|
- **chat()-provided `tools` are bridged** into the in-sandbox agent over a
|
|
995
1083
|
host-side MCP tool-proxy: the agent calls them as `mcp__tanstack__<tool>` and
|
|
996
1084
|
each call is proxied back to the host where the tool's `execute()` runs (with
|
package/src/approvals.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `approvalId` is stable for a given (provider, kind, target) so a client grant
|
|
16
16
|
* matches the same action on the resumed run.
|
|
17
17
|
*/
|
|
18
|
-
import { EventType } from '@tanstack/ai'
|
|
18
|
+
import { EventType, withTanstackMetadata } from '@tanstack/ai'
|
|
19
19
|
import { evaluateCommand } from './policy'
|
|
20
20
|
import type { SandboxPolicy } from './policy'
|
|
21
21
|
import type { StreamChunk } from '@tanstack/ai'
|
|
@@ -81,16 +81,17 @@ export function buildApprovalRequestedEvent(input: {
|
|
|
81
81
|
runId: string
|
|
82
82
|
detail?: Record<string, unknown>
|
|
83
83
|
}): StreamChunk {
|
|
84
|
-
return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
return withTanstackMetadata(
|
|
85
|
+
{
|
|
86
|
+
type: EventType.CUSTOM,
|
|
87
|
+
name: APPROVAL_REQUESTED_EVENT,
|
|
88
|
+
value: {
|
|
89
|
+
approvalId: input.approvalId,
|
|
90
|
+
title: input.title,
|
|
91
|
+
...(input.detail ?? {}),
|
|
92
|
+
},
|
|
93
|
+
timestamp: Date.now(),
|
|
91
94
|
},
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
runId: input.runId,
|
|
95
|
-
}
|
|
95
|
+
{ threadId: input.threadId, runId: input.runId },
|
|
96
|
+
) as StreamChunk
|
|
96
97
|
}
|
package/src/bridge-events.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* stream into its translated output — so events interleave live while the agent
|
|
11
11
|
* runs (e.g. code mode's `code_mode:console` logs during a long execution).
|
|
12
12
|
*/
|
|
13
|
-
import { EventType } from '@tanstack/ai'
|
|
13
|
+
import { EventType, withTanstackMetadata } from '@tanstack/ai'
|
|
14
14
|
import type { StreamChunk } from '@tanstack/ai'
|
|
15
15
|
|
|
16
16
|
export interface BridgeEventChannel {
|
|
@@ -50,15 +50,21 @@ export function createBridgeEventChannel(meta: {
|
|
|
50
50
|
return {
|
|
51
51
|
emitCustomEvent(eventName, value) {
|
|
52
52
|
if (closed) return
|
|
53
|
-
buffer.push(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
buffer.push(
|
|
54
|
+
withTanstackMetadata(
|
|
55
|
+
{
|
|
56
|
+
type: EventType.CUSTOM,
|
|
57
|
+
name: eventName,
|
|
58
|
+
value,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
model: meta.model,
|
|
63
|
+
...(meta.threadId !== undefined ? { threadId: meta.threadId } : {}),
|
|
64
|
+
...(meta.runId !== undefined ? { runId: meta.runId } : {}),
|
|
65
|
+
},
|
|
66
|
+
) as StreamChunk,
|
|
67
|
+
)
|
|
62
68
|
notify?.()
|
|
63
69
|
},
|
|
64
70
|
close() {
|