kernl 0.10.0 → 0.11.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.
@@ -0,0 +1,553 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { z } from "zod";
3
+ import { IN_PROGRESS } from "@kernl-sdk/protocol";
4
+ import { message } from "@kernl-sdk/protocol";
5
+ import { Agent } from "../../agent.js";
6
+ import { Kernl } from "../../kernl/index.js";
7
+ import { tool, FunctionToolkit } from "../../tool/index.js";
8
+ import { createMockModel } from "../../thread/__tests__/fixtures/mock-model.js";
9
+ describe("Lifecycle Hooks", () => {
10
+ describe("Thread events", () => {
11
+ it("emits thread.start on spawn()", async () => {
12
+ const events = [];
13
+ const model = createMockModel(async () => ({
14
+ content: [message({ role: "assistant", text: "Done" })],
15
+ finishReason: "stop",
16
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
17
+ warnings: [],
18
+ }));
19
+ const agent = new Agent({
20
+ id: "test-agent",
21
+ name: "Test",
22
+ instructions: "Test",
23
+ model,
24
+ });
25
+ const kernl = new Kernl();
26
+ kernl.register(agent);
27
+ kernl.on("thread.start", (e) => events.push(e));
28
+ await agent.run("Hello");
29
+ expect(events).toHaveLength(1);
30
+ expect(events[0]).toMatchObject({
31
+ kind: "thread.start",
32
+ agentId: "test-agent",
33
+ namespace: "kernl",
34
+ });
35
+ expect(events[0].threadId).toBeDefined();
36
+ expect(events[0].context).toBeDefined();
37
+ });
38
+ it("emits thread.stop with outcome=success on successful run", async () => {
39
+ const events = [];
40
+ const model = createMockModel(async () => ({
41
+ content: [message({ role: "assistant", text: "Done" })],
42
+ finishReason: "stop",
43
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
44
+ warnings: [],
45
+ }));
46
+ const agent = new Agent({
47
+ id: "test-agent",
48
+ name: "Test",
49
+ instructions: "Test",
50
+ model,
51
+ });
52
+ const kernl = new Kernl();
53
+ kernl.register(agent);
54
+ kernl.on("thread.stop", (e) => events.push(e));
55
+ await agent.run("Hello");
56
+ expect(events).toHaveLength(1);
57
+ expect(events[0]).toMatchObject({
58
+ kind: "thread.stop",
59
+ agentId: "test-agent",
60
+ namespace: "kernl",
61
+ outcome: "success",
62
+ state: "stopped",
63
+ result: "Done",
64
+ });
65
+ });
66
+ it("emits thread.stop with outcome=error on failure", async () => {
67
+ const events = [];
68
+ const model = createMockModel(async () => {
69
+ throw new Error("Model exploded");
70
+ });
71
+ const agent = new Agent({
72
+ id: "test-agent",
73
+ name: "Test",
74
+ instructions: "Test",
75
+ model,
76
+ });
77
+ const kernl = new Kernl();
78
+ kernl.register(agent);
79
+ kernl.on("thread.stop", (e) => events.push(e));
80
+ await expect(agent.run("Hello")).rejects.toThrow("Model exploded");
81
+ expect(events).toHaveLength(1);
82
+ expect(events[0]).toMatchObject({
83
+ kind: "thread.stop",
84
+ agentId: "test-agent",
85
+ outcome: "error",
86
+ error: "Model exploded",
87
+ });
88
+ });
89
+ it("propagates model events from agent to kernl", async () => {
90
+ const agentEvents = [];
91
+ const kernlEvents = [];
92
+ const model = createMockModel(async () => ({
93
+ content: [message({ role: "assistant", text: "Done" })],
94
+ finishReason: "stop",
95
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
96
+ warnings: [],
97
+ }));
98
+ const agent = new Agent({
99
+ id: "test-agent",
100
+ name: "Test",
101
+ instructions: "Test",
102
+ model,
103
+ });
104
+ const kernl = new Kernl();
105
+ kernl.register(agent);
106
+ // Model events are emitted by agent and bubble to kernl
107
+ agent.on("model.call.start", (e) => agentEvents.push(e));
108
+ kernl.on("model.call.start", (e) => kernlEvents.push(e));
109
+ await agent.run("Hello");
110
+ // Both should receive the event
111
+ expect(agentEvents).toHaveLength(1);
112
+ expect(kernlEvents).toHaveLength(1);
113
+ expect(agentEvents[0].threadId).toBe(kernlEvents[0].threadId);
114
+ });
115
+ });
116
+ describe("Model events", () => {
117
+ it("emits model.call.start before generation", async () => {
118
+ const events = [];
119
+ const model = createMockModel(async () => ({
120
+ content: [message({ role: "assistant", text: "Done" })],
121
+ finishReason: "stop",
122
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
123
+ warnings: [],
124
+ }));
125
+ const agent = new Agent({
126
+ id: "test-agent",
127
+ name: "Test",
128
+ instructions: "Test",
129
+ model,
130
+ });
131
+ const kernl = new Kernl();
132
+ kernl.register(agent);
133
+ agent.on("model.call.start", (e) => events.push(e));
134
+ await agent.run("Hello");
135
+ expect(events).toHaveLength(1);
136
+ expect(events[0]).toMatchObject({
137
+ kind: "model.call.start",
138
+ provider: "test",
139
+ modelId: "test-model",
140
+ agentId: "test-agent",
141
+ });
142
+ expect(events[0].threadId).toBeDefined();
143
+ expect(events[0].context).toBeDefined();
144
+ expect(events[0].settings).toBeDefined();
145
+ });
146
+ it("emits model.call.end with usage and finishReason after generation", async () => {
147
+ const events = [];
148
+ const model = createMockModel(async () => ({
149
+ content: [message({ role: "assistant", text: "Done" })],
150
+ finishReason: "stop",
151
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
152
+ warnings: [],
153
+ }));
154
+ const agent = new Agent({
155
+ id: "test-agent",
156
+ name: "Test",
157
+ instructions: "Test",
158
+ model,
159
+ });
160
+ const kernl = new Kernl();
161
+ kernl.register(agent);
162
+ agent.on("model.call.end", (e) => events.push(e));
163
+ await agent.run("Hello");
164
+ expect(events).toHaveLength(1);
165
+ expect(events[0]).toMatchObject({
166
+ kind: "model.call.end",
167
+ provider: "test",
168
+ modelId: "test-model",
169
+ finishReason: "stop",
170
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
171
+ agentId: "test-agent",
172
+ });
173
+ });
174
+ it("emits model.call.end with finishReason=error on model error", async () => {
175
+ const events = [];
176
+ const model = createMockModel(async () => {
177
+ throw new Error("Model failed");
178
+ });
179
+ const agent = new Agent({
180
+ id: "test-agent",
181
+ name: "Test",
182
+ instructions: "Test",
183
+ model,
184
+ });
185
+ const kernl = new Kernl();
186
+ kernl.register(agent);
187
+ agent.on("model.call.end", (e) => events.push(e));
188
+ await expect(agent.run("Hello")).rejects.toThrow();
189
+ expect(events).toHaveLength(1);
190
+ expect(events[0]).toMatchObject({
191
+ kind: "model.call.end",
192
+ provider: "test",
193
+ modelId: "test-model",
194
+ finishReason: "error",
195
+ });
196
+ });
197
+ it("emits events for each model call in multi-turn execution", async () => {
198
+ const startEvents = [];
199
+ const endEvents = [];
200
+ let callCount = 0;
201
+ const model = createMockModel(async () => {
202
+ callCount++;
203
+ if (callCount === 1) {
204
+ return {
205
+ content: [
206
+ message({ role: "assistant", text: "" }),
207
+ {
208
+ kind: "tool-call",
209
+ toolId: "echo",
210
+ state: IN_PROGRESS,
211
+ callId: "call_1",
212
+ arguments: JSON.stringify({ text: "test" }),
213
+ },
214
+ ],
215
+ finishReason: "stop",
216
+ usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
217
+ warnings: [],
218
+ };
219
+ }
220
+ return {
221
+ content: [message({ role: "assistant", text: "Done" })],
222
+ finishReason: "stop",
223
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
224
+ warnings: [],
225
+ };
226
+ });
227
+ const echoTool = tool({
228
+ id: "echo",
229
+ description: "Echoes input",
230
+ parameters: z.object({ text: z.string() }),
231
+ execute: async (ctx, { text }) => `Echo: ${text}`,
232
+ });
233
+ const agent = new Agent({
234
+ id: "test-agent",
235
+ name: "Test",
236
+ instructions: "Test",
237
+ model,
238
+ toolkits: [new FunctionToolkit({ id: "tools", tools: [echoTool] })],
239
+ });
240
+ const kernl = new Kernl();
241
+ kernl.register(agent);
242
+ agent.on("model.call.start", (e) => startEvents.push(e));
243
+ agent.on("model.call.end", (e) => endEvents.push(e));
244
+ await agent.run("Hello");
245
+ // Should have 2 model calls (first returns tool call, second returns final response)
246
+ expect(startEvents).toHaveLength(2);
247
+ expect(endEvents).toHaveLength(2);
248
+ // Verify usage from each call
249
+ expect(endEvents[0].usage).toEqual({
250
+ inputTokens: 5,
251
+ outputTokens: 3,
252
+ totalTokens: 8,
253
+ });
254
+ expect(endEvents[1].usage).toEqual({
255
+ inputTokens: 10,
256
+ outputTokens: 5,
257
+ totalTokens: 15,
258
+ });
259
+ });
260
+ });
261
+ describe("Tool events", () => {
262
+ it("emits tool.call.start with args before execution", async () => {
263
+ const events = [];
264
+ let callCount = 0;
265
+ const model = createMockModel(async () => {
266
+ callCount++;
267
+ if (callCount === 1) {
268
+ return {
269
+ content: [
270
+ message({ role: "assistant", text: "" }),
271
+ {
272
+ kind: "tool-call",
273
+ toolId: "add",
274
+ state: IN_PROGRESS,
275
+ callId: "call_1",
276
+ arguments: JSON.stringify({ a: 5, b: 3 }),
277
+ },
278
+ ],
279
+ finishReason: "stop",
280
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
281
+ warnings: [],
282
+ };
283
+ }
284
+ return {
285
+ content: [message({ role: "assistant", text: "Done" })],
286
+ finishReason: "stop",
287
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
288
+ warnings: [],
289
+ };
290
+ });
291
+ const addTool = tool({
292
+ id: "add",
293
+ description: "Adds numbers",
294
+ parameters: z.object({ a: z.number(), b: z.number() }),
295
+ execute: async (ctx, { a, b }) => a + b,
296
+ });
297
+ const agent = new Agent({
298
+ id: "test-agent",
299
+ name: "Test",
300
+ instructions: "Test",
301
+ model,
302
+ toolkits: [new FunctionToolkit({ id: "tools", tools: [addTool] })],
303
+ });
304
+ const kernl = new Kernl();
305
+ kernl.register(agent);
306
+ agent.on("tool.call.start", (e) => events.push(e));
307
+ await agent.run("Add 5 and 3");
308
+ expect(events).toHaveLength(1);
309
+ expect(events[0]).toMatchObject({
310
+ kind: "tool.call.start",
311
+ toolId: "add",
312
+ callId: "call_1",
313
+ agentId: "test-agent",
314
+ args: { a: 5, b: 3 },
315
+ });
316
+ expect(events[0].threadId).toBeDefined();
317
+ expect(events[0].context).toBeDefined();
318
+ });
319
+ it("emits tool.call.end with result on success", async () => {
320
+ const events = [];
321
+ let callCount = 0;
322
+ const model = createMockModel(async () => {
323
+ callCount++;
324
+ if (callCount === 1) {
325
+ return {
326
+ content: [
327
+ message({ role: "assistant", text: "" }),
328
+ {
329
+ kind: "tool-call",
330
+ toolId: "add",
331
+ state: IN_PROGRESS,
332
+ callId: "call_1",
333
+ arguments: JSON.stringify({ a: 5, b: 3 }),
334
+ },
335
+ ],
336
+ finishReason: "stop",
337
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
338
+ warnings: [],
339
+ };
340
+ }
341
+ return {
342
+ content: [message({ role: "assistant", text: "Done" })],
343
+ finishReason: "stop",
344
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
345
+ warnings: [],
346
+ };
347
+ });
348
+ const addTool = tool({
349
+ id: "add",
350
+ description: "Adds numbers",
351
+ parameters: z.object({ a: z.number(), b: z.number() }),
352
+ execute: async (ctx, { a, b }) => a + b,
353
+ });
354
+ const agent = new Agent({
355
+ id: "test-agent",
356
+ name: "Test",
357
+ instructions: "Test",
358
+ model,
359
+ toolkits: [new FunctionToolkit({ id: "tools", tools: [addTool] })],
360
+ });
361
+ const kernl = new Kernl();
362
+ kernl.register(agent);
363
+ agent.on("tool.call.end", (e) => events.push(e));
364
+ await agent.run("Add 5 and 3");
365
+ expect(events).toHaveLength(1);
366
+ expect(events[0]).toMatchObject({
367
+ kind: "tool.call.end",
368
+ toolId: "add",
369
+ callId: "call_1",
370
+ agentId: "test-agent",
371
+ state: "completed",
372
+ result: "8",
373
+ });
374
+ });
375
+ it("emits tool.call.end with error on failure", async () => {
376
+ const events = [];
377
+ let callCount = 0;
378
+ const model = createMockModel(async () => {
379
+ callCount++;
380
+ if (callCount === 1) {
381
+ return {
382
+ content: [
383
+ message({ role: "assistant", text: "" }),
384
+ {
385
+ kind: "tool-call",
386
+ toolId: "failing",
387
+ state: IN_PROGRESS,
388
+ callId: "call_1",
389
+ arguments: "{}",
390
+ },
391
+ ],
392
+ finishReason: "stop",
393
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
394
+ warnings: [],
395
+ };
396
+ }
397
+ return {
398
+ content: [message({ role: "assistant", text: "Done" })],
399
+ finishReason: "stop",
400
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
401
+ warnings: [],
402
+ };
403
+ });
404
+ const failingTool = tool({
405
+ id: "failing",
406
+ description: "Tool that throws",
407
+ parameters: undefined,
408
+ execute: async () => {
409
+ throw new Error("Tool execution failed!");
410
+ },
411
+ });
412
+ const agent = new Agent({
413
+ id: "test-agent",
414
+ name: "Test",
415
+ instructions: "Test",
416
+ model,
417
+ toolkits: [new FunctionToolkit({ id: "tools", tools: [failingTool] })],
418
+ });
419
+ const kernl = new Kernl();
420
+ kernl.register(agent);
421
+ agent.on("tool.call.end", (e) => events.push(e));
422
+ await agent.run("Use failing tool");
423
+ expect(events).toHaveLength(1);
424
+ expect(events[0]).toMatchObject({
425
+ kind: "tool.call.end",
426
+ toolId: "failing",
427
+ callId: "call_1",
428
+ state: "failed",
429
+ });
430
+ expect(events[0].error).toContain("Tool execution failed!");
431
+ });
432
+ it("emits events for parallel tool calls", async () => {
433
+ const startEvents = [];
434
+ const endEvents = [];
435
+ let callCount = 0;
436
+ const model = createMockModel(async () => {
437
+ callCount++;
438
+ if (callCount === 1) {
439
+ return {
440
+ content: [
441
+ message({ role: "assistant", text: "" }),
442
+ {
443
+ kind: "tool-call",
444
+ toolId: "tool1",
445
+ state: IN_PROGRESS,
446
+ callId: "call_1",
447
+ arguments: JSON.stringify({ value: "a" }),
448
+ },
449
+ {
450
+ kind: "tool-call",
451
+ toolId: "tool2",
452
+ state: IN_PROGRESS,
453
+ callId: "call_2",
454
+ arguments: JSON.stringify({ value: "b" }),
455
+ },
456
+ ],
457
+ finishReason: "stop",
458
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
459
+ warnings: [],
460
+ };
461
+ }
462
+ return {
463
+ content: [message({ role: "assistant", text: "Done" })],
464
+ finishReason: "stop",
465
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
466
+ warnings: [],
467
+ };
468
+ });
469
+ const tool1 = tool({
470
+ id: "tool1",
471
+ description: "Tool 1",
472
+ parameters: z.object({ value: z.string() }),
473
+ execute: async (ctx, { value }) => `Tool1: ${value}`,
474
+ });
475
+ const tool2 = tool({
476
+ id: "tool2",
477
+ description: "Tool 2",
478
+ parameters: z.object({ value: z.string() }),
479
+ execute: async (ctx, { value }) => `Tool2: ${value}`,
480
+ });
481
+ const agent = new Agent({
482
+ id: "test-agent",
483
+ name: "Test",
484
+ instructions: "Test",
485
+ model,
486
+ toolkits: [new FunctionToolkit({ id: "tools", tools: [tool1, tool2] })],
487
+ });
488
+ const kernl = new Kernl();
489
+ kernl.register(agent);
490
+ agent.on("tool.call.start", (e) => startEvents.push(e));
491
+ agent.on("tool.call.end", (e) => endEvents.push(e));
492
+ await agent.run("Use both tools");
493
+ expect(startEvents).toHaveLength(2);
494
+ expect(endEvents).toHaveLength(2);
495
+ // Verify both tools were called
496
+ const toolIds = startEvents.map((e) => e.toolId);
497
+ expect(toolIds).toContain("tool1");
498
+ expect(toolIds).toContain("tool2");
499
+ // Verify both completed successfully
500
+ expect(endEvents.every((e) => e.state === "completed")).toBe(true);
501
+ });
502
+ });
503
+ describe("Streaming", () => {
504
+ it("emits same events for stream() as run()", async () => {
505
+ const runEvents = [];
506
+ const streamEvents = [];
507
+ const model = createMockModel(async () => ({
508
+ content: [message({ role: "assistant", text: "Done" })],
509
+ finishReason: "stop",
510
+ usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 },
511
+ warnings: [],
512
+ }));
513
+ // Run agent
514
+ const agent1 = new Agent({
515
+ id: "test-agent",
516
+ name: "Test",
517
+ instructions: "Test",
518
+ model,
519
+ });
520
+ const kernl1 = new Kernl();
521
+ kernl1.register(agent1);
522
+ kernl1.on("thread.start", () => runEvents.push("thread.start"));
523
+ kernl1.on("thread.stop", () => runEvents.push("thread.stop"));
524
+ agent1.on("model.call.start", () => runEvents.push("model.call.start"));
525
+ agent1.on("model.call.end", () => runEvents.push("model.call.end"));
526
+ await agent1.run("Hello");
527
+ // Stream agent
528
+ const agent2 = new Agent({
529
+ id: "test-agent",
530
+ name: "Test",
531
+ instructions: "Test",
532
+ model,
533
+ });
534
+ const kernl2 = new Kernl();
535
+ kernl2.register(agent2);
536
+ kernl2.on("thread.start", () => streamEvents.push("thread.start"));
537
+ kernl2.on("thread.stop", () => streamEvents.push("thread.stop"));
538
+ agent2.on("model.call.start", () => streamEvents.push("model.call.start"));
539
+ agent2.on("model.call.end", () => streamEvents.push("model.call.end"));
540
+ for await (const _ of agent2.stream("Hello")) {
541
+ // consume stream
542
+ }
543
+ // Both should emit the same lifecycle events in the same order
544
+ expect(streamEvents).toEqual(runEvents);
545
+ expect(runEvents).toEqual([
546
+ "thread.start",
547
+ "model.call.start",
548
+ "model.call.end",
549
+ "thread.stop",
550
+ ]);
551
+ });
552
+ });
553
+ });