@kuralle-syrinx/cf-agents 4.1.0 → 4.3.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.
@@ -1,185 +0,0 @@
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
- });