@kernl-sdk/pg 0.1.9
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +124 -0
- package/LICENSE +201 -0
- package/dist/__tests__/integration.test.d.ts +2 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +542 -0
- package/dist/__tests__/thread.test.d.ts +2 -0
- package/dist/__tests__/thread.test.d.ts.map +1 -0
- package/dist/__tests__/thread.test.js +208 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/migrations.d.ts +25 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +20 -0
- package/dist/postgres.d.ts +43 -0
- package/dist/postgres.d.ts.map +1 -0
- package/dist/postgres.js +46 -0
- package/dist/sql.d.ts +8 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +7 -0
- package/dist/storage.d.ts +68 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +194 -0
- package/dist/thread/store.d.ts +64 -0
- package/dist/thread/store.d.ts.map +1 -0
- package/dist/thread/store.js +346 -0
- package/package.json +54 -0
- package/src/__tests__/integration.test.ts +774 -0
- package/src/__tests__/thread.test.ts +285 -0
- package/src/index.ts +7 -0
- package/src/migrations.ts +38 -0
- package/src/postgres.ts +63 -0
- package/src/sql.ts +8 -0
- package/src/storage.ts +270 -0
- package/src/thread/store.ts +460 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
import { PGStorage } from "../storage";
|
|
5
|
+
import { PGThreadStore } from "../thread/store";
|
|
6
|
+
import {
|
|
7
|
+
Agent,
|
|
8
|
+
Context,
|
|
9
|
+
type AgentRegistry,
|
|
10
|
+
type ModelRegistry,
|
|
11
|
+
} from "kernl";
|
|
12
|
+
import { Thread } from "kernl/internal";
|
|
13
|
+
|
|
14
|
+
const TEST_DB_URL = process.env.KERNL_PG_TEST_URL;
|
|
15
|
+
|
|
16
|
+
type TestLanguageModel =
|
|
17
|
+
ModelRegistry extends { get(key: string): infer T | undefined } ? T : never;
|
|
18
|
+
|
|
19
|
+
describe.sequential("PGStorage integration", () => {
|
|
20
|
+
if (!TEST_DB_URL) {
|
|
21
|
+
it.skip("requires KERNL_PG_TEST_URL to be set", () => {
|
|
22
|
+
// Intentionally empty - environment not configured for integration tests.
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TableRow {
|
|
28
|
+
table_name: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface MigrationRow {
|
|
32
|
+
id: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let pool: Pool;
|
|
36
|
+
let storage: PGStorage;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
pool = new Pool({ connectionString: TEST_DB_URL });
|
|
40
|
+
storage = new PGStorage({ pool });
|
|
41
|
+
|
|
42
|
+
// Ensure a clean schema for this test run.
|
|
43
|
+
await pool.query('DROP SCHEMA IF EXISTS "kernl" CASCADE');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await storage.close(); // calls pool.end()
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("connects, creates schema, runs migrations, enforces constraints, and is idempotent", async () => {
|
|
51
|
+
// First init: should create schema + migrations table + apply all migrations.
|
|
52
|
+
await storage.init();
|
|
53
|
+
|
|
54
|
+
// ---- verify tables exist ----
|
|
55
|
+
const tablesResult = await pool.query<TableRow>(
|
|
56
|
+
`
|
|
57
|
+
SELECT table_name
|
|
58
|
+
FROM information_schema.tables
|
|
59
|
+
WHERE table_schema = 'kernl'
|
|
60
|
+
ORDER BY table_name ASC
|
|
61
|
+
`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const tableNames = tablesResult.rows.map((row) => row.table_name);
|
|
65
|
+
expect(tableNames).toContain("migrations");
|
|
66
|
+
expect(tableNames).toContain("threads");
|
|
67
|
+
expect(tableNames).toContain("thread_events");
|
|
68
|
+
|
|
69
|
+
// ---- verify migrations recorded ----
|
|
70
|
+
const migrationsResult = await pool.query<MigrationRow>(
|
|
71
|
+
`SELECT id FROM "kernl".migrations ORDER BY applied_at ASC`,
|
|
72
|
+
);
|
|
73
|
+
const appliedMigrationIds = migrationsResult.rows.map((row) => row.id);
|
|
74
|
+
expect(appliedMigrationIds).toEqual(["0001_initial"]);
|
|
75
|
+
|
|
76
|
+
// ---- verify indexes created by table definitions ----
|
|
77
|
+
const indexesResult = await pool.query<{
|
|
78
|
+
indexname: string;
|
|
79
|
+
tablename: string;
|
|
80
|
+
}>(
|
|
81
|
+
`
|
|
82
|
+
SELECT indexname, tablename
|
|
83
|
+
FROM pg_indexes
|
|
84
|
+
WHERE schemaname = 'kernl'
|
|
85
|
+
`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const indexNames = indexesResult.rows.map((row) => row.indexname);
|
|
89
|
+
expect(indexNames).toEqual(
|
|
90
|
+
expect.arrayContaining([
|
|
91
|
+
"idx_threads_state",
|
|
92
|
+
"idx_threads_namespace",
|
|
93
|
+
"idx_threads_agent_id",
|
|
94
|
+
"idx_threads_parent_task_id",
|
|
95
|
+
"idx_threads_created_at",
|
|
96
|
+
"idx_threads_updated_at",
|
|
97
|
+
"idx_thread_events_tid_seq",
|
|
98
|
+
"idx_thread_events_tid_kind",
|
|
99
|
+
]),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// ---- verify unique + foreign-key constraints on thread_events ----
|
|
103
|
+
|
|
104
|
+
// Insert a parent thread row that satisfies NOT NULL constraints.
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const threadId = "thread-int-1";
|
|
107
|
+
await pool.query(
|
|
108
|
+
`
|
|
109
|
+
INSERT INTO "kernl"."threads"
|
|
110
|
+
(id, agent_id, model, context, parent_task_id, tick, state, metadata, created_at, updated_at)
|
|
111
|
+
VALUES
|
|
112
|
+
($1, $2, $3, $4::jsonb, $5, $6, $7, $8::jsonb, $9, $10)
|
|
113
|
+
`,
|
|
114
|
+
[
|
|
115
|
+
threadId,
|
|
116
|
+
"agent-int",
|
|
117
|
+
"provider/model",
|
|
118
|
+
JSON.stringify({ foo: "bar" }),
|
|
119
|
+
null,
|
|
120
|
+
0,
|
|
121
|
+
"stopped",
|
|
122
|
+
null,
|
|
123
|
+
now,
|
|
124
|
+
now,
|
|
125
|
+
],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// 1) Foreign key should reject events for non-existent threads.
|
|
129
|
+
await expect(
|
|
130
|
+
pool.query(
|
|
131
|
+
`
|
|
132
|
+
INSERT INTO "kernl"."thread_events"
|
|
133
|
+
(id, tid, seq, kind, timestamp, data, metadata)
|
|
134
|
+
VALUES
|
|
135
|
+
($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
|
|
136
|
+
`,
|
|
137
|
+
[
|
|
138
|
+
"event-bad-thread",
|
|
139
|
+
"non-existent-thread",
|
|
140
|
+
0,
|
|
141
|
+
"system",
|
|
142
|
+
now,
|
|
143
|
+
null,
|
|
144
|
+
null,
|
|
145
|
+
],
|
|
146
|
+
),
|
|
147
|
+
).rejects.toThrow();
|
|
148
|
+
|
|
149
|
+
// 2) Unique constraint on (tid, id) should reject duplicates.
|
|
150
|
+
const eventId = "event-1";
|
|
151
|
+
await pool.query(
|
|
152
|
+
`
|
|
153
|
+
INSERT INTO "kernl"."thread_events"
|
|
154
|
+
(id, tid, seq, kind, timestamp, data, metadata)
|
|
155
|
+
VALUES
|
|
156
|
+
($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
|
|
157
|
+
`,
|
|
158
|
+
[eventId, threadId, 0, "system", now, null, null],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await expect(
|
|
162
|
+
pool.query(
|
|
163
|
+
`
|
|
164
|
+
INSERT INTO "kernl"."thread_events"
|
|
165
|
+
(id, tid, seq, kind, timestamp, data, metadata)
|
|
166
|
+
VALUES
|
|
167
|
+
($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
|
|
168
|
+
`,
|
|
169
|
+
[eventId, threadId, 1, "system", now, null, null],
|
|
170
|
+
),
|
|
171
|
+
).rejects.toThrow();
|
|
172
|
+
|
|
173
|
+
// 3) ON DELETE CASCADE: deleting the thread should delete its events.
|
|
174
|
+
await pool.query(
|
|
175
|
+
`DELETE FROM "kernl"."threads" WHERE id = $1`,
|
|
176
|
+
[threadId],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const countResult = await pool.query<{ count: string }>(
|
|
180
|
+
`
|
|
181
|
+
SELECT COUNT(*)::text AS count
|
|
182
|
+
FROM "kernl"."thread_events"
|
|
183
|
+
WHERE tid = $1
|
|
184
|
+
`,
|
|
185
|
+
[threadId],
|
|
186
|
+
);
|
|
187
|
+
expect(countResult.rows[0]?.count).toBe("0");
|
|
188
|
+
|
|
189
|
+
// ---- verify init() is idempotent ----
|
|
190
|
+
await storage.init();
|
|
191
|
+
|
|
192
|
+
const migrationsAfterResult = await pool.query<MigrationRow>(
|
|
193
|
+
`SELECT id FROM "kernl".migrations ORDER BY applied_at ASC`,
|
|
194
|
+
);
|
|
195
|
+
expect(migrationsAfterResult.rows).toHaveLength(
|
|
196
|
+
migrationsResult.rows.length,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("performs basic thread CRUD via PGThreadStore", async () => {
|
|
201
|
+
// Ensure schema is initialized (idempotent if already run).
|
|
202
|
+
await storage.init();
|
|
203
|
+
|
|
204
|
+
// Bind minimal agent/model registries so PGThreadStore can hydrate threads.
|
|
205
|
+
const model = {
|
|
206
|
+
spec: "1.0" as const,
|
|
207
|
+
provider: "test",
|
|
208
|
+
modelId: "test-model",
|
|
209
|
+
} as unknown as TestLanguageModel;
|
|
210
|
+
|
|
211
|
+
const agent = new Agent({
|
|
212
|
+
id: "agent-1",
|
|
213
|
+
name: "Test Agent",
|
|
214
|
+
instructions: () => "test",
|
|
215
|
+
model,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const agents: AgentRegistry = new Map<string, Agent>([["agent-1", agent]]);
|
|
219
|
+
const models: ModelRegistry = new Map<string, TestLanguageModel>([
|
|
220
|
+
["provider/model", model],
|
|
221
|
+
]) as unknown as ModelRegistry;
|
|
222
|
+
|
|
223
|
+
storage.bind({ agents, models });
|
|
224
|
+
const store = storage.threads;
|
|
225
|
+
|
|
226
|
+
const tid = "thread-store-1";
|
|
227
|
+
|
|
228
|
+
// Insert a new thread (no explicit context/metadata) and verify defaults.
|
|
229
|
+
await store.insert({
|
|
230
|
+
id: tid,
|
|
231
|
+
namespace: "kernl",
|
|
232
|
+
agentId: "agent-1",
|
|
233
|
+
model: "provider/model",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const inserted = await pool.query<{
|
|
237
|
+
id: string;
|
|
238
|
+
agent_id: string;
|
|
239
|
+
model: string;
|
|
240
|
+
tick: number;
|
|
241
|
+
state: string;
|
|
242
|
+
context: unknown;
|
|
243
|
+
metadata: unknown;
|
|
244
|
+
}>(
|
|
245
|
+
`
|
|
246
|
+
SELECT id, agent_id, model, tick, state, context, metadata
|
|
247
|
+
FROM "kernl"."threads"
|
|
248
|
+
WHERE id = $1
|
|
249
|
+
`,
|
|
250
|
+
[tid],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(inserted.rows).toHaveLength(1);
|
|
254
|
+
expect(inserted.rows[0]?.id).toBe(tid);
|
|
255
|
+
expect(inserted.rows[0]?.agent_id).toBe("agent-1");
|
|
256
|
+
expect(inserted.rows[0]?.model).toBe("provider/model");
|
|
257
|
+
expect(inserted.rows[0]?.tick).toBe(0);
|
|
258
|
+
expect(inserted.rows[0]?.state).toBe("stopped");
|
|
259
|
+
// NewThreadCodec should default context to {} and metadata to null.
|
|
260
|
+
expect(inserted.rows[0]?.context).toEqual({});
|
|
261
|
+
expect(inserted.rows[0]?.metadata).toBeNull();
|
|
262
|
+
|
|
263
|
+
// Insert a thread with a non-trivial context to verify JSONB handling.
|
|
264
|
+
const tidWithContext = "thread-store-ctx";
|
|
265
|
+
const complexContext = { foo: "bar", nested: { a: 1, b: [1, 2, 3] } };
|
|
266
|
+
|
|
267
|
+
await store.insert({
|
|
268
|
+
id: tidWithContext,
|
|
269
|
+
namespace: "kernl",
|
|
270
|
+
agentId: "agent-1",
|
|
271
|
+
model: "provider/model",
|
|
272
|
+
context: complexContext as unknown as never,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const ctxResult = await pool.query<{ context: unknown }>(
|
|
276
|
+
`
|
|
277
|
+
SELECT context
|
|
278
|
+
FROM "kernl"."threads"
|
|
279
|
+
WHERE id = $1
|
|
280
|
+
`,
|
|
281
|
+
[tidWithContext],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(ctxResult.rows).toHaveLength(1);
|
|
285
|
+
expect(ctxResult.rows[0]?.context).toEqual(complexContext);
|
|
286
|
+
|
|
287
|
+
// Update context via ThreadStore.update and verify JSONB is replaced.
|
|
288
|
+
const updatedContext = { foo: "baz", nested: { a: 2, b: [4, 5, 6] } };
|
|
289
|
+
|
|
290
|
+
await store.update(tidWithContext, {
|
|
291
|
+
context: new Context("kernl", updatedContext as unknown),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const ctxUpdatedResult = await pool.query<{ context: unknown }>(
|
|
295
|
+
`
|
|
296
|
+
SELECT context
|
|
297
|
+
FROM "kernl"."threads"
|
|
298
|
+
WHERE id = $1
|
|
299
|
+
`,
|
|
300
|
+
[tidWithContext],
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(ctxUpdatedResult.rows).toHaveLength(1);
|
|
304
|
+
expect(ctxUpdatedResult.rows[0]?.context).toEqual(updatedContext);
|
|
305
|
+
|
|
306
|
+
// Update the original thread with full patch, including a title in metadata.
|
|
307
|
+
await store.update(tid, {
|
|
308
|
+
tick: 5,
|
|
309
|
+
state: "running",
|
|
310
|
+
metadata: { source: "integration-test", title: "Test Thread" },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const updatedFull = await pool.query<{
|
|
314
|
+
tick: number;
|
|
315
|
+
state: string;
|
|
316
|
+
metadata: unknown;
|
|
317
|
+
}>(
|
|
318
|
+
`
|
|
319
|
+
SELECT tick, state, metadata
|
|
320
|
+
FROM "kernl"."threads"
|
|
321
|
+
WHERE id = $1
|
|
322
|
+
`,
|
|
323
|
+
[tid],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(updatedFull.rows).toHaveLength(1);
|
|
327
|
+
expect(updatedFull.rows[0]?.tick).toBe(5);
|
|
328
|
+
expect(updatedFull.rows[0]?.state).toBe("running");
|
|
329
|
+
expect(updatedFull.rows[0]?.metadata).toEqual({
|
|
330
|
+
source: "integration-test",
|
|
331
|
+
title: "Test Thread",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Partial update: only tick should change; state/metadata should stay the same.
|
|
335
|
+
await store.update(tid, {
|
|
336
|
+
tick: 10,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const updatedPartial = await pool.query<{
|
|
340
|
+
tick: number;
|
|
341
|
+
state: string;
|
|
342
|
+
metadata: unknown;
|
|
343
|
+
}>(
|
|
344
|
+
`
|
|
345
|
+
SELECT tick, state, metadata
|
|
346
|
+
FROM "kernl"."threads"
|
|
347
|
+
WHERE id = $1
|
|
348
|
+
`,
|
|
349
|
+
[tid],
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(updatedPartial.rows).toHaveLength(1);
|
|
353
|
+
expect(updatedPartial.rows[0]?.tick).toBe(10);
|
|
354
|
+
expect(updatedPartial.rows[0]?.state).toBe("running");
|
|
355
|
+
expect(updatedPartial.rows[0]?.metadata).toEqual({
|
|
356
|
+
source: "integration-test",
|
|
357
|
+
title: "Test Thread",
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Update with metadata explicitly null to verify JSONB nulling.
|
|
361
|
+
await store.update(tid, {
|
|
362
|
+
metadata: null,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const updatedNullMeta = await pool.query<{
|
|
366
|
+
metadata: unknown;
|
|
367
|
+
}>(
|
|
368
|
+
`
|
|
369
|
+
SELECT metadata
|
|
370
|
+
FROM "kernl"."threads"
|
|
371
|
+
WHERE id = $1
|
|
372
|
+
`,
|
|
373
|
+
[tid],
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
expect(updatedNullMeta.rows).toHaveLength(1);
|
|
377
|
+
expect(updatedNullMeta.rows[0]?.metadata).toBeNull();
|
|
378
|
+
|
|
379
|
+
// Delete the thread.
|
|
380
|
+
await store.delete(tid);
|
|
381
|
+
|
|
382
|
+
const afterDelete = await pool.query<{ count: string }>(
|
|
383
|
+
`
|
|
384
|
+
SELECT COUNT(*)::text AS count
|
|
385
|
+
FROM "kernl"."threads"
|
|
386
|
+
WHERE id = $1
|
|
387
|
+
`,
|
|
388
|
+
[tid],
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
expect(afterDelete.rows[0]?.count).toBe("0");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("appends and queries thread events via PGThreadStore.history", async () => {
|
|
395
|
+
await storage.init();
|
|
396
|
+
|
|
397
|
+
// Reset tables for this test so we only see events we insert here.
|
|
398
|
+
await pool.query('DELETE FROM "kernl"."thread_events"');
|
|
399
|
+
await pool.query('DELETE FROM "kernl"."threads"');
|
|
400
|
+
|
|
401
|
+
const model = {
|
|
402
|
+
spec: "1.0" as const,
|
|
403
|
+
provider: "test",
|
|
404
|
+
modelId: "test-model",
|
|
405
|
+
} as unknown as TestLanguageModel;
|
|
406
|
+
|
|
407
|
+
const agent = new Agent({
|
|
408
|
+
id: "agent-1",
|
|
409
|
+
name: "Test Agent",
|
|
410
|
+
instructions: () => "test",
|
|
411
|
+
model,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const agents: AgentRegistry = new Map<string, Agent>([["agent-1", agent]]);
|
|
415
|
+
const models: ModelRegistry = new Map<string, TestLanguageModel>([
|
|
416
|
+
["provider/model", model],
|
|
417
|
+
]) as unknown as ModelRegistry;
|
|
418
|
+
|
|
419
|
+
storage.bind({ agents, models });
|
|
420
|
+
const store = storage.threads;
|
|
421
|
+
|
|
422
|
+
const tid = "thread-events-1";
|
|
423
|
+
|
|
424
|
+
await store.insert({
|
|
425
|
+
id: tid,
|
|
426
|
+
namespace: "kernl",
|
|
427
|
+
agentId: "agent-1",
|
|
428
|
+
model: "provider/model",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const thread = new Thread({
|
|
432
|
+
agent,
|
|
433
|
+
model,
|
|
434
|
+
tid,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const [event1, event2, event3] = thread.append(
|
|
438
|
+
{ kind: "message", role: "user", text: "one" } as any,
|
|
439
|
+
{ kind: "message", role: "assistant", text: "two" } as any,
|
|
440
|
+
{ kind: "reasoning", text: "thinking" } as any,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
await store.append([event1, event2, event3]);
|
|
444
|
+
|
|
445
|
+
const allAsc = await store.history(tid);
|
|
446
|
+
expect(allAsc.map((e) => e.id)).toEqual([
|
|
447
|
+
event1.id,
|
|
448
|
+
event2.id,
|
|
449
|
+
event3.id,
|
|
450
|
+
]);
|
|
451
|
+
|
|
452
|
+
const afterFirst = await store.history(tid, { after: event1.seq });
|
|
453
|
+
expect(afterFirst.map((e) => e.id)).toEqual([event2.id, event3.id]);
|
|
454
|
+
|
|
455
|
+
const reasoningOnly = await store.history(tid, { kinds: ["reasoning"] });
|
|
456
|
+
expect(reasoningOnly).toHaveLength(1);
|
|
457
|
+
expect(reasoningOnly[0]?.id).toBe(event3.id);
|
|
458
|
+
expect(reasoningOnly[0]?.kind).toBe("reasoning");
|
|
459
|
+
|
|
460
|
+
const lastDesc = await store.history(tid, { order: "desc", limit: 1 });
|
|
461
|
+
expect(lastDesc).toHaveLength(1);
|
|
462
|
+
expect(lastDesc[0]?.id).toBe(event3.id);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("does not re-insert an existing thread when streaming from a hydrated instance", async () => {
|
|
466
|
+
await storage.init();
|
|
467
|
+
|
|
468
|
+
// Reset tables for this test
|
|
469
|
+
await pool.query('DELETE FROM "kernl"."thread_events"');
|
|
470
|
+
await pool.query('DELETE FROM "kernl"."threads"');
|
|
471
|
+
|
|
472
|
+
const model = {
|
|
473
|
+
spec: "1.0" as const,
|
|
474
|
+
provider: "test",
|
|
475
|
+
modelId: "test-model",
|
|
476
|
+
// generate/stream are not used in this test - we only advance stream
|
|
477
|
+
// far enough to trigger the first checkpoint.
|
|
478
|
+
} as unknown as TestLanguageModel;
|
|
479
|
+
|
|
480
|
+
const agent = new Agent({
|
|
481
|
+
id: "agent-1",
|
|
482
|
+
name: "Test Agent",
|
|
483
|
+
instructions: () => "test",
|
|
484
|
+
model,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const agents: AgentRegistry = new Map<string, Agent>([["agent-1", agent]]);
|
|
488
|
+
const models: ModelRegistry = new Map<string, TestLanguageModel>([
|
|
489
|
+
["provider/model", model],
|
|
490
|
+
]) as unknown as ModelRegistry;
|
|
491
|
+
|
|
492
|
+
storage.bind({ agents, models });
|
|
493
|
+
const store = storage.threads;
|
|
494
|
+
|
|
495
|
+
const tid = "thread-stream-checkpoint-1";
|
|
496
|
+
|
|
497
|
+
// Create the thread row once via the store.
|
|
498
|
+
await store.insert({
|
|
499
|
+
id: tid,
|
|
500
|
+
namespace: "kernl",
|
|
501
|
+
agentId: "agent-1",
|
|
502
|
+
model: "provider/model",
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Hydrate a Thread instance from storage.
|
|
506
|
+
const hydrated = await store.get(tid);
|
|
507
|
+
expect(hydrated).not.toBeNull();
|
|
508
|
+
|
|
509
|
+
// Advance the stream once to trigger the initial checkpoint().
|
|
510
|
+
const iterator = hydrated!.stream()[Symbol.asyncIterator]();
|
|
511
|
+
await iterator.next();
|
|
512
|
+
|
|
513
|
+
// Ensure only one row exists for this tid (no duplicate INSERT).
|
|
514
|
+
const countResult = await pool.query<{ count: string }>(
|
|
515
|
+
`
|
|
516
|
+
SELECT COUNT(*)::text AS count
|
|
517
|
+
FROM "kernl"."threads"
|
|
518
|
+
WHERE id = $1
|
|
519
|
+
`,
|
|
520
|
+
[tid],
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect(countResult.rows[0]?.count).toBe("1");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("gets a thread with joined history via include.history", async () => {
|
|
527
|
+
await storage.init();
|
|
528
|
+
|
|
529
|
+
await pool.query('DELETE FROM "kernl"."thread_events"');
|
|
530
|
+
await pool.query('DELETE FROM "kernl"."threads"');
|
|
531
|
+
|
|
532
|
+
const model = {
|
|
533
|
+
spec: "1.0" as const,
|
|
534
|
+
provider: "test",
|
|
535
|
+
modelId: "test-model",
|
|
536
|
+
} as unknown as TestLanguageModel;
|
|
537
|
+
|
|
538
|
+
const agent = new Agent({
|
|
539
|
+
id: "agent-1",
|
|
540
|
+
name: "Test Agent",
|
|
541
|
+
instructions: () => "test",
|
|
542
|
+
model,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const agents: AgentRegistry = new Map<string, Agent>([["agent-1", agent]]);
|
|
546
|
+
const models: ModelRegistry = new Map<string, TestLanguageModel>([
|
|
547
|
+
["provider/model", model],
|
|
548
|
+
]) as unknown as ModelRegistry;
|
|
549
|
+
|
|
550
|
+
storage.bind({ agents, models });
|
|
551
|
+
const store = storage.threads;
|
|
552
|
+
|
|
553
|
+
const tid = "thread-get-1";
|
|
554
|
+
|
|
555
|
+
await store.insert({
|
|
556
|
+
id: tid,
|
|
557
|
+
namespace: "kernl",
|
|
558
|
+
agentId: "agent-1",
|
|
559
|
+
model: "provider/model",
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const thread = new Thread({
|
|
563
|
+
agent,
|
|
564
|
+
model,
|
|
565
|
+
tid,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const [event1, event2] = thread.append(
|
|
569
|
+
{ kind: "message", role: "user", text: "hello" } as any,
|
|
570
|
+
{ kind: "message", role: "assistant", text: "world" } as any,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
await store.append([event1, event2]);
|
|
574
|
+
|
|
575
|
+
const loaded = await store.get(tid, { history: true });
|
|
576
|
+
expect(loaded).not.toBeNull();
|
|
577
|
+
|
|
578
|
+
const loadedAny = loaded as any;
|
|
579
|
+
expect(loadedAny.tid).toBe(tid);
|
|
580
|
+
expect(Array.isArray(loadedAny.history)).toBe(true);
|
|
581
|
+
expect(loadedAny.history.map((e: any) => e.id)).toEqual([
|
|
582
|
+
event1.id,
|
|
583
|
+
event2.id,
|
|
584
|
+
]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("lists threads with filters, ordering, and pagination", async () => {
|
|
588
|
+
await storage.init();
|
|
589
|
+
|
|
590
|
+
// Reset tables for this test so list results are deterministic.
|
|
591
|
+
await pool.query('DELETE FROM "kernl"."thread_events"');
|
|
592
|
+
await pool.query('DELETE FROM "kernl"."threads"');
|
|
593
|
+
|
|
594
|
+
const model = {
|
|
595
|
+
spec: "1.0" as const,
|
|
596
|
+
provider: "test",
|
|
597
|
+
modelId: "test-model",
|
|
598
|
+
} as unknown as TestLanguageModel;
|
|
599
|
+
|
|
600
|
+
const agent1 = new Agent({
|
|
601
|
+
id: "agent-1",
|
|
602
|
+
name: "Agent 1",
|
|
603
|
+
instructions: () => "test",
|
|
604
|
+
model,
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const agent2 = new Agent({
|
|
608
|
+
id: "agent-2",
|
|
609
|
+
name: "Agent 2",
|
|
610
|
+
instructions: () => "test",
|
|
611
|
+
model,
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const agents: AgentRegistry = new Map<string, Agent>([
|
|
615
|
+
["agent-1", agent1],
|
|
616
|
+
["agent-2", agent2],
|
|
617
|
+
]);
|
|
618
|
+
const models: ModelRegistry = new Map<string, TestLanguageModel>([
|
|
619
|
+
["provider/model", model],
|
|
620
|
+
]) as unknown as ModelRegistry;
|
|
621
|
+
|
|
622
|
+
storage.bind({ agents, models });
|
|
623
|
+
const store = storage.threads;
|
|
624
|
+
|
|
625
|
+
const now = Date.now();
|
|
626
|
+
const t1Created = new Date(now - 3000);
|
|
627
|
+
const t2Created = new Date(now - 2000);
|
|
628
|
+
const t3Created = new Date(now - 1000);
|
|
629
|
+
|
|
630
|
+
await store.insert({
|
|
631
|
+
id: "list-1",
|
|
632
|
+
namespace: "kernl",
|
|
633
|
+
agentId: "agent-1",
|
|
634
|
+
model: "provider/model",
|
|
635
|
+
state: "running" as any,
|
|
636
|
+
parentTaskId: "task-1",
|
|
637
|
+
createdAt: t1Created,
|
|
638
|
+
updatedAt: t1Created,
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
await store.insert({
|
|
642
|
+
id: "list-2",
|
|
643
|
+
namespace: "kernl",
|
|
644
|
+
agentId: "agent-1",
|
|
645
|
+
model: "provider/model",
|
|
646
|
+
state: "stopped" as any,
|
|
647
|
+
parentTaskId: "task-2",
|
|
648
|
+
createdAt: t2Created,
|
|
649
|
+
updatedAt: t2Created,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
await store.insert({
|
|
653
|
+
id: "list-3",
|
|
654
|
+
namespace: "kernl",
|
|
655
|
+
agentId: "agent-2",
|
|
656
|
+
model: "provider/model",
|
|
657
|
+
state: "running" as any,
|
|
658
|
+
parentTaskId: null,
|
|
659
|
+
createdAt: t3Created,
|
|
660
|
+
updatedAt: t3Created,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Default ordering: created_at DESC.
|
|
664
|
+
const all = await store.list();
|
|
665
|
+
expect(all.map((t) => t.tid)).toEqual(["list-3", "list-2", "list-1"]);
|
|
666
|
+
|
|
667
|
+
// Filter by state (single).
|
|
668
|
+
const running = await store.list({ filter: { state: "running" as any } });
|
|
669
|
+
expect(running.map((t) => t.tid)).toEqual(
|
|
670
|
+
expect.arrayContaining(["list-1", "list-3"]),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Filter by state (array).
|
|
674
|
+
const states = await store.list({
|
|
675
|
+
filter: { state: ["running", "stopped"] as any },
|
|
676
|
+
});
|
|
677
|
+
expect(states.map((t) => t.tid)).toEqual(
|
|
678
|
+
expect.arrayContaining(["list-1", "list-2", "list-3"]),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
// Filter by agentId.
|
|
682
|
+
const agent2Threads = await store.list({ filter: { agentId: "agent-2" } });
|
|
683
|
+
expect(agent2Threads.map((t) => t.tid)).toEqual(["list-3"]);
|
|
684
|
+
|
|
685
|
+
// Filter by parentTaskId.
|
|
686
|
+
const task2Threads = await store.list({
|
|
687
|
+
filter: { parentTaskId: "task-2" },
|
|
688
|
+
});
|
|
689
|
+
expect(task2Threads.map((t) => t.tid)).toEqual(["list-2"]);
|
|
690
|
+
|
|
691
|
+
// Filter by createdAfter / createdBefore.
|
|
692
|
+
const afterT1 = await store.list({ filter: { createdAfter: t1Created } });
|
|
693
|
+
expect(afterT1.map((t) => t.tid)).toEqual(
|
|
694
|
+
expect.arrayContaining(["list-2", "list-3"]),
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
const beforeT3 = await store.list({
|
|
698
|
+
filter: { createdBefore: t3Created },
|
|
699
|
+
order: { createdAt: "asc" },
|
|
700
|
+
});
|
|
701
|
+
expect(beforeT3.map((t) => t.tid)).toEqual(["list-1", "list-2"]);
|
|
702
|
+
|
|
703
|
+
// Pagination with explicit ordering.
|
|
704
|
+
const page = await store.list({
|
|
705
|
+
order: { createdAt: "asc" },
|
|
706
|
+
limit: 2,
|
|
707
|
+
offset: 1,
|
|
708
|
+
});
|
|
709
|
+
expect(page.map((t) => t.tid)).toEqual(["list-2", "list-3"]);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("persists namespace and filters by namespace", async () => {
|
|
713
|
+
await storage.init();
|
|
714
|
+
|
|
715
|
+
await pool.query('DELETE FROM "kernl"."thread_events"');
|
|
716
|
+
await pool.query('DELETE FROM "kernl"."threads"');
|
|
717
|
+
|
|
718
|
+
const model = {
|
|
719
|
+
spec: "1.0" as const,
|
|
720
|
+
provider: "test",
|
|
721
|
+
modelId: "test-model",
|
|
722
|
+
} as unknown as TestLanguageModel;
|
|
723
|
+
|
|
724
|
+
const agent = new Agent({
|
|
725
|
+
id: "agent-1",
|
|
726
|
+
name: "Test Agent",
|
|
727
|
+
instructions: () => "test",
|
|
728
|
+
model,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const agents: AgentRegistry = new Map<string, Agent>([["agent-1", agent]]);
|
|
732
|
+
const models: ModelRegistry = new Map<string, TestLanguageModel>([
|
|
733
|
+
["provider/model", model],
|
|
734
|
+
]) as unknown as ModelRegistry;
|
|
735
|
+
|
|
736
|
+
storage.bind({ agents, models });
|
|
737
|
+
const store = storage.threads;
|
|
738
|
+
|
|
739
|
+
await store.insert({
|
|
740
|
+
id: "ns-1a",
|
|
741
|
+
namespace: "ns-a",
|
|
742
|
+
agentId: "agent-1",
|
|
743
|
+
model: "provider/model",
|
|
744
|
+
});
|
|
745
|
+
await store.insert({
|
|
746
|
+
id: "ns-2b",
|
|
747
|
+
namespace: "ns-b",
|
|
748
|
+
agentId: "agent-1",
|
|
749
|
+
model: "provider/model",
|
|
750
|
+
});
|
|
751
|
+
await store.insert({
|
|
752
|
+
id: "ns-3a",
|
|
753
|
+
namespace: "ns-a",
|
|
754
|
+
agentId: "agent-1",
|
|
755
|
+
model: "provider/model",
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
const a = await store.list({ filter: { namespace: "ns-a" } });
|
|
759
|
+
expect(a.map((t) => t.tid)).toEqual(
|
|
760
|
+
expect.arrayContaining(["ns-1a", "ns-3a"]),
|
|
761
|
+
);
|
|
762
|
+
expect(a.every((t) => t.namespace === "ns-a")).toBe(true);
|
|
763
|
+
|
|
764
|
+
const b = await store.list({ filter: { namespace: "ns-b" } });
|
|
765
|
+
expect(b.map((t) => t.tid)).toEqual(["ns-2b"]);
|
|
766
|
+
expect(b[0]?.namespace).toBe("ns-b");
|
|
767
|
+
|
|
768
|
+
// get with history should hydrate namespace properly
|
|
769
|
+
const loaded = await store.get("ns-1a", { history: true });
|
|
770
|
+
expect(loaded?.namespace).toBe("ns-a");
|
|
771
|
+
expect(loaded?.context.namespace).toBe("ns-a");
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|