@kuralle-syrinx/cf-agents 4.4.1 → 4.5.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,114 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { connectionManagedSocket, type VoiceConnection } from "./connection-socket.js";
5
+
6
+ function fakeConnection(readyState = 1): VoiceConnection & { sent: Array<string | ArrayBuffer | ArrayBufferView>; closed: boolean } {
7
+ const sent: Array<string | ArrayBuffer | ArrayBufferView> = [];
8
+ let closed = false;
9
+ return {
10
+ id: "c1",
11
+ get readyState() {
12
+ return closed ? 3 : readyState;
13
+ },
14
+ send(data) {
15
+ sent.push(data);
16
+ },
17
+ close() {
18
+ closed = true;
19
+ },
20
+ sent,
21
+ get closed() {
22
+ return closed;
23
+ },
24
+ };
25
+ }
26
+
27
+ describe("connectionManagedSocket", () => {
28
+ it("forwards send() to the connection and reflects readyState via isOpen", () => {
29
+ const conn = fakeConnection();
30
+ const { socket } = connectionManagedSocket(conn);
31
+ expect(socket.isOpen).toBe(true);
32
+ socket.send("hello");
33
+ socket.send(new Uint8Array([1, 2, 3]));
34
+ expect(conn.sent).toEqual(["hello", new Uint8Array([1, 2, 3])]);
35
+ });
36
+
37
+ it("isOpen is false when the connection is not OPEN", () => {
38
+ const conn = fakeConnection(0);
39
+ const { socket } = connectionManagedSocket(conn);
40
+ expect(socket.isOpen).toBe(false);
41
+ });
42
+
43
+ it("dispose() closes the underlying connection", () => {
44
+ const conn = fakeConnection();
45
+ const { socket } = connectionManagedSocket(conn);
46
+ socket.dispose();
47
+ expect(conn.closed).toBe(true);
48
+ expect(socket.isOpen).toBe(false);
49
+ });
50
+
51
+ it("controller.message pumps string frames as text and ArrayBuffer as binary", () => {
52
+ const { socket, controller } = connectionManagedSocket(fakeConnection());
53
+ const received: Array<{ data: unknown; isBinary: boolean }> = [];
54
+ socket.onMessage((data, isBinary) => received.push({ data, isBinary }));
55
+
56
+ controller.message("a-json-frame");
57
+ const buf = new Uint8Array([9, 8, 7]).buffer;
58
+ controller.message(buf);
59
+
60
+ expect(received).toHaveLength(2);
61
+ expect(received[0]).toEqual({ data: "a-json-frame", isBinary: false });
62
+ expect(received[1]?.isBinary).toBe(true);
63
+ expect(received[1]?.data).toBeInstanceOf(Uint8Array);
64
+ expect(Array.from(received[1]?.data as Uint8Array)).toEqual([9, 8, 7]);
65
+ });
66
+
67
+ it("controller.close and controller.error fan out to registered handlers", () => {
68
+ const { socket, controller } = connectionManagedSocket(fakeConnection());
69
+ const onClose = vi.fn();
70
+ const onError = vi.fn();
71
+ socket.onClose(onClose);
72
+ socket.onError(onError);
73
+
74
+ controller.close(1011, "boom");
75
+ controller.error(new Error("network"));
76
+
77
+ expect(onClose).toHaveBeenCalledWith(1011, "boom");
78
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "network" }));
79
+ });
80
+
81
+ it("dispose() fires the close handlers (so edge teardown runs) and closes the connection", () => {
82
+ const conn = fakeConnection();
83
+ const { socket } = connectionManagedSocket(conn);
84
+ const onClose = vi.fn();
85
+ socket.onClose(onClose);
86
+
87
+ socket.dispose();
88
+
89
+ expect(onClose).toHaveBeenCalledTimes(1);
90
+ expect(conn.closed).toBe(true);
91
+ expect(socket.isOpen).toBe(false);
92
+ });
93
+
94
+ it("fires close at most once across dispose() and controller.close()", () => {
95
+ const { socket, controller } = connectionManagedSocket(fakeConnection());
96
+ const onClose = vi.fn();
97
+ socket.onClose(onClose);
98
+
99
+ socket.dispose();
100
+ controller.close(1000, "later");
101
+ controller.close(1000, "again");
102
+
103
+ expect(onClose).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ it("onOpen fires on the next microtask when already open", async () => {
107
+ const { socket } = connectionManagedSocket(fakeConnection());
108
+ const onOpen = vi.fn();
109
+ socket.onOpen(onOpen);
110
+ expect(onOpen).not.toHaveBeenCalled();
111
+ await Promise.resolve();
112
+ expect(onOpen).toHaveBeenCalledOnce();
113
+ });
114
+ });
@@ -0,0 +1,185 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { R2EdgeRecorder } from "./r2-recorder.js";
5
+
6
+ interface PutCall {
7
+ key: string;
8
+ body: Uint8Array | string;
9
+ }
10
+
11
+ interface MpuRec {
12
+ key: string;
13
+ parts: { partNumber: number; size: number }[];
14
+ first?: Uint8Array; // retained bytes of part 1 (the header-bearing part)
15
+ completed: boolean;
16
+ }
17
+
18
+ function fakeBucket() {
19
+ const puts: PutCall[] = [];
20
+ const mpus = new Map<string, MpuRec>();
21
+ let seq = 0;
22
+
23
+ function handle(uploadId: string) {
24
+ const rec = mpus.get(uploadId)!;
25
+ return {
26
+ key: rec.key,
27
+ uploadId,
28
+ async uploadPart(partNumber: number, value: Uint8Array) {
29
+ rec.parts.push({ partNumber, size: value.byteLength });
30
+ if (partNumber === 1) rec.first = value.slice();
31
+ return { partNumber, etag: `etag-${partNumber}` };
32
+ },
33
+ async complete(_parts: unknown) {
34
+ rec.completed = true;
35
+ return { key: rec.key } as unknown;
36
+ },
37
+ async abort() {
38
+ /* no-op */
39
+ },
40
+ };
41
+ }
42
+
43
+ const bucket = {
44
+ async put(key: string, body: Uint8Array | string) {
45
+ puts.push({ key, body: typeof body === "string" ? body : body.slice() });
46
+ return {} as unknown;
47
+ },
48
+ async createMultipartUpload(key: string) {
49
+ const uploadId = `u${++seq}`;
50
+ mpus.set(uploadId, { key, parts: [], completed: false });
51
+ return handle(uploadId);
52
+ },
53
+ resumeMultipartUpload(_key: string, uploadId: string) {
54
+ return handle(uploadId);
55
+ },
56
+ } as unknown as R2Bucket;
57
+
58
+ return { bucket, puts, mpus };
59
+ }
60
+
61
+ function asString(body: Uint8Array | string): string {
62
+ return typeof body === "string" ? body : new TextDecoder().decode(body);
63
+ }
64
+
65
+ function wavChannels(body: Uint8Array | string): number {
66
+ const wav = body as Uint8Array;
67
+ return new DataView(wav.buffer, wav.byteOffset, wav.byteLength).getUint16(22, true);
68
+ }
69
+
70
+ describe("R2EdgeRecorder", () => {
71
+ it("writes a stereo conversation.wav plus user/assistant stems and manifest", async () => {
72
+ const { bucket, puts } = fakeBucket();
73
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "s1", startedAtMs: 1000, now: () => 1000 });
74
+
75
+ rec.onUserAudio("c1", new Uint8Array(640), 16000);
76
+ rec.onAssistantAudio("c1", new Uint8Array(320), 24000);
77
+ await rec.finalize({ sessionId: "s1", closedAtMs: 2000 });
78
+
79
+ const keys = puts.map((p) => p.key).sort();
80
+ expect(keys).toEqual([
81
+ "recordings/s1/1000/assistant.wav",
82
+ "recordings/s1/1000/conversation.wav",
83
+ "recordings/s1/1000/manifest.json",
84
+ "recordings/s1/1000/user.wav",
85
+ ]);
86
+
87
+ const conversation = puts.find((p) => p.key.endsWith("conversation.wav"))!.body;
88
+ expect(asString((conversation as Uint8Array).subarray(0, 4))).toBe("RIFF");
89
+ expect(wavChannels(conversation)).toBe(2); // stereo: user L / assistant R
90
+ expect(wavChannels(puts.find((p) => p.key.endsWith("user.wav"))!.body)).toBe(1);
91
+
92
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
93
+ conversation: { channels: number; omitted: boolean };
94
+ };
95
+ expect(manifest.conversation.channels).toBe(2);
96
+ expect(manifest.conversation.omitted).toBe(false);
97
+ });
98
+
99
+ it("time-aligns the assistant after the user instead of stacking at 0", async () => {
100
+ const { bucket, puts } = fakeBucket();
101
+ let now = 0;
102
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "t", startedAtMs: 0, now: () => now });
103
+
104
+ now = 0;
105
+ rec.onUserAudio("c", new Uint8Array(640), 16000); // 20ms of user at t=0
106
+ now = 1000;
107
+ rec.onAssistantAudio("c", new Uint8Array(320), 16000); // assistant starts at t=1000ms
108
+ await rec.finalize({ sessionId: "t", closedAtMs: 1100 });
109
+
110
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
111
+ conversation: { durationMs: number };
112
+ assistant: { durationMs: number };
113
+ };
114
+ // Assistant anchored at ~1000ms, so both the assistant stem and the merged
115
+ // conversation run ~1010ms — not the ~20ms they'd be if stacked at offset 0.
116
+ expect(manifest.assistant.durationMs).toBe(1010);
117
+ expect(manifest.conversation.durationMs).toBe(1010);
118
+ });
119
+
120
+ it("does not write anything when no audio was captured", async () => {
121
+ const { bucket, puts } = fakeBucket();
122
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "empty", startedAtMs: 1 });
123
+ await rec.finalize({ sessionId: "empty", closedAtMs: 2 });
124
+ expect(puts).toHaveLength(0);
125
+ });
126
+
127
+ it("flags truncation past the per-stream cap instead of buffering unbounded", async () => {
128
+ const { bucket, puts } = fakeBucket();
129
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "big", startedAtMs: 1, maxBytesPerStream: 1000, now: () => 1 });
130
+ rec.onUserAudio("c", new Uint8Array(800), 16000);
131
+ rec.onUserAudio("c", new Uint8Array(800), 16000); // would exceed 1000 -> dropped
132
+ await rec.finalize({ sessionId: "big", closedAtMs: 2 });
133
+
134
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
135
+ user: { byteLength: number; truncated: boolean };
136
+ };
137
+ expect(manifest.user.byteLength).toBe(800);
138
+ expect(manifest.user.truncated).toBe(true);
139
+ });
140
+
141
+ it("streams a long, mostly-silent call to R2 in bounded parts instead of one giant buffer", async () => {
142
+ const { bucket, puts, mpus } = fakeBucket();
143
+ let now = 0;
144
+ const rec = new R2EdgeRecorder({ bucket, sessionId: "long", startedAtMs: 0, now: () => now });
145
+
146
+ // A 20-minute call: a blip of audio, a 20-minute wall-clock gap, then another blip.
147
+ // The old recorder would gap-fill the full ~38 MB wall-clock length in one allocation
148
+ // at finalize (OOMing the 128 MB DO). The stream must instead flush it as R2 parts.
149
+ now = 0;
150
+ rec.onUserAudio("c", new Uint8Array(320), 16000);
151
+ now = 20 * 60 * 1000; // +20 min
152
+ rec.onUserAudio("c", new Uint8Array(320), 16000);
153
+ await rec.finalize({ sessionId: "long", closedAtMs: now + 100 });
154
+
155
+ const expectedDataBytes = 20 * 60 * 16000 * 2 + 320; // wall-clock timeline + the final blip
156
+
157
+ // (1) The user stem streamed out incrementally: multiple parts, not one buffer.
158
+ const userMpu = [...mpus.values()].find((m) => m.key === "recordings/long/0/user.wav")!;
159
+ expect(userMpu).toBeDefined();
160
+ expect(userMpu.parts.length).toBeGreaterThan(1);
161
+ expect(userMpu.completed).toBe(true);
162
+ // Every part except the last carries at least R2's 5 MiB minimum (part 1 also holds the header).
163
+ const byNumber = [...userMpu.parts].sort((a, b) => a.partNumber - b.partNumber);
164
+ for (const p of byNumber.slice(0, -1)) expect(p.size).toBeGreaterThanOrEqual(5 * 1024 * 1024);
165
+
166
+ // (2) The stem decodes to the right duration: part 1's WAV header declares the full
167
+ // data length, and the parts sum to header(44) + that length.
168
+ const header = new DataView(userMpu.first!.buffer, userMpu.first!.byteOffset, userMpu.first!.byteLength);
169
+ expect(header.getUint32(40, true)).toBe(expectedDataBytes); // WAV data-chunk size field
170
+ const uploaded = userMpu.parts.reduce((n, p) => n + p.size, 0);
171
+ expect(uploaded).toBe(44 + expectedDataBytes);
172
+
173
+ // (3) finalize still writes the manifest; the stem length/duration match, and the
174
+ // stereo mix is flagged omitted (best-effort) once a stem has streamed out.
175
+ const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
176
+ user: { byteLength: number; durationMs: number; truncated: boolean };
177
+ conversation: { omitted: boolean };
178
+ };
179
+ expect(manifest.user.byteLength).toBe(expectedDataBytes);
180
+ expect(manifest.user.durationMs).toBe(Math.round((expectedDataBytes / 2 / 16000) * 1000));
181
+ expect(manifest.user.truncated).toBe(false);
182
+ expect(manifest.conversation.omitted).toBe(true);
183
+ expect(puts.some((p) => p.key.endsWith("conversation.wav"))).toBe(false);
184
+ });
185
+ });