@copilotkitnext/sqlite-runner 0.0.0-max-changeset-20260109174803

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.
@@ -0,0 +1,402 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { SqliteAgentRunner } from "..";
3
+ import {
4
+ AbstractAgent,
5
+ BaseEvent,
6
+ EventType,
7
+ Message,
8
+ RunAgentInput,
9
+ RunFinishedEvent,
10
+ RunStartedEvent,
11
+ TextMessageContentEvent,
12
+ TextMessageEndEvent,
13
+ TextMessageStartEvent,
14
+ } from "@ag-ui/client";
15
+ import { EMPTY, firstValueFrom } from "rxjs";
16
+ import { toArray } from "rxjs/operators";
17
+ import Database from "better-sqlite3";
18
+ import fs from "fs";
19
+ import os from "os";
20
+ import path from "path";
21
+
22
+ type RunCallbacks = {
23
+ onEvent: (event: { event: BaseEvent }) => void | Promise<void>;
24
+ onNewMessage?: (args: { message: Message }) => void | Promise<void>;
25
+ onRunStartedEvent?: () => void | Promise<void>;
26
+ };
27
+
28
+ class MockAgent extends AbstractAgent {
29
+ constructor(
30
+ private readonly events: BaseEvent[] = [],
31
+ private readonly emitDefaultRunStarted = true,
32
+ ) {
33
+ super();
34
+ }
35
+
36
+ async runAgent(input: RunAgentInput, callbacks: RunCallbacks): Promise<void> {
37
+ if (this.emitDefaultRunStarted) {
38
+ const runStarted: RunStartedEvent = {
39
+ type: EventType.RUN_STARTED,
40
+ threadId: input.threadId,
41
+ runId: input.runId,
42
+ };
43
+ await callbacks.onEvent({ event: runStarted });
44
+ await callbacks.onRunStartedEvent?.();
45
+ }
46
+
47
+ for (const event of this.events) {
48
+ await callbacks.onEvent({ event });
49
+ }
50
+
51
+ const hasTerminalEvent = this.events.some((event) =>
52
+ event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR,
53
+ );
54
+
55
+ if (!hasTerminalEvent) {
56
+ const runFinished: RunFinishedEvent = {
57
+ type: EventType.RUN_FINISHED,
58
+ threadId: input.threadId,
59
+ runId: input.runId,
60
+ };
61
+ await callbacks.onEvent({ event: runFinished });
62
+ }
63
+ }
64
+
65
+ protected run(): ReturnType<AbstractAgent["run"]> {
66
+ return EMPTY;
67
+ }
68
+
69
+ protected connect(): ReturnType<AbstractAgent["connect"]> {
70
+ return EMPTY;
71
+ }
72
+
73
+ clone(): AbstractAgent {
74
+ return new MockAgent(this.events, this.emitDefaultRunStarted);
75
+ }
76
+ }
77
+
78
+ class StoppableAgent extends AbstractAgent {
79
+ private shouldStop = false;
80
+ private eventDelay: number;
81
+
82
+ constructor(eventDelay = 5) {
83
+ super();
84
+ this.eventDelay = eventDelay;
85
+ }
86
+
87
+ async runAgent(
88
+ input: RunAgentInput,
89
+ callbacks: RunCallbacks,
90
+ ): Promise<void> {
91
+ this.shouldStop = false;
92
+ let counter = 0;
93
+
94
+ const runStarted: RunStartedEvent = {
95
+ type: EventType.RUN_STARTED,
96
+ threadId: input.threadId,
97
+ runId: input.runId,
98
+ };
99
+ await callbacks.onEvent({ event: runStarted });
100
+ await callbacks.onRunStartedEvent?.();
101
+
102
+ while (!this.shouldStop && counter < 10_000) {
103
+ await new Promise((resolve) => setTimeout(resolve, this.eventDelay));
104
+ const event: BaseEvent = {
105
+ type: EventType.TEXT_MESSAGE_CONTENT,
106
+ messageId: `sqlite-stop-${counter}`,
107
+ delta: `chunk-${counter}`,
108
+ } as TextMessageContentEvent;
109
+ await callbacks.onEvent({ event });
110
+ counter += 1;
111
+ }
112
+ }
113
+
114
+ abortRun(): void {
115
+ this.shouldStop = true;
116
+ }
117
+
118
+ clone(): AbstractAgent {
119
+ return new StoppableAgent(this.eventDelay);
120
+ }
121
+ }
122
+
123
+ class OpenEventsAgent extends AbstractAgent {
124
+ private shouldStop = false;
125
+
126
+ async runAgent(
127
+ input: RunAgentInput,
128
+ callbacks: RunCallbacks,
129
+ ): Promise<void> {
130
+ this.shouldStop = false;
131
+ const messageId = "open-message";
132
+ const toolCallId = "open-tool";
133
+
134
+ await callbacks.onEvent({
135
+ event: {
136
+ type: EventType.TEXT_MESSAGE_START,
137
+ messageId,
138
+ role: "assistant",
139
+ } as BaseEvent,
140
+ });
141
+
142
+ await callbacks.onEvent({
143
+ event: {
144
+ type: EventType.TEXT_MESSAGE_CONTENT,
145
+ messageId,
146
+ delta: "Partial content",
147
+ } as BaseEvent,
148
+ });
149
+
150
+ await callbacks.onEvent({
151
+ event: {
152
+ type: EventType.TOOL_CALL_START,
153
+ toolCallId,
154
+ toolCallName: "testTool",
155
+ parentMessageId: messageId,
156
+ } as BaseEvent,
157
+ });
158
+
159
+ while (!this.shouldStop) {
160
+ await new Promise((resolve) => setTimeout(resolve, 5));
161
+ }
162
+ }
163
+
164
+ abortRun(): void {
165
+ this.shouldStop = true;
166
+ }
167
+
168
+ clone(): AbstractAgent {
169
+ return new OpenEventsAgent();
170
+ }
171
+ }
172
+
173
+ describe("SqliteAgentRunner", () => {
174
+ let tempDir: string;
175
+ let dbPath: string;
176
+ let runner: SqliteAgentRunner;
177
+
178
+ beforeEach(() => {
179
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-runner-test-"));
180
+ dbPath = path.join(tempDir, "test.db");
181
+ runner = new SqliteAgentRunner({ dbPath });
182
+ });
183
+
184
+ afterEach(() => {
185
+ if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
186
+ if (fs.existsSync(tempDir)) fs.rmdirSync(tempDir);
187
+ });
188
+
189
+ it("emits RUN_STARTED and agent events", async () => {
190
+ const threadId = "sqlite-basic";
191
+ const agent = new MockAgent([
192
+ { type: EventType.TEXT_MESSAGE_START, messageId: "msg-1", role: "assistant" } as TextMessageStartEvent,
193
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "msg-1", delta: "Hello" } as TextMessageContentEvent,
194
+ { type: EventType.TEXT_MESSAGE_END, messageId: "msg-1" } as TextMessageEndEvent,
195
+ { type: EventType.RUN_FINISHED, threadId, runId: "run-1" } as RunFinishedEvent,
196
+ ]);
197
+
198
+ const events = await firstValueFrom(
199
+ runner
200
+ .run({
201
+ threadId,
202
+ agent,
203
+ input: { threadId, runId: "run-1", messages: [], state: {} },
204
+ })
205
+ .pipe(toArray()),
206
+ );
207
+
208
+ expect(events.map((event) => event.type)).toEqual([
209
+ EventType.RUN_STARTED,
210
+ EventType.TEXT_MESSAGE_START,
211
+ EventType.TEXT_MESSAGE_CONTENT,
212
+ EventType.TEXT_MESSAGE_END,
213
+ EventType.RUN_FINISHED,
214
+ ]);
215
+ });
216
+
217
+ it("attaches only new messages on subsequent runs", async () => {
218
+ const threadId = "sqlite-new-messages";
219
+ const existing: Message = { id: "existing", role: "user", content: "hi" };
220
+
221
+ await firstValueFrom(
222
+ runner
223
+ .run({
224
+ threadId,
225
+ agent: new MockAgent(),
226
+ input: { threadId, runId: "run-0", messages: [existing], state: {} },
227
+ })
228
+ .pipe(toArray()),
229
+ );
230
+
231
+ const newMessage: Message = { id: "new", role: "user", content: "follow up" };
232
+
233
+ const secondRun = await firstValueFrom(
234
+ runner
235
+ .run({
236
+ threadId,
237
+ agent: new MockAgent(),
238
+ input: {
239
+ threadId,
240
+ runId: "run-1",
241
+ messages: [existing, newMessage],
242
+ state: { counter: 1 },
243
+ },
244
+ })
245
+ .pipe(toArray()),
246
+ );
247
+
248
+ const runStarted = secondRun[0] as RunStartedEvent;
249
+ expect(runStarted.input?.messages?.map((m) => m.id)).toEqual(["new"]);
250
+
251
+ const db = new Database(dbPath);
252
+ const rows = db
253
+ .prepare("SELECT events FROM agent_runs WHERE thread_id = ? ORDER BY created_at")
254
+ .all(threadId) as { events: string }[];
255
+ db.close();
256
+
257
+ expect(rows).toHaveLength(2);
258
+ const run1Stored = JSON.parse(rows[0].events) as BaseEvent[];
259
+ const run2Stored = JSON.parse(rows[1].events) as BaseEvent[];
260
+
261
+ const run1Started = run1Stored.find((event) => event.type === EventType.RUN_STARTED) as RunStartedEvent;
262
+ expect(run1Started.input?.messages?.map((m) => m.id)).toEqual(["existing"]);
263
+
264
+ const run2Started = run2Stored.find((event) => event.type === EventType.RUN_STARTED) as RunStartedEvent;
265
+ expect(run2Started.input?.messages?.map((m) => m.id)).toEqual(["new"]);
266
+ });
267
+
268
+ it("preserves agent-provided input", async () => {
269
+ const threadId = "sqlite-agent-input";
270
+ const providedInput: RunAgentInput = {
271
+ threadId,
272
+ runId: "run-keep",
273
+ messages: [],
274
+ state: { fromAgent: true },
275
+ };
276
+
277
+ const agent = new MockAgent(
278
+ [
279
+ {
280
+ type: EventType.RUN_STARTED,
281
+ threadId,
282
+ runId: "run-keep",
283
+ input: providedInput,
284
+ } as RunStartedEvent,
285
+ {
286
+ type: EventType.RUN_FINISHED,
287
+ threadId,
288
+ runId: "run-keep",
289
+ } as RunFinishedEvent,
290
+ ],
291
+ false,
292
+ );
293
+
294
+ const events = await firstValueFrom(
295
+ runner
296
+ .run({
297
+ threadId,
298
+ agent,
299
+ input: {
300
+ threadId,
301
+ runId: "run-keep",
302
+ messages: [{ id: "ignored", role: "user", content: "hi" }],
303
+ state: {},
304
+ },
305
+ })
306
+ .pipe(toArray()),
307
+ );
308
+
309
+ expect(events.map((event) => event.type)).toEqual([
310
+ EventType.RUN_STARTED,
311
+ EventType.RUN_FINISHED,
312
+ ]);
313
+ const runStarted = events[0] as RunStartedEvent;
314
+ expect(runStarted.input).toBe(providedInput);
315
+ });
316
+
317
+ it("persists events across runner instances", async () => {
318
+ const threadId = "sqlite-persist";
319
+ const agent = new MockAgent([
320
+ { type: EventType.TEXT_MESSAGE_START, messageId: "msg", role: "assistant" } as TextMessageStartEvent,
321
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "msg", delta: "hi" } as TextMessageContentEvent,
322
+ { type: EventType.TEXT_MESSAGE_END, messageId: "msg" } as TextMessageEndEvent,
323
+ { type: EventType.RUN_FINISHED, threadId, runId: "run-1" } as RunFinishedEvent,
324
+ ]);
325
+
326
+ await firstValueFrom(
327
+ runner
328
+ .run({
329
+ threadId,
330
+ agent,
331
+ input: { threadId, runId: "run-1", messages: [], state: {} },
332
+ })
333
+ .pipe(toArray()),
334
+ );
335
+
336
+ const newRunner = new SqliteAgentRunner({ dbPath });
337
+ const replayed = await firstValueFrom(newRunner.connect({ threadId }).pipe(toArray()));
338
+
339
+ expect(replayed[0].type).toBe(EventType.RUN_STARTED);
340
+ expect(replayed.slice(1).map((event) => event.type)).toEqual([
341
+ EventType.TEXT_MESSAGE_START,
342
+ EventType.TEXT_MESSAGE_CONTENT,
343
+ EventType.TEXT_MESSAGE_END,
344
+ EventType.RUN_FINISHED,
345
+ ]);
346
+ });
347
+
348
+ it("returns false when stopping a thread that is not running", async () => {
349
+ await expect(runner.stop({ threadId: "sqlite-missing" })).resolves.toBe(false);
350
+ });
351
+
352
+ it("stops an active run and completes observables", async () => {
353
+ const threadId = "sqlite-stop";
354
+ const agent = new StoppableAgent(2);
355
+ const input: RunAgentInput = {
356
+ threadId,
357
+ runId: "sqlite-stop-run",
358
+ messages: [],
359
+ state: {},
360
+ };
361
+
362
+ const run$ = runner.run({ threadId, agent, input });
363
+ const collected = firstValueFrom(run$.pipe(toArray()));
364
+
365
+ await new Promise((resolve) => setTimeout(resolve, 20));
366
+ expect(await runner.isRunning({ threadId })).toBe(true);
367
+
368
+ const stopped = await runner.stop({ threadId });
369
+ expect(stopped).toBe(true);
370
+
371
+ const events = await collected;
372
+ expect(events.length).toBeGreaterThan(0);
373
+ expect(events[events.length - 1].type).toBe(EventType.RUN_FINISHED);
374
+ expect(await runner.isRunning({ threadId })).toBe(false);
375
+ });
376
+
377
+ it("closes open text and tool events when stopping", async () => {
378
+ const threadId = "sqlite-open-events";
379
+ const agent = new OpenEventsAgent();
380
+ const input: RunAgentInput = {
381
+ threadId,
382
+ runId: "sqlite-open-run",
383
+ messages: [],
384
+ state: {},
385
+ };
386
+
387
+ const run$ = runner.run({ threadId, agent, input });
388
+ const collected = firstValueFrom(run$.pipe(toArray()));
389
+
390
+ await new Promise((resolve) => setTimeout(resolve, 20));
391
+ await runner.stop({ threadId });
392
+
393
+ const events = await collected;
394
+ const endingTypes = events.slice(-4).map((event) => event.type);
395
+ expect(endingTypes).toEqual([
396
+ EventType.TEXT_MESSAGE_END,
397
+ EventType.TOOL_CALL_END,
398
+ EventType.TOOL_CALL_RESULT,
399
+ EventType.RUN_FINISHED,
400
+ ]);
401
+ });
402
+ });
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./sqlite-runner";