@copilotkit/sqlite-runner 1.81.0 → 1.83.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/dist/sqlite-runner.cjs +132 -15
- package/dist/sqlite-runner.cjs.map +1 -1
- package/dist/sqlite-runner.d.cts +45 -1
- package/dist/sqlite-runner.d.cts.map +1 -1
- package/dist/sqlite-runner.d.mts +45 -1
- package/dist/sqlite-runner.d.mts.map +1 -1
- package/dist/sqlite-runner.mjs +133 -16
- package/dist/sqlite-runner.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/sqlite-ownership.test.ts +748 -0
- package/src/sqlite-runner.ts +222 -17
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread ownership in SqliteAgentRunner: on the runner itself, and through
|
|
3
|
+
* the real runtime handler with an identity callback.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AbstractAgent, EventType } from "@ag-ui/client";
|
|
7
|
+
import type {
|
|
8
|
+
BaseEvent,
|
|
9
|
+
RunAgentInput,
|
|
10
|
+
RunAgentResult,
|
|
11
|
+
RunStartedEvent,
|
|
12
|
+
} from "@ag-ui/client";
|
|
13
|
+
import type { Observable } from "rxjs";
|
|
14
|
+
import { EMPTY } from "rxjs";
|
|
15
|
+
import Database from "better-sqlite3";
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import path from "path";
|
|
19
|
+
import {
|
|
20
|
+
CopilotRuntime,
|
|
21
|
+
ThreadAccessDeniedError,
|
|
22
|
+
createCopilotRuntimeHandler,
|
|
23
|
+
} from "@copilotkit/runtime/v2";
|
|
24
|
+
import { SqliteAgentRunner } from "..";
|
|
25
|
+
|
|
26
|
+
type Callbacks = {
|
|
27
|
+
onEvent: (event: { event: BaseEvent }) => void;
|
|
28
|
+
onRunStartedEvent?: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Emits the caller's secret in a STATE_SNAPSHOT; with `hang`, waits for abort. */
|
|
32
|
+
class SecretAgent extends AbstractAgent {
|
|
33
|
+
private release: (() => void) | null = null;
|
|
34
|
+
private markStarted!: () => void;
|
|
35
|
+
readonly started = new Promise<void>((resolve) => {
|
|
36
|
+
this.markStarted = resolve;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
constructor(private readonly hang = false) {
|
|
40
|
+
super();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
run(_input: RunAgentInput): Observable<BaseEvent> {
|
|
44
|
+
return EMPTY;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clone(): SecretAgent {
|
|
48
|
+
return new SecretAgent(this.hang);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
abortRun(): void {
|
|
52
|
+
this.release?.();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async runAgent(
|
|
56
|
+
input: RunAgentInput,
|
|
57
|
+
callbacks: Callbacks,
|
|
58
|
+
): Promise<RunAgentResult> {
|
|
59
|
+
const started: RunStartedEvent = {
|
|
60
|
+
type: EventType.RUN_STARTED,
|
|
61
|
+
threadId: input.threadId,
|
|
62
|
+
runId: input.runId,
|
|
63
|
+
};
|
|
64
|
+
callbacks.onEvent({ event: started });
|
|
65
|
+
callbacks.onRunStartedEvent?.();
|
|
66
|
+
callbacks.onEvent({
|
|
67
|
+
event: {
|
|
68
|
+
type: EventType.STATE_SNAPSHOT,
|
|
69
|
+
snapshot: { secret: (input.state as { secret?: string }).secret },
|
|
70
|
+
} as BaseEvent,
|
|
71
|
+
});
|
|
72
|
+
this.markStarted();
|
|
73
|
+
if (this.hang) {
|
|
74
|
+
await new Promise<void>((resolve) => {
|
|
75
|
+
this.release = resolve;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
callbacks.onEvent({
|
|
79
|
+
event: {
|
|
80
|
+
type: EventType.RUN_FINISHED,
|
|
81
|
+
threadId: input.threadId,
|
|
82
|
+
runId: input.runId,
|
|
83
|
+
} as BaseEvent,
|
|
84
|
+
});
|
|
85
|
+
return { result: undefined, newMessages: [] };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let seq = 0;
|
|
90
|
+
function threadIdFor(label: string): string {
|
|
91
|
+
seq += 1;
|
|
92
|
+
return `${label}-${Date.now().toString(36)}-${seq}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function inputFor(threadId: string, secret: string, runId?: string) {
|
|
96
|
+
return {
|
|
97
|
+
threadId,
|
|
98
|
+
runId: runId ?? `run-${threadId}-${Math.random().toString(36).slice(2)}`,
|
|
99
|
+
messages: [
|
|
100
|
+
{
|
|
101
|
+
id: `msg-${threadId}-${secret}`,
|
|
102
|
+
role: "user" as const,
|
|
103
|
+
content: secret,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
state: { secret },
|
|
107
|
+
tools: [],
|
|
108
|
+
context: [],
|
|
109
|
+
forwardedProps: {},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collect(stream: Observable<BaseEvent>): Promise<BaseEvent[]> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const events: BaseEvent[] = [];
|
|
116
|
+
stream.subscribe({
|
|
117
|
+
next: (event) => events.push(event),
|
|
118
|
+
error: reject,
|
|
119
|
+
complete: () => resolve(events),
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const runners: SqliteAgentRunner[] = [];
|
|
125
|
+
const hanging: SecretAgent[] = [];
|
|
126
|
+
const tempDirs: string[] = [];
|
|
127
|
+
|
|
128
|
+
afterEach(async () => {
|
|
129
|
+
vi.restoreAllMocks();
|
|
130
|
+
for (const agent of hanging.splice(0)) agent.abortRun();
|
|
131
|
+
// Let aborted runs finish writing before their database closes.
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
133
|
+
for (const runner of runners.splice(0)) runner.close();
|
|
134
|
+
for (const dir of tempDirs.splice(0)) {
|
|
135
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** A database file path in a temp directory that is removed after the test. */
|
|
140
|
+
function tempDbPath(name: string): string {
|
|
141
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-owner-"));
|
|
142
|
+
tempDirs.push(dir);
|
|
143
|
+
return path.join(dir, name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function newRunner(dbPath?: string): SqliteAgentRunner {
|
|
147
|
+
const runner = new SqliteAgentRunner(dbPath ? { dbPath } : {});
|
|
148
|
+
runners.push(runner);
|
|
149
|
+
return runner;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe("SqliteAgentRunner thread ownership", () => {
|
|
153
|
+
it("keeps each identity to its own threads", async () => {
|
|
154
|
+
const runner = newRunner();
|
|
155
|
+
const threadId = threadIdFor("sqlite-owned");
|
|
156
|
+
await collect(
|
|
157
|
+
runner.run({
|
|
158
|
+
threadId,
|
|
159
|
+
agent: new SecretAgent(),
|
|
160
|
+
input: inputFor(threadId, "ALICE_SECRET"),
|
|
161
|
+
ownerId: "alice",
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(runner.isThreadAccessible(threadId, { ownerId: "alice" })).toBe(
|
|
166
|
+
true,
|
|
167
|
+
);
|
|
168
|
+
expect(runner.isThreadAccessible(threadId, { ownerId: "bob" })).toBe(false);
|
|
169
|
+
expect(runner.isThreadAccessible(threadId, { ownerId: "" })).toBe(false);
|
|
170
|
+
|
|
171
|
+
const foreign = await collect(runner.connect({ threadId, ownerId: "bob" }));
|
|
172
|
+
expect(JSON.stringify(foreign)).not.toContain("ALICE_SECRET");
|
|
173
|
+
const own = await collect(runner.connect({ threadId, ownerId: "alice" }));
|
|
174
|
+
expect(JSON.stringify(own)).toContain("ALICE_SECRET");
|
|
175
|
+
|
|
176
|
+
expect(() =>
|
|
177
|
+
runner.run({
|
|
178
|
+
threadId,
|
|
179
|
+
agent: new SecretAgent(),
|
|
180
|
+
input: inputFor(threadId, "BOB_INJECTED"),
|
|
181
|
+
ownerId: "bob",
|
|
182
|
+
}),
|
|
183
|
+
).toThrow(ThreadAccessDeniedError);
|
|
184
|
+
const after = await collect(runner.connect({ threadId, ownerId: "alice" }));
|
|
185
|
+
expect(JSON.stringify(after)).not.toContain("BOB_INJECTED");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("does not let another identity stop or observe a live run", async () => {
|
|
189
|
+
const runner = newRunner();
|
|
190
|
+
const threadId = threadIdFor("sqlite-live");
|
|
191
|
+
const agent = new SecretAgent(true);
|
|
192
|
+
hanging.push(agent);
|
|
193
|
+
const input = inputFor(threadId, "ALICE_LIVE", `run-${threadId}`);
|
|
194
|
+
const done = collect(
|
|
195
|
+
runner.run({ threadId, agent, input, ownerId: "alice" }),
|
|
196
|
+
);
|
|
197
|
+
await agent.started;
|
|
198
|
+
|
|
199
|
+
expect(await runner.isRunning({ threadId, ownerId: "bob" })).toBe(false);
|
|
200
|
+
expect(await runner.isRunning({ threadId, ownerId: "alice" })).toBe(true);
|
|
201
|
+
expect(await runner.stop({ threadId, ownerId: "bob" })).toBe(false);
|
|
202
|
+
expect(
|
|
203
|
+
await runner.stop({ threadId, runId: input.runId, ownerId: "bob" }),
|
|
204
|
+
).toBe(false);
|
|
205
|
+
expect(
|
|
206
|
+
await runner.stop({
|
|
207
|
+
threadId,
|
|
208
|
+
runId: "some-other-run",
|
|
209
|
+
ownerId: "alice",
|
|
210
|
+
}),
|
|
211
|
+
).toBe(false);
|
|
212
|
+
expect(
|
|
213
|
+
await runner.stop({ threadId, runId: input.runId, ownerId: "alice" }),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
await done;
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Writes a database in the schema from before owners were stored, with one
|
|
221
|
+
* finished run on `threadId` whose state holds `secret`.
|
|
222
|
+
*/
|
|
223
|
+
function writeLegacyDb(dbPath: string, threadId: string, secret: string) {
|
|
224
|
+
const legacy = new Database(dbPath);
|
|
225
|
+
legacy.exec(`
|
|
226
|
+
CREATE TABLE agent_runs (
|
|
227
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id TEXT NOT NULL,
|
|
228
|
+
run_id TEXT NOT NULL UNIQUE, parent_run_id TEXT, events TEXT NOT NULL,
|
|
229
|
+
input TEXT NOT NULL, created_at INTEGER NOT NULL, version INTEGER NOT NULL);
|
|
230
|
+
CREATE TABLE run_state (thread_id TEXT PRIMARY KEY, is_running INTEGER DEFAULT 0,
|
|
231
|
+
current_run_id TEXT, updated_at INTEGER NOT NULL);
|
|
232
|
+
CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
|
|
233
|
+
INSERT INTO schema_version VALUES (1, 0);
|
|
234
|
+
`);
|
|
235
|
+
legacy
|
|
236
|
+
.prepare(
|
|
237
|
+
"INSERT INTO agent_runs (thread_id, run_id, parent_run_id, events, input, created_at, version) VALUES (?, ?, NULL, ?, ?, ?, 1)",
|
|
238
|
+
)
|
|
239
|
+
.run(
|
|
240
|
+
threadId,
|
|
241
|
+
`legacy-run-${threadId}`,
|
|
242
|
+
JSON.stringify([
|
|
243
|
+
{ type: EventType.RUN_STARTED, threadId, runId: "legacy-run" },
|
|
244
|
+
{ type: EventType.STATE_SNAPSHOT, snapshot: { secret } },
|
|
245
|
+
{ type: EventType.RUN_FINISHED, threadId, runId: "legacy-run" },
|
|
246
|
+
]),
|
|
247
|
+
JSON.stringify(inputFor(threadId, secret, "legacy-run")),
|
|
248
|
+
Date.now(),
|
|
249
|
+
);
|
|
250
|
+
legacy.close();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("SqliteAgentRunner threads stored before owners were recorded", () => {
|
|
254
|
+
it("stay reachable by id for every caller, as before the upgrade", async () => {
|
|
255
|
+
const dbPath = tempDbPath("legacy.db");
|
|
256
|
+
const threadId = threadIdFor("sqlite-legacy");
|
|
257
|
+
writeLegacyDb(dbPath, threadId, "LEGACY_SECRET");
|
|
258
|
+
const runner = newRunner(dbPath);
|
|
259
|
+
|
|
260
|
+
for (const ownerId of ["alice", "bob", ""]) {
|
|
261
|
+
expect(runner.isThreadAccessible(threadId, { ownerId })).toBe(true);
|
|
262
|
+
const replay = await collect(runner.connect({ threadId, ownerId }));
|
|
263
|
+
expect(JSON.stringify(replay)).toContain("LEGACY_SECRET");
|
|
264
|
+
}
|
|
265
|
+
// A server-side caller that names no owner keeps them too.
|
|
266
|
+
expect(
|
|
267
|
+
JSON.stringify(await collect(runner.connect({ threadId }))),
|
|
268
|
+
).toContain("LEGACY_SECRET");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("stay unowned when an identity continues them", async () => {
|
|
272
|
+
const dbPath = tempDbPath("legacy.db");
|
|
273
|
+
const threadId = threadIdFor("sqlite-legacy-run");
|
|
274
|
+
writeLegacyDb(dbPath, threadId, "LEGACY_SECRET");
|
|
275
|
+
const runner = newRunner(dbPath);
|
|
276
|
+
|
|
277
|
+
await collect(
|
|
278
|
+
runner.run({
|
|
279
|
+
threadId,
|
|
280
|
+
agent: new SecretAgent(),
|
|
281
|
+
input: inputFor(threadId, "ALICE_TURN"),
|
|
282
|
+
ownerId: "alice",
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
// Continuing does not claim the thread for alice: bob, who also holds
|
|
286
|
+
// the id, still reaches and continues it.
|
|
287
|
+
expect(runner.isThreadAccessible(threadId, { ownerId: "bob" })).toBe(true);
|
|
288
|
+
await collect(
|
|
289
|
+
runner.run({
|
|
290
|
+
threadId,
|
|
291
|
+
agent: new SecretAgent(),
|
|
292
|
+
input: inputFor(threadId, "BOB_TURN"),
|
|
293
|
+
ownerId: "bob",
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
const replay = JSON.stringify(
|
|
297
|
+
await collect(runner.connect({ threadId, ownerId: "alice" })),
|
|
298
|
+
);
|
|
299
|
+
expect(replay).toContain("BOB_TURN");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("never make a new thread unowned", async () => {
|
|
303
|
+
const dbPath = tempDbPath("legacy.db");
|
|
304
|
+
writeLegacyDb(dbPath, threadIdFor("sqlite-legacy-other"), "LEGACY_SECRET");
|
|
305
|
+
const runner = newRunner(dbPath);
|
|
306
|
+
const threadId = threadIdFor("sqlite-new-after-upgrade");
|
|
307
|
+
|
|
308
|
+
await collect(
|
|
309
|
+
runner.run({
|
|
310
|
+
threadId,
|
|
311
|
+
agent: new SecretAgent(),
|
|
312
|
+
input: inputFor(threadId, "ALICE_NEW"),
|
|
313
|
+
ownerId: "alice",
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
expect(runner.isThreadAccessible(threadId, { ownerId: "bob" })).toBe(false);
|
|
317
|
+
expect(runner.isThreadAccessible(threadId, { ownerId: "" })).toBe(false);
|
|
318
|
+
expect(
|
|
319
|
+
JSON.stringify(
|
|
320
|
+
await collect(runner.connect({ threadId, ownerId: "bob" })),
|
|
321
|
+
),
|
|
322
|
+
).not.toContain("ALICE_NEW");
|
|
323
|
+
expect(() =>
|
|
324
|
+
runner.run({
|
|
325
|
+
threadId,
|
|
326
|
+
agent: new SecretAgent(),
|
|
327
|
+
input: inputFor(threadId, "BOB_INJECTED"),
|
|
328
|
+
ownerId: "bob",
|
|
329
|
+
}),
|
|
330
|
+
).toThrow(ThreadAccessDeniedError);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("are logged once when the runner opens the database", () => {
|
|
334
|
+
const dbPath = tempDbPath("legacy.db");
|
|
335
|
+
writeLegacyDb(dbPath, threadIdFor("sqlite-legacy-log-a"), "A");
|
|
336
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
337
|
+
newRunner(dbPath);
|
|
338
|
+
const legacyWarnings = warn.mock.calls
|
|
339
|
+
.map((call: unknown[]) => call.map(String).join(" "))
|
|
340
|
+
.filter((line) => line.includes("before thread owners were recorded"));
|
|
341
|
+
expect(legacyWarnings).toHaveLength(1);
|
|
342
|
+
expect(legacyWarnings[0]).toContain("1 thread");
|
|
343
|
+
|
|
344
|
+
// A database with no such threads logs nothing.
|
|
345
|
+
warn.mockClear();
|
|
346
|
+
newRunner();
|
|
347
|
+
expect(warn).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** A hanging agent whose first abort fails, as an agent mid-teardown may. */
|
|
352
|
+
class FlakyAbortAgent extends SecretAgent {
|
|
353
|
+
aborts = 0;
|
|
354
|
+
|
|
355
|
+
constructor() {
|
|
356
|
+
super(true);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
override abortRun(): void {
|
|
360
|
+
this.aborts += 1;
|
|
361
|
+
if (this.aborts === 1) throw new Error("abort failed");
|
|
362
|
+
super.abortRun();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
describe("SqliteAgentRunner stop after a failed abort", () => {
|
|
367
|
+
it("still stops the run by its run id", async () => {
|
|
368
|
+
const runner = newRunner();
|
|
369
|
+
const threadId = threadIdFor("sqlite-flaky-abort");
|
|
370
|
+
const agent = new FlakyAbortAgent();
|
|
371
|
+
hanging.push(agent);
|
|
372
|
+
const input = inputFor(threadId, "ALICE", `run-${threadId}`);
|
|
373
|
+
const done = collect(
|
|
374
|
+
runner.run({ threadId, agent, input, ownerId: "alice" }),
|
|
375
|
+
);
|
|
376
|
+
await agent.started;
|
|
377
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
378
|
+
|
|
379
|
+
const stop = { threadId, runId: input.runId, ownerId: "alice" };
|
|
380
|
+
expect(await runner.stop(stop)).toBe(false);
|
|
381
|
+
expect(await runner.isRunning({ threadId, ownerId: "alice" })).toBe(true);
|
|
382
|
+
expect(await runner.stop(stop)).toBe(true);
|
|
383
|
+
await done;
|
|
384
|
+
expect(await runner.isRunning({ threadId, ownerId: "alice" })).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/** Emits an event that cannot be stored (JSON has no BigInt), then finishes. */
|
|
389
|
+
class UnstorableEventAgent extends SecretAgent {
|
|
390
|
+
override async runAgent(
|
|
391
|
+
input: RunAgentInput,
|
|
392
|
+
callbacks: Callbacks,
|
|
393
|
+
): Promise<RunAgentResult> {
|
|
394
|
+
callbacks.onEvent({
|
|
395
|
+
event: {
|
|
396
|
+
type: EventType.RUN_STARTED,
|
|
397
|
+
threadId: input.threadId,
|
|
398
|
+
runId: input.runId,
|
|
399
|
+
} as BaseEvent,
|
|
400
|
+
});
|
|
401
|
+
callbacks.onEvent({
|
|
402
|
+
event: {
|
|
403
|
+
type: EventType.CUSTOM,
|
|
404
|
+
name: "unstorable",
|
|
405
|
+
value: BigInt(1),
|
|
406
|
+
} as BaseEvent,
|
|
407
|
+
});
|
|
408
|
+
return { result: undefined, newMessages: [] };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Fails the way a crashing agent does. */
|
|
413
|
+
class ThrowingAgent extends SecretAgent {
|
|
414
|
+
override async runAgent(): Promise<RunAgentResult> {
|
|
415
|
+
throw new Error("AGENT_EXPLODED");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
describe("SqliteAgentRunner run teardown failures", () => {
|
|
420
|
+
it("does not leave a thread running when its run cannot be stored", async () => {
|
|
421
|
+
const runner = newRunner();
|
|
422
|
+
const threadId = threadIdFor("sqlite-unstorable");
|
|
423
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
424
|
+
const events: BaseEvent[] = [];
|
|
425
|
+
let completed = false;
|
|
426
|
+
runner
|
|
427
|
+
.run({
|
|
428
|
+
threadId,
|
|
429
|
+
agent: new UnstorableEventAgent(),
|
|
430
|
+
input: inputFor(threadId, "ALICE"),
|
|
431
|
+
ownerId: "alice",
|
|
432
|
+
})
|
|
433
|
+
.subscribe({
|
|
434
|
+
next: (event) => events.push(event),
|
|
435
|
+
error: () => {
|
|
436
|
+
completed = true;
|
|
437
|
+
},
|
|
438
|
+
complete: () => {
|
|
439
|
+
completed = true;
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
await vi.waitFor(() => expect(completed).toBe(true), { timeout: 1000 });
|
|
444
|
+
expect(await runner.isRunning({ threadId, ownerId: "alice" })).toBe(false);
|
|
445
|
+
expect(error).toHaveBeenCalled();
|
|
446
|
+
// The thread takes a new run.
|
|
447
|
+
const next = await collect(
|
|
448
|
+
runner.run({
|
|
449
|
+
threadId,
|
|
450
|
+
agent: new SecretAgent(),
|
|
451
|
+
input: inputFor(threadId, "ALICE_AGAIN"),
|
|
452
|
+
ownerId: "alice",
|
|
453
|
+
}),
|
|
454
|
+
);
|
|
455
|
+
expect(JSON.stringify(next)).toContain("RUN_FINISHED");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("logs the agent error of a failed run", async () => {
|
|
459
|
+
const runner = newRunner();
|
|
460
|
+
const threadId = threadIdFor("sqlite-agent-error");
|
|
461
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
462
|
+
await collect(
|
|
463
|
+
runner.run({
|
|
464
|
+
threadId,
|
|
465
|
+
agent: new ThrowingAgent(),
|
|
466
|
+
input: inputFor(threadId, "ALICE"),
|
|
467
|
+
ownerId: "alice",
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
const logged = error.mock.calls
|
|
471
|
+
.flat()
|
|
472
|
+
.map((arg: unknown) =>
|
|
473
|
+
arg instanceof Error ? arg.message : JSON.stringify(arg),
|
|
474
|
+
);
|
|
475
|
+
expect(logged.join(" ")).toContain("AGENT_EXPLODED");
|
|
476
|
+
expect(logged.join(" ")).toContain(threadId);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("SqliteAgentRunner runners that share one database file", () => {
|
|
481
|
+
it("refuses a run whose owner claim lost to another runner", async () => {
|
|
482
|
+
const dbPath = tempDbPath("shared.db");
|
|
483
|
+
const winner = newRunner(dbPath);
|
|
484
|
+
const loser = newRunner(dbPath);
|
|
485
|
+
const threadId = threadIdFor("sqlite-claim-race");
|
|
486
|
+
|
|
487
|
+
// The winner (another process on the same file) claims the thread after
|
|
488
|
+
// the loser checked access and before the loser records its own claim.
|
|
489
|
+
const loserState = loser as unknown as {
|
|
490
|
+
getRunState(threadId: string): unknown;
|
|
491
|
+
};
|
|
492
|
+
const readState = loserState.getRunState.bind(loser);
|
|
493
|
+
let winnerRun: Promise<BaseEvent[]> | undefined;
|
|
494
|
+
vi.spyOn(loserState, "getRunState").mockImplementationOnce((id) => {
|
|
495
|
+
const state = readState(id);
|
|
496
|
+
winnerRun = collect(
|
|
497
|
+
winner.run({
|
|
498
|
+
threadId,
|
|
499
|
+
agent: new SecretAgent(),
|
|
500
|
+
input: inputFor(threadId, "ALICE_SECRET"),
|
|
501
|
+
ownerId: "alice",
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
return state;
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
expect(() =>
|
|
508
|
+
loser.run({
|
|
509
|
+
threadId,
|
|
510
|
+
agent: new SecretAgent(),
|
|
511
|
+
input: inputFor(threadId, "BOB_INJECTED"),
|
|
512
|
+
ownerId: "bob",
|
|
513
|
+
}),
|
|
514
|
+
).toThrow(ThreadAccessDeniedError);
|
|
515
|
+
expect(winnerRun).toBeDefined();
|
|
516
|
+
await winnerRun;
|
|
517
|
+
|
|
518
|
+
const replay = JSON.stringify(
|
|
519
|
+
await collect(winner.connect({ threadId, ownerId: "alice" })),
|
|
520
|
+
);
|
|
521
|
+
expect(replay).toContain("ALICE_SECRET");
|
|
522
|
+
expect(replay).not.toContain("BOB_INJECTED");
|
|
523
|
+
expect(loser.isThreadAccessible(threadId, { ownerId: "bob" })).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("never replays a thread claimed by another runner mid-lookup as legacy", async () => {
|
|
527
|
+
const dbPath = tempDbPath("shared.db");
|
|
528
|
+
const reader = newRunner(dbPath);
|
|
529
|
+
const writer = newRunner(dbPath);
|
|
530
|
+
const threadId = threadIdFor("sqlite-classify-race");
|
|
531
|
+
|
|
532
|
+
// The writer (another connection on the same file) claims the thread for
|
|
533
|
+
// alice and stores her run right after the reader's first read of the
|
|
534
|
+
// owner tables, before the reader can read anything else.
|
|
535
|
+
const writerInternals = writer as unknown as {
|
|
536
|
+
claimThread(threadId: string, owner: string): boolean;
|
|
537
|
+
storeRun(
|
|
538
|
+
threadId: string,
|
|
539
|
+
runId: string,
|
|
540
|
+
events: BaseEvent[],
|
|
541
|
+
input: RunAgentInput,
|
|
542
|
+
): void;
|
|
543
|
+
};
|
|
544
|
+
const readerDb = (reader as unknown as { db: Database.Database }).db;
|
|
545
|
+
const prepare = readerDb.prepare.bind(readerDb);
|
|
546
|
+
let interleaved = false;
|
|
547
|
+
vi.spyOn(readerDb, "prepare").mockImplementation((sql: string) => {
|
|
548
|
+
const stmt = prepare(sql);
|
|
549
|
+
if (interleaved || !sql.includes("thread_owners")) return stmt;
|
|
550
|
+
const get = stmt.get.bind(stmt);
|
|
551
|
+
return Object.assign(stmt, {
|
|
552
|
+
get: (...params: unknown[]) => {
|
|
553
|
+
const row = get(...params);
|
|
554
|
+
if (!interleaved) {
|
|
555
|
+
interleaved = true;
|
|
556
|
+
expect(writerInternals.claimThread(threadId, "alice")).toBe(true);
|
|
557
|
+
writerInternals.storeRun(
|
|
558
|
+
threadId,
|
|
559
|
+
`run-${threadId}-alice`,
|
|
560
|
+
[
|
|
561
|
+
{
|
|
562
|
+
type: EventType.RUN_STARTED,
|
|
563
|
+
threadId,
|
|
564
|
+
runId: "alice-run",
|
|
565
|
+
} as BaseEvent,
|
|
566
|
+
{
|
|
567
|
+
type: EventType.STATE_SNAPSHOT,
|
|
568
|
+
snapshot: { secret: "ALICE_SECRET" },
|
|
569
|
+
} as BaseEvent,
|
|
570
|
+
{
|
|
571
|
+
type: EventType.RUN_FINISHED,
|
|
572
|
+
threadId,
|
|
573
|
+
runId: "alice-run",
|
|
574
|
+
} as BaseEvent,
|
|
575
|
+
],
|
|
576
|
+
inputFor(threadId, "ALICE_SECRET", "alice-run"),
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return row;
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const replay = JSON.stringify(
|
|
585
|
+
await collect(reader.connect({ threadId, ownerId: "bob" })),
|
|
586
|
+
);
|
|
587
|
+
expect(interleaved).toBe(true);
|
|
588
|
+
expect(replay).not.toContain("ALICE_SECRET");
|
|
589
|
+
// Once the race is over, the thread is alice's alone.
|
|
590
|
+
vi.restoreAllMocks();
|
|
591
|
+
expect(reader.isThreadAccessible(threadId, { ownerId: "bob" })).toBe(false);
|
|
592
|
+
expect(
|
|
593
|
+
JSON.stringify(
|
|
594
|
+
await collect(reader.connect({ threadId, ownerId: "alice" })),
|
|
595
|
+
),
|
|
596
|
+
).toContain("ALICE_SECRET");
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
describe("SqliteAgentRunner instances in one process", () => {
|
|
601
|
+
it("keep their live runs apart for the same thread id", async () => {
|
|
602
|
+
const runnerA = newRunner();
|
|
603
|
+
const runnerB = newRunner();
|
|
604
|
+
const threadId = threadIdFor("sqlite-instances");
|
|
605
|
+
|
|
606
|
+
const aliceAgent = new SecretAgent(true);
|
|
607
|
+
hanging.push(aliceAgent);
|
|
608
|
+
const aliceInput = inputFor(threadId, "ALICE_LIVE", `run-a-${threadId}`);
|
|
609
|
+
const aliceDone = collect(
|
|
610
|
+
runnerA.run({
|
|
611
|
+
threadId,
|
|
612
|
+
agent: aliceAgent,
|
|
613
|
+
input: aliceInput,
|
|
614
|
+
ownerId: "alice",
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
await aliceAgent.started;
|
|
618
|
+
|
|
619
|
+
// Runner B has its own database, where bob may claim the same id.
|
|
620
|
+
const bobAgent = new SecretAgent(true);
|
|
621
|
+
hanging.push(bobAgent);
|
|
622
|
+
const bobDone = collect(
|
|
623
|
+
runnerB.run({
|
|
624
|
+
threadId,
|
|
625
|
+
agent: bobAgent,
|
|
626
|
+
input: inputFor(threadId, "BOB_LIVE", `run-b-${threadId}`),
|
|
627
|
+
ownerId: "bob",
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
await bobAgent.started;
|
|
631
|
+
|
|
632
|
+
// Bob's live view on runner B carries none of alice's events.
|
|
633
|
+
const bobEvents: BaseEvent[] = [];
|
|
634
|
+
const bobView = runnerB
|
|
635
|
+
.connect({ threadId, ownerId: "bob" })
|
|
636
|
+
.subscribe((event) => bobEvents.push(event));
|
|
637
|
+
expect(JSON.stringify(bobEvents)).toContain("BOB_LIVE");
|
|
638
|
+
expect(JSON.stringify(bobEvents)).not.toContain("ALICE_LIVE");
|
|
639
|
+
bobView.unsubscribe();
|
|
640
|
+
|
|
641
|
+
// Alice's stop on runner A stops her run, never bob's on runner B.
|
|
642
|
+
expect(
|
|
643
|
+
await runnerA.stop({
|
|
644
|
+
threadId,
|
|
645
|
+
runId: aliceInput.runId,
|
|
646
|
+
ownerId: "alice",
|
|
647
|
+
}),
|
|
648
|
+
).toBe(true);
|
|
649
|
+
await aliceDone;
|
|
650
|
+
expect(await runnerB.isRunning({ threadId, ownerId: "bob" })).toBe(true);
|
|
651
|
+
|
|
652
|
+
bobAgent.abortRun();
|
|
653
|
+
const bobStream = JSON.stringify(await bobDone);
|
|
654
|
+
expect(bobStream).not.toContain("ALICE_LIVE");
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
describe("SqliteAgentRunner behind a runtime with identifyUser", () => {
|
|
659
|
+
const BASE = "http://localhost/api/copilotkit";
|
|
660
|
+
const identify = (request: Request) => {
|
|
661
|
+
const id = request.headers.get("x-test-user");
|
|
662
|
+
if (!id) throw new Error("not signed in");
|
|
663
|
+
return { id, name: id };
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
function makeHandler(runner: SqliteAgentRunner) {
|
|
667
|
+
const runtime = new CopilotRuntime({
|
|
668
|
+
agents: { secret: new SecretAgent() },
|
|
669
|
+
runner,
|
|
670
|
+
identifyUser: identify,
|
|
671
|
+
});
|
|
672
|
+
return createCopilotRuntimeHandler({
|
|
673
|
+
runtime,
|
|
674
|
+
basePath: "/api/copilotkit",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function post(
|
|
679
|
+
handler: (request: Request) => Promise<Response>,
|
|
680
|
+
route: string,
|
|
681
|
+
user: string,
|
|
682
|
+
body: string,
|
|
683
|
+
) {
|
|
684
|
+
return handler(
|
|
685
|
+
new Request(`${BASE}${route}`, {
|
|
686
|
+
method: "POST",
|
|
687
|
+
headers: { "Content-Type": "application/json", "x-test-user": user },
|
|
688
|
+
body,
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
it("serves each user only their own thread", async () => {
|
|
694
|
+
const handler = makeHandler(newRunner());
|
|
695
|
+
const threadId = threadIdFor("sqlite-http");
|
|
696
|
+
const body = (secret: string) => JSON.stringify(inputFor(threadId, secret));
|
|
697
|
+
|
|
698
|
+
const aliceRun = await post(
|
|
699
|
+
handler,
|
|
700
|
+
"/agent/secret/run",
|
|
701
|
+
"alice",
|
|
702
|
+
body("ALICE_SECRET"),
|
|
703
|
+
);
|
|
704
|
+
expect(aliceRun.status).toBe(200);
|
|
705
|
+
expect(await aliceRun.text()).toContain("RUN_FINISHED");
|
|
706
|
+
|
|
707
|
+
const bobReplay = await post(
|
|
708
|
+
handler,
|
|
709
|
+
"/agent/secret/connect",
|
|
710
|
+
"bob",
|
|
711
|
+
body("x"),
|
|
712
|
+
);
|
|
713
|
+
expect(bobReplay.status).toBe(200);
|
|
714
|
+
expect(await bobReplay.text()).not.toContain("ALICE_SECRET");
|
|
715
|
+
|
|
716
|
+
const bobRun = await post(
|
|
717
|
+
handler,
|
|
718
|
+
"/agent/secret/run",
|
|
719
|
+
"bob",
|
|
720
|
+
body("BOB_INJECTED"),
|
|
721
|
+
);
|
|
722
|
+
expect(bobRun.status).toBe(404);
|
|
723
|
+
await bobRun.text();
|
|
724
|
+
|
|
725
|
+
const aliceReplay = await post(
|
|
726
|
+
handler,
|
|
727
|
+
"/agent/secret/connect",
|
|
728
|
+
"alice",
|
|
729
|
+
body("x"),
|
|
730
|
+
);
|
|
731
|
+
const aliceText = await aliceReplay.text();
|
|
732
|
+
expect(aliceText).toContain("ALICE_SECRET");
|
|
733
|
+
expect(aliceText).not.toContain("BOB_INJECTED");
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("serves a thread stored before owners were recorded to every user", async () => {
|
|
737
|
+
const dbPath = tempDbPath("legacy-http.db");
|
|
738
|
+
const threadId = threadIdFor("sqlite-http-legacy");
|
|
739
|
+
writeLegacyDb(dbPath, threadId, "LEGACY_SECRET");
|
|
740
|
+
const handler = makeHandler(newRunner(dbPath));
|
|
741
|
+
const body = JSON.stringify(inputFor(threadId, "x"));
|
|
742
|
+
for (const user of ["alice", "bob"]) {
|
|
743
|
+
const replay = await post(handler, "/agent/secret/connect", user, body);
|
|
744
|
+
expect(replay.status).toBe(200);
|
|
745
|
+
expect(await replay.text()).toContain("LEGACY_SECRET");
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
});
|