@copilotkit/learning 0.0.1

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,609 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { installNetworkCapture } from "../network";
3
+ import type { BodySnapshot } from "../network-body";
4
+
5
+ interface Captured {
6
+ name: string;
7
+ value: {
8
+ transport: string;
9
+ method: string;
10
+ url: string;
11
+ route: string;
12
+ origin: string;
13
+ status: number | null;
14
+ outcome: string;
15
+ durationMs: number;
16
+ completedAt: number;
17
+ request: { headers: Record<string, string>; body: BodySnapshot };
18
+ response: {
19
+ url?: string;
20
+ headers: Record<string, string>;
21
+ body: BodySnapshot;
22
+ };
23
+ framework?: unknown;
24
+ };
25
+ }
26
+ const PAGE_ORIGIN = "http://localhost:3000";
27
+ const realFetch = globalThis.fetch;
28
+ let uninstall: (() => void) | undefined;
29
+ afterEach(() => {
30
+ uninstall?.();
31
+ uninstall = undefined;
32
+ vi.useRealTimers();
33
+ vi.unstubAllGlobals();
34
+ vi.restoreAllMocks();
35
+ globalThis.fetch = realFetch;
36
+ });
37
+ function setup(fakeFetch: typeof fetch, ignoreUrls: (string | RegExp)[] = []) {
38
+ globalThis.fetch = fakeFetch;
39
+ const events: Captured[] = [];
40
+ uninstall = installNetworkCapture({
41
+ emit: (name, value) => events.push({ name, value } as Captured),
42
+ routes: ["/api/deals/:id"],
43
+ ignoreUrls,
44
+ });
45
+ return events;
46
+ }
47
+ function respondWith(status = 200) {
48
+ return vi.fn<typeof fetch>(
49
+ async () =>
50
+ new Response("response text", {
51
+ status,
52
+ headers: { "content-type": "text/plain" },
53
+ }),
54
+ );
55
+ }
56
+ async function captured(events: Captured[], count = 1) {
57
+ await vi.waitFor(() => expect(events).toHaveLength(count));
58
+ return events[count - 1]!.value;
59
+ }
60
+ async function microtasks() {
61
+ for (let i = 0; i < 20; i++) await Promise.resolve();
62
+ }
63
+ function deferred<T>() {
64
+ let resolve!: (value: T) => void;
65
+ const promise = new Promise<T>((done) => {
66
+ resolve = done;
67
+ });
68
+ return { promise, resolve };
69
+ }
70
+
71
+ describe("fetch capture", () => {
72
+ it("captures full URL, headers and bodies while returning the identical Promise and Response", async () => {
73
+ const nativeResponse = new Response('{"saved":true}', {
74
+ status: 201,
75
+ headers: { "Content-Type": "application/json", "X-Result": "full-value" },
76
+ });
77
+ const originalPromise = Promise.resolve(nativeResponse);
78
+ const nativeFetch = vi.fn<typeof fetch>(() => originalPromise);
79
+ const events = setup(nativeFetch);
80
+ const init = {
81
+ method: "POST",
82
+ headers: {
83
+ Authorization: "Bearer visible",
84
+ "Content-Type": "application/json",
85
+ },
86
+ body: '{"email":"alice@example.com"}',
87
+ };
88
+ const promise = fetch("/api/deals/42?token=secret#fragment", init);
89
+ expect(promise).toBe(originalPromise);
90
+ expect(nativeFetch).toHaveBeenCalledWith(
91
+ "/api/deals/42?token=secret#fragment",
92
+ init,
93
+ );
94
+ const response = await promise;
95
+ expect(response).toBe(nativeResponse);
96
+ expect(response.bodyUsed).toBe(false);
97
+ expect(await response.text()).toBe('{"saved":true}');
98
+ const event = await captured(events);
99
+ expect(event).toMatchObject({
100
+ transport: "fetch",
101
+ method: "POST",
102
+ url: `${PAGE_ORIGIN}/api/deals/42?token=[redacted]#fragment`,
103
+ origin: PAGE_ORIGIN,
104
+ route: "/api/deals/42",
105
+ status: 201,
106
+ outcome: "ok",
107
+ request: {
108
+ headers: {
109
+ authorization: "[redacted]",
110
+ "content-type": "application/json",
111
+ },
112
+ body: { status: "complete", text: init.body, encoding: "utf-8" },
113
+ },
114
+ response: {
115
+ headers: {
116
+ "content-type": "application/json",
117
+ "x-result": "full-value",
118
+ },
119
+ body: { status: "complete", text: '{"saved":true}' },
120
+ },
121
+ });
122
+ expect(typeof event.durationMs).toBe("number");
123
+ });
124
+
125
+ it("clones a Request body without consuming it and honors init overrides", async () => {
126
+ const requests: string[] = [];
127
+ const events = setup(
128
+ vi.fn<typeof fetch>(async (input, init) => {
129
+ const request = input as Request;
130
+ requests.push(init?.body ? String(init.body) : await request.text());
131
+ return new Response("ok");
132
+ }),
133
+ );
134
+ const request = new Request("https://api.example.com/orders/9?full=1", {
135
+ method: "POST",
136
+ headers: { "X-Token": "full" },
137
+ body: "original body",
138
+ });
139
+ await fetch(request);
140
+ await fetch(
141
+ new Request("https://api.example.com/orders/10", {
142
+ method: "POST",
143
+ body: "ignored original",
144
+ }),
145
+ {
146
+ method: "PATCH",
147
+ body: "replacement",
148
+ headers: { "X-Override": "yes" },
149
+ },
150
+ );
151
+ await captured(events, 2);
152
+ expect(requests).toEqual(["original body", "replacement"]);
153
+ expect(events[0]!.value.request).toMatchObject({
154
+ headers: { "x-token": "[redacted]" },
155
+ body: { text: "original body" },
156
+ });
157
+ expect(events[1]!.value).toMatchObject({
158
+ method: "PATCH",
159
+ route: "/orders/10",
160
+ request: {
161
+ headers: { "x-override": "yes" },
162
+ body: { text: "replacement" },
163
+ },
164
+ });
165
+ });
166
+
167
+ it("captures framework requests normally without categorizing or hiding their headers/query", async () => {
168
+ const events = setup(respondWith());
169
+ await fetch("/learning/deals/1?_rsc=abc", {
170
+ headers: { RSC: "1", "Next-Action": "a1b2" },
171
+ });
172
+ const event = await captured(events);
173
+ expect(event.url).toBe(`${PAGE_ORIGIN}/learning/deals/1?_rsc=abc`);
174
+ expect(event.request.headers).toEqual({ "next-action": "a1b2", rsc: "1" });
175
+ expect(event).not.toHaveProperty("framework");
176
+ });
177
+
178
+ it("skips only configured URLs and handles global regexes consistently", async () => {
179
+ const native = respondWith();
180
+ const events = setup(native, ["/api/learning-events", /copilotkit/g]);
181
+ await fetch("/api/learning-events?full=1");
182
+ await fetch("https://runtime.example.com/copilotkit/run");
183
+ await fetch("https://runtime.example.com/copilotkit/run");
184
+ await microtasks();
185
+ expect(events).toEqual([]);
186
+ expect(native).toHaveBeenCalledTimes(3);
187
+ });
188
+
189
+ it("preserves exact errors, including a synchronous throw, while retaining request metadata", async () => {
190
+ const abort = new DOMException("stopped", "AbortError");
191
+ const offline = new TypeError("Failed to fetch");
192
+ const sync = new Error("sync");
193
+ const native = vi
194
+ .fn<typeof fetch>()
195
+ .mockRejectedValueOnce(abort)
196
+ .mockRejectedValueOnce(offline)
197
+ .mockImplementationOnce(() => {
198
+ throw sync;
199
+ });
200
+ const events = setup(native);
201
+ await expect(
202
+ fetch("/a", { method: "POST", body: "submitted" }),
203
+ ).rejects.toBe(abort);
204
+ await expect(fetch("/b")).rejects.toBe(offline);
205
+ expect(() => fetch("/c")).toThrow(sync);
206
+ await captured(events, 3);
207
+ expect(events.map(({ value }) => [value.outcome, value.status])).toEqual([
208
+ ["aborted", null],
209
+ ["error", null],
210
+ ["error", null],
211
+ ]);
212
+ expect(events[0]!.value.request.body.text).toBe("submitted");
213
+ expect(events[0]!.value.response.body).toEqual({
214
+ status: "unavailable",
215
+ reason: "no-response",
216
+ });
217
+ expect(native).toHaveBeenCalledTimes(3);
218
+ });
219
+
220
+ it("does not call an ignored synchronously throwing fetch twice", () => {
221
+ const error = new Error("native error");
222
+ const native = vi.fn<typeof fetch>(() => {
223
+ throw error;
224
+ });
225
+ setup(native, ["/ignored"]);
226
+ expect(() => fetch("/ignored")).toThrow(error);
227
+ expect(native).toHaveBeenCalledTimes(1);
228
+ });
229
+
230
+ it("bounds request/response fields in JSON bytes and keeps Unicode boundaries intact", async () => {
231
+ const text = '🙂\\\n"'.repeat(3000);
232
+ const events = setup(
233
+ vi.fn<typeof fetch>(
234
+ async () =>
235
+ new Response(text, { headers: { "content-type": "text/plain" } }),
236
+ ),
237
+ );
238
+ const response = await fetch("/large", { method: "POST", body: text });
239
+ expect(await response.text()).toBe(text);
240
+ const event = await captured(events);
241
+ for (const body of [event.request.body, event.response.body]) {
242
+ expect(body.status).toBe("truncated");
243
+ expect(
244
+ new TextEncoder().encode(JSON.stringify(body)).length,
245
+ ).toBeLessThanOrEqual(4096);
246
+ expect(body.text).not.toContain("�");
247
+ expect(text.startsWith(body.text!)).toBe(true);
248
+ }
249
+ });
250
+
251
+ it("captures binary body prefixes as base64 and does not alter caller bytes", async () => {
252
+ const bytes = new Uint8Array(6000).fill(255);
253
+ const events = setup(
254
+ vi.fn<typeof fetch>(
255
+ async () =>
256
+ new Response(bytes, {
257
+ headers: { "content-type": "application/octet-stream" },
258
+ }),
259
+ ),
260
+ );
261
+ const response = await fetch("/binary", { method: "POST", body: bytes });
262
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes);
263
+ const event = await captured(events);
264
+ for (const body of [event.request.body, event.response.body]) {
265
+ expect(body).toMatchObject({ status: "truncated", encoding: "base64" });
266
+ expect(
267
+ new TextEncoder().encode(JSON.stringify(body)).length,
268
+ ).toBeLessThanOrEqual(4096);
269
+ expect(atob(body.text!).charCodeAt(0)).toBe(255);
270
+ expect(body.text!.length % 4).toBe(0);
271
+ }
272
+ });
273
+
274
+ it("preserves NUL-containing text as base64 instead of sending invalid JSON strings", async () => {
275
+ const text = "before\0after";
276
+ const events = setup(vi.fn<typeof fetch>(async () => new Response(text)));
277
+ const response = await fetch("/nul", { method: "POST", body: text });
278
+ expect(await response.text()).toBe(text);
279
+ const event = await captured(events);
280
+ for (const body of [event.request.body, event.response.body]) {
281
+ expect(body).toMatchObject({ status: "complete", encoding: "base64" });
282
+ expect(atob(body.text!)).toBe(text);
283
+ }
284
+ });
285
+
286
+ it("times out observation of SSE without cancelling the caller's stream", async () => {
287
+ vi.useFakeTimers();
288
+ vi.setSystemTime(100_000);
289
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
290
+ const stream = new ReadableStream<Uint8Array>({
291
+ start(value) {
292
+ controller = value;
293
+ },
294
+ });
295
+ const events = setup(
296
+ vi.fn<typeof fetch>(
297
+ async () =>
298
+ new Response(stream, {
299
+ headers: { "content-type": "text/event-stream" },
300
+ }),
301
+ ),
302
+ );
303
+ const response = await fetch("/events");
304
+ const reader = response.body!.getReader();
305
+ controller.enqueue(new TextEncoder().encode("data: first\n\n"));
306
+ expect(new TextDecoder().decode((await reader.read()).value)).toBe(
307
+ "data: first\n\n",
308
+ );
309
+ await microtasks();
310
+ await vi.advanceTimersByTimeAsync(1000);
311
+ expect(events[0]!.value.completedAt).toBe(100_000);
312
+ expect(events[0]!.value.response.body).toMatchObject({
313
+ status: "timeout",
314
+ reason: "body-read-timeout",
315
+ text: "data: first\n\n",
316
+ });
317
+ controller.enqueue(new TextEncoder().encode("data: next\n\n"));
318
+ expect(new TextDecoder().decode((await reader.read()).value)).toBe(
319
+ "data: next\n\n",
320
+ );
321
+ controller.close();
322
+ expect((await reader.read()).done).toBe(true);
323
+ });
324
+
325
+ it("keeps metadata when a cloned response stream fails after a partial body", async () => {
326
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
327
+ const stream = new ReadableStream<Uint8Array>({
328
+ start(value) {
329
+ controller = value;
330
+ },
331
+ });
332
+ const events = setup(vi.fn<typeof fetch>(async () => new Response(stream)));
333
+ const response = await fetch("/interrupted");
334
+ const reader = response.body!.getReader();
335
+ controller.enqueue(new TextEncoder().encode("partial"));
336
+ await reader.read();
337
+ await microtasks();
338
+ const error = new Error("broken body");
339
+ controller.error(error);
340
+ await expect(reader.read()).rejects.toBe(error);
341
+ const event = await captured(events);
342
+ expect(event).toMatchObject({
343
+ status: 200,
344
+ outcome: "ok",
345
+ response: {
346
+ body: {
347
+ status: "interrupted",
348
+ reason: "body-read-failed",
349
+ text: "partial",
350
+ },
351
+ },
352
+ });
353
+ });
354
+
355
+ it("does not read or replace a caller-owned init.body stream", async () => {
356
+ const stream = new ReadableStream<Uint8Array>({
357
+ start(controller) {
358
+ controller.enqueue(new TextEncoder().encode("upload"));
359
+ controller.close();
360
+ },
361
+ });
362
+ const native = vi.fn<typeof fetch>(async (_input, init) => {
363
+ expect(init?.body).toBe(stream);
364
+ expect(stream.locked).toBe(false);
365
+ expect(await new Response(init?.body).text()).toBe("upload");
366
+ return new Response("ok");
367
+ });
368
+ const events = setup(native);
369
+ await fetch("/stream-upload", { method: "POST", body: stream });
370
+ const event = await captured(events);
371
+ expect(event.request.body).toEqual({
372
+ status: "unavailable",
373
+ reason: "caller-owned-stream",
374
+ });
375
+ });
376
+
377
+ it("reports clone failures without dropping headers or metadata", async () => {
378
+ const response = new Response("already consumed", {
379
+ headers: { "X-Visible": "value" },
380
+ });
381
+ await response.text();
382
+ const events = setup(vi.fn<typeof fetch>(async () => response));
383
+ expect(await fetch("/used")).toBe(response);
384
+ const event = await captured(events);
385
+ expect(event.response).toMatchObject({
386
+ headers: { "x-visible": "value" },
387
+ body: { status: "unavailable", reason: "response-clone-failed" },
388
+ });
389
+ });
390
+
391
+ it("does not leak a late completion across uninstall/reinstall and keeps third-party wrappers", async () => {
392
+ const late = deferred<Response>();
393
+ const native = vi
394
+ .fn<typeof fetch>()
395
+ .mockReturnValueOnce(late.promise)
396
+ .mockResolvedValue(new Response("current"));
397
+ const oldEvents = setup(native);
398
+ const oldPromise = fetch("/old");
399
+ const ours = globalThis.fetch;
400
+ const thirdParty = vi.fn<typeof fetch>((input, init) => ours(input, init));
401
+ globalThis.fetch = thirdParty;
402
+ uninstall?.();
403
+ expect(globalThis.fetch).toBe(thirdParty);
404
+ const newEvents = setup(thirdParty);
405
+ late.resolve(new Response("late"));
406
+ await oldPromise;
407
+ await fetch("/new");
408
+ await captured(newEvents);
409
+ expect(oldEvents).toEqual([]);
410
+ expect(newEvents.map((event) => event.value.route)).toEqual(["/new"]);
411
+ });
412
+
413
+ it("cancels only observation on stop while the app can still finish reading", async () => {
414
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
415
+ const stream = new ReadableStream<Uint8Array>({
416
+ start(value) {
417
+ controller = value;
418
+ },
419
+ });
420
+ const events = setup(vi.fn<typeof fetch>(async () => new Response(stream)));
421
+ const response = await fetch("/pending-body");
422
+ uninstall?.();
423
+ const text = response.text();
424
+ controller.enqueue(new TextEncoder().encode("after stop"));
425
+ controller.close();
426
+ expect(await text).toBe("after stop");
427
+ await microtasks();
428
+ expect(events).toEqual([]);
429
+ });
430
+ });
431
+
432
+ class FakeXhr extends EventTarget {
433
+ status = 200;
434
+ responseType: XMLHttpRequestResponseType = "text";
435
+ responseText = "response body";
436
+ response: unknown = null;
437
+ responseXML: Document | null = null;
438
+ responseURL = "https://api.example.com/result?query=full#fragment";
439
+ headers: Record<string, string> = {};
440
+ sentBody: unknown;
441
+ open(_method: string, _url: string | URL) {}
442
+ setRequestHeader(name: string, value: string) {
443
+ this.headers[name] = value;
444
+ }
445
+ getAllResponseHeaders() {
446
+ return "Content-Type: text/plain\r\nX-Full: response-token\r\n";
447
+ }
448
+ send(body?: unknown) {
449
+ this.sentBody = body;
450
+ }
451
+ finish() {
452
+ this.dispatchEvent(new Event("loadend"));
453
+ }
454
+ }
455
+
456
+ describe("XMLHttpRequest capture", () => {
457
+ it("records real XHR response text without changing the native caller", async () => {
458
+ const events = setup(respondWith());
459
+ const xhr = new XMLHttpRequest();
460
+ const done = new Promise((resolve) =>
461
+ xhr.addEventListener("loadend", resolve),
462
+ );
463
+ xhr.open("get", "data:text/plain,hello");
464
+ xhr.send();
465
+ await done;
466
+ expect(xhr.responseText).toBe("hello");
467
+ const event = await captured(events);
468
+ expect(event).toMatchObject({
469
+ transport: "xhr",
470
+ method: "GET",
471
+ status: 200,
472
+ outcome: "ok",
473
+ response: { body: { status: "complete", text: "hello" } },
474
+ });
475
+ });
476
+
477
+ it("captures full request/response headers, body and URLs", async () => {
478
+ vi.stubGlobal("XMLHttpRequest", FakeXhr);
479
+ const events = setup(respondWith());
480
+ const xhr = new FakeXhr();
481
+ xhr.open("post", "/api/deals/42?secret=full#hash");
482
+ xhr.setRequestHeader("Authorization", "Bearer full-value");
483
+ xhr.setRequestHeader("X-Multi", "one");
484
+ xhr.setRequestHeader("X-Multi", "two");
485
+ xhr.send("request body");
486
+ expect(xhr.sentBody).toBe("request body");
487
+ xhr.finish();
488
+ const event = await captured(events);
489
+ expect(event).toMatchObject({
490
+ url: `${PAGE_ORIGIN}/api/deals/42?secret=[redacted]#hash`,
491
+ route: "/api/deals/42",
492
+ request: {
493
+ headers: { authorization: "[redacted]", "x-multi": "one, two" },
494
+ body: { text: "request body" },
495
+ },
496
+ response: {
497
+ url: xhr.responseURL,
498
+ headers: { "x-full": "response-token" },
499
+ body: { text: "response body" },
500
+ },
501
+ });
502
+ });
503
+
504
+ it.each(["json", "arraybuffer"] as const)(
505
+ "captures browser-visible XHR %s responses",
506
+ async (responseType) => {
507
+ vi.stubGlobal("XMLHttpRequest", FakeXhr);
508
+ const events = setup(respondWith());
509
+ const xhr = new FakeXhr();
510
+ xhr.responseType = responseType;
511
+ // Declared text is decoded and redacted; only binary types stay base64.
512
+ xhr.getAllResponseHeaders = () =>
513
+ "Content-Type: application/octet-stream\r\n";
514
+ xhr.response =
515
+ responseType === "json"
516
+ ? { complete: true }
517
+ : new Uint8Array([0, 1, 255]).buffer;
518
+ xhr.open("get", "/typed-response");
519
+ xhr.send();
520
+ xhr.finish();
521
+ const event = await captured(events);
522
+ if (responseType === "json")
523
+ expect(event.response.body.text).toBe('{"complete":true}');
524
+ else {
525
+ expect(event.response.body.encoding).toBe("base64");
526
+ expect(
527
+ [...atob(event.response.body.text!)].map((char) =>
528
+ char.charCodeAt(0),
529
+ ),
530
+ ).toEqual([0, 1, 255]);
531
+ }
532
+ },
533
+ );
534
+
535
+ it("forgets an in-flight request when the same XHR is opened again", async () => {
536
+ vi.stubGlobal("XMLHttpRequest", FakeXhr);
537
+ const events = setup(respondWith());
538
+ const xhr = new FakeXhr();
539
+ xhr.open("get", "/first");
540
+ xhr.send();
541
+ // A real open() ends /first without firing loadend.
542
+ xhr.open("get", "/second");
543
+ xhr.send();
544
+ xhr.finish();
545
+ await captured(events);
546
+ await microtasks();
547
+
548
+ expect(events.map((event) => event.value.route)).toEqual(["/second"]);
549
+ });
550
+
551
+ it("redacts credential response headers but keeps the others", async () => {
552
+ vi.stubGlobal("XMLHttpRequest", FakeXhr);
553
+ const events = setup(respondWith());
554
+ const xhr = new FakeXhr();
555
+ xhr.getAllResponseHeaders = () =>
556
+ "Set-Cookie: session=abc\r\nX-Full: response-token\r\n";
557
+ xhr.open("get", "/session");
558
+ xhr.send();
559
+ xhr.finish();
560
+
561
+ expect(await captured(events)).toMatchObject({
562
+ response: {
563
+ headers: { "set-cookie": "[redacted]", "x-full": "response-token" },
564
+ },
565
+ });
566
+ });
567
+
568
+ it("records aborts, removes old listeners on reuse, and does not leak after stop", async () => {
569
+ vi.stubGlobal("XMLHttpRequest", FakeXhr);
570
+ const events = setup(respondWith());
571
+ const xhr = new FakeXhr();
572
+ xhr.open("get", "/first");
573
+ xhr.send();
574
+ xhr.finish();
575
+ await captured(events);
576
+ xhr.open("get", "/aborted");
577
+ xhr.send();
578
+ xhr.status = 0;
579
+ xhr.dispatchEvent(new Event("abort"));
580
+ xhr.finish();
581
+ await captured(events, 2);
582
+ expect(events[1]!.value).toMatchObject({
583
+ outcome: "aborted",
584
+ status: null,
585
+ response: { body: { status: "interrupted" } },
586
+ });
587
+ xhr.open("get", "/late");
588
+ xhr.send();
589
+ uninstall?.();
590
+ xhr.finish();
591
+ await microtasks();
592
+ expect(events).toHaveLength(2);
593
+ });
594
+
595
+ it("preserves synchronous XHR errors and records metadata", async () => {
596
+ class ThrowingXhr extends FakeXhr {
597
+ override send() {
598
+ throw error;
599
+ }
600
+ }
601
+ const error = new Error("invalid native state");
602
+ vi.stubGlobal("XMLHttpRequest", ThrowingXhr);
603
+ const events = setup(respondWith());
604
+ const xhr = new ThrowingXhr();
605
+ xhr.open("post", "/sync-failure");
606
+ expect(() => xhr.send()).toThrow(error);
607
+ expect((await captured(events)).outcome).toBe("error");
608
+ });
609
+ });