@kuralle-syrinx/cf-agents 3.1.0 → 4.1.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.
- package/README.md +31 -3
- package/package.json +8 -8
- package/src/build-session.ts +56 -2
- package/src/durable-history.ts +79 -0
- package/src/index.ts +4 -0
- package/src/r2-recorder.test.ts +88 -3
- package/src/r2-recorder.ts +256 -76
- package/src/with-voice.test.ts +326 -5
- package/src/with-voice.ts +206 -6
package/README.md
CHANGED
|
@@ -61,15 +61,43 @@ export class SupportVoiceAgent extends withVoice<Env, typeof Agent<Env>>(Agent<E
|
|
|
61
61
|
}) {}
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
## The Responder-Thinker primitive
|
|
65
|
+
|
|
66
|
+
`withVoice` packages Syrinx's bi-model **Responder-Thinker** shape turnkey (RFC
|
|
67
|
+
`docs/rfc-bimodel-delegate-seam.md`): wire a realtime front + a `Reasoner` and the delegate seam
|
|
68
|
+
comes with —
|
|
69
|
+
|
|
70
|
+
- **Structured result envelope (G1, default).** The reasoner's answer reaches the front model as
|
|
71
|
+
`{ response_text, require_repeat_verbatim: true, render? }` so it repeats facts faithfully
|
|
72
|
+
instead of paraphrasing. Configure per pipeline: `toolResultFormat: "envelope" | "string"`,
|
|
73
|
+
`renderDirective: "translate_faithfully"`.
|
|
74
|
+
- **Delegate observability (G2).** `onDelegateQuery` / `onDelegateResult` hooks fire around every
|
|
75
|
+
reasoner run with the query, answer, `durationMs`, and `grounded` — log or persist the Q&A pair
|
|
76
|
+
without wrapping the `Reasoner`.
|
|
77
|
+
- **Typed "thinking" cues (G3).** Clients automatically receive `tool_call_started` /
|
|
78
|
+
`tool_call_delayed` (after `delayCueAfterMs`) / `tool_call_complete` / `tool_call_failed` wire
|
|
79
|
+
messages around the reasoner-latency window — key earcons/indicators on these instead of
|
|
80
|
+
inventing an app message (`@kuralle-syrinx/browser-client` parses them).
|
|
81
|
+
- **Durable session + resume (G4, default on).** The conversation persists to the Agent's
|
|
82
|
+
DO-SQLite and survives eviction/hibernation: cascaded pipelines re-seed the `ReasoningBridge`;
|
|
83
|
+
realtime pipelines feed the durable transcript to delegate turns and expose `ctx.resume` to the
|
|
84
|
+
`front()` factory — `resumeHistory: ctx.resume.history` on replay providers (OpenAI),
|
|
85
|
+
`sessionResumptionHandle: ctx.resume.providerHandle` on native-resume providers (Gemini; never
|
|
86
|
+
replay on top of a handle).
|
|
87
|
+
|
|
64
88
|
## Options
|
|
65
89
|
|
|
66
90
|
| Option | Description |
|
|
67
91
|
| --- | --- |
|
|
68
92
|
| `transport` | `"edge"` (default — Syrinx browser/edge protocol over `/ws`) or `"twilio"` (Twilio Media Streams, μ-law 8 kHz, for a PSTN leg). One transport per Agent class. |
|
|
69
|
-
| `pipeline` | `{ kind: "realtime", front, delegateToolName? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner?, sttForceFinalizeTimeoutMs? }`. |
|
|
70
|
-
| `reasoner` | `(env, { sessionId })
|
|
93
|
+
| `pipeline` | `{ kind: "realtime", front, delegateToolName?, toolResultFormat?, renderDirective? }` or `{ kind: "cascaded", stt, tts, vad?, eos?, endpointingOwner?, sttForceFinalizeTimeoutMs? }`. |
|
|
94
|
+
| `reasoner` | `(env, ctx) => Reasoner` (ctx: `{ sessionId, resume? }`). Defaults to `fromKuralleRuntime(this.runtime)` when the Agent exposes a kuralle `runtime`. Required for cascaded agents without one. |
|
|
71
95
|
| `recorder` | `(env, { sessionId }) => EdgeRecorder \| undefined` — optional per-call recorder (e.g. the R2 recorder at `@kuralle-syrinx/cf-agents/r2-recorder`). Edge transport. |
|
|
72
|
-
| `onToolCallStart` | `(ctx: { toolName, args, sessionId, connection }) => void \| Promise<void>` — fired the instant the front model invokes the delegate tool, **before** the reasoner runs
|
|
96
|
+
| `onToolCallStart` | `(ctx: { toolName, args, sessionId, connection }) => void \| Promise<void>` — fired the instant the front model invokes the delegate tool, **before** the reasoner runs — for app-specific cues beyond the standard `tool_call_*` wire messages. A throwing callback never affects the call. |
|
|
97
|
+
| `onDelegateQuery` / `onDelegateResult` | G2 observability hooks around the reasoner run. `onDelegateResult` is self-contained (`{ query, answer, durationMs, grounded, toolId?, toolName?, turnId, sessionId, connection }`) — the one hook for logging/persisting grounded Q&A pairs. Throwing never affects the call. |
|
|
98
|
+
| `durableHistory` | G4 durable session state over the Agent's SQLite (default `true`). Set `false` for ephemeral pre-G4 behavior. |
|
|
99
|
+
| `delayCueAfterMs` | G3: ms before a pending tool call fires the `tool_call_delayed` ("still working") cue. 0 disables. Default 2000. |
|
|
100
|
+
| `backgroundAudio` | `{ ambient?, thinking?, duckWhileSpeaking? }` — looped ambient bed + thinking loop (raw mono PCM16 sources), mixed (ducked) under assistant speech; on the `"twilio"` transport the bed also fills between-turn gaps as comfort noise. Thinking follows the G3 cues. |
|
|
73
101
|
| `inputSampleRateHz` / `outputSampleRateHz` | Edge audio rates (default 16000). |
|
|
74
102
|
| `resumeWindowMs` | How long a dropped connection can resume its session. |
|
|
75
103
|
| `sessionId` | `(request, agentName) => string`. Defaults to the `?sessionId=` query param (so a reconnecting client can resume), else a per-connection random id. (Not the Agent name — concurrent connections to one instance must not share a session.) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/cf-agents",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"./r2-recorder": "./src/r2-recorder.ts"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@kuralle-syrinx/core": "
|
|
16
|
-
"@kuralle-syrinx/
|
|
17
|
-
"@kuralle-syrinx/
|
|
18
|
-
"@kuralle-syrinx/
|
|
19
|
-
"@kuralle-syrinx/
|
|
20
|
-
"@kuralle-syrinx/
|
|
21
|
-
"@kuralle-syrinx/
|
|
15
|
+
"@kuralle-syrinx/core": "4.1.0",
|
|
16
|
+
"@kuralle-syrinx/aisdk": "4.1.0",
|
|
17
|
+
"@kuralle-syrinx/server-websocket": "4.1.0",
|
|
18
|
+
"@kuralle-syrinx/ws": "4.1.0",
|
|
19
|
+
"@kuralle-syrinx/recorder": "4.1.0",
|
|
20
|
+
"@kuralle-syrinx/realtime": "4.1.0",
|
|
21
|
+
"@kuralle-syrinx/kuralle": "4.1.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"agents": ">=0.14.0 <1.0.0"
|
package/src/build-session.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import {
|
|
4
4
|
VoiceAgentSession,
|
|
5
5
|
type Reasoner,
|
|
6
|
+
type ReasonerMessage,
|
|
7
|
+
type ReasonerSessionStore,
|
|
6
8
|
type VoicePlugin,
|
|
7
9
|
type PluginConfig,
|
|
8
10
|
} from "@kuralle-syrinx/core";
|
|
@@ -12,6 +14,28 @@ import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
|
|
|
12
14
|
/** Per-session context handed to every pipeline factory. */
|
|
13
15
|
export interface VoicePipelineContext {
|
|
14
16
|
readonly sessionId: string;
|
|
17
|
+
/**
|
|
18
|
+
* G4 resume state for the session, present when durable history is on. The
|
|
19
|
+
* `front()` factory wires it into the adapter: `resumeHistory: ctx.resume.history`
|
|
20
|
+
* on replay providers (OpenAI), `sessionResumptionHandle: ctx.resume.providerHandle`
|
|
21
|
+
* on native-resume providers (Gemini — do NOT also replay, R6).
|
|
22
|
+
*/
|
|
23
|
+
readonly resume?: {
|
|
24
|
+
/** Live view of the durable transcript (call again on reconnect for the current state). */
|
|
25
|
+
readonly history: () => readonly { readonly role: "user" | "assistant"; readonly content: string }[];
|
|
26
|
+
/** Latest provider-native resume handle, when one was issued. */
|
|
27
|
+
readonly providerHandle?: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Host wiring (withVoice) threaded into the assembled session. */
|
|
32
|
+
export interface VoiceSessionWiring {
|
|
33
|
+
/** G4: durable store for the cascaded ReasoningBridge's conversation history. */
|
|
34
|
+
readonly reasonerSessionStore?: ReasonerSessionStore;
|
|
35
|
+
/** G4: prior-context provider for realtime delegate turns (live view of durable history). */
|
|
36
|
+
readonly contextProvider?: () => readonly ReasonerMessage[];
|
|
37
|
+
/** G3: ms before a pending tool call fires its "delayed" (still-working) cue. */
|
|
38
|
+
readonly delayCueAfterMs?: number;
|
|
15
39
|
}
|
|
16
40
|
|
|
17
41
|
/**
|
|
@@ -26,6 +50,14 @@ export interface RealtimePipeline<Env> {
|
|
|
26
50
|
readonly front: (env: Env, ctx: VoicePipelineContext) => RealtimeAdapter;
|
|
27
51
|
/** Name of the front-model tool routed to the reasoner. @default "consult_knowledge" */
|
|
28
52
|
readonly delegateToolName?: string;
|
|
53
|
+
/**
|
|
54
|
+
* How the delegate answer reaches the front model (G1): `"envelope"` (default) wraps
|
|
55
|
+
* it as a `DelegateResultEnvelope` (`response_text` + `require_repeat_verbatim`);
|
|
56
|
+
* `"string"` injects the raw answer.
|
|
57
|
+
*/
|
|
58
|
+
readonly toolResultFormat?: "envelope" | "string";
|
|
59
|
+
/** Optional `render` directive included in the envelope, e.g. `"translate_faithfully"`. */
|
|
60
|
+
readonly renderDirective?: string;
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
/** A cascaded-stage plugin plus its `VoiceAgentSession` plugin config. */
|
|
@@ -57,6 +89,13 @@ export interface CascadedPipeline<Env> {
|
|
|
57
89
|
* (engine default 7000). Set it when a provider-endpointed cascade tunes this (e.g. Deepgram at 3500).
|
|
58
90
|
*/
|
|
59
91
|
readonly sttForceFinalizeTimeoutMs?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Speculative generation: start the reasoner on an eager end-of-turn signal
|
|
94
|
+
* (`eos.interim` — Deepgram Flux `eager_eot_threshold`, smart-turn interim) and
|
|
95
|
+
* commit/discard when the endpoint confirms. Trades extra LLM calls for
|
|
96
|
+
* parallelizing LLM TTFT with endpoint confirmation. @default false
|
|
97
|
+
*/
|
|
98
|
+
readonly speculative?: boolean;
|
|
60
99
|
}
|
|
61
100
|
|
|
62
101
|
export type VoicePipeline<Env> = RealtimePipeline<Env> | CascadedPipeline<Env>;
|
|
@@ -71,13 +110,19 @@ export function buildVoiceSession<Env>(
|
|
|
71
110
|
env: Env,
|
|
72
111
|
reasoner: Reasoner | undefined,
|
|
73
112
|
ctx: VoicePipelineContext,
|
|
113
|
+
wiring: VoiceSessionWiring = {},
|
|
74
114
|
): VoiceAgentSession {
|
|
75
115
|
if (pipeline.kind === "realtime") {
|
|
76
116
|
const front = pipeline.front(env, ctx);
|
|
77
|
-
const bridge = new RealtimeBridge(front, reasoner, pipeline.delegateToolName
|
|
117
|
+
const bridge = new RealtimeBridge(front, reasoner, pipeline.delegateToolName, {
|
|
118
|
+
...(pipeline.toolResultFormat !== undefined ? { toolResultFormat: pipeline.toolResultFormat } : {}),
|
|
119
|
+
...(pipeline.renderDirective !== undefined ? { renderDirective: pipeline.renderDirective } : {}),
|
|
120
|
+
...(wiring.contextProvider ? { contextProvider: wiring.contextProvider } : {}),
|
|
121
|
+
});
|
|
78
122
|
const session = new VoiceAgentSession({
|
|
79
123
|
plugins: { realtime: {} },
|
|
80
124
|
endpointingOwner: "timer",
|
|
125
|
+
...(wiring.delayCueAfterMs !== undefined ? { delayCueAfterMs: wiring.delayCueAfterMs } : {}),
|
|
81
126
|
});
|
|
82
127
|
session.registerPlugin("realtime", bridge);
|
|
83
128
|
return session;
|
|
@@ -116,9 +161,18 @@ export function buildVoiceSession<Env>(
|
|
|
116
161
|
...(pipeline.sttForceFinalizeTimeoutMs !== undefined
|
|
117
162
|
? { sttForceFinalizeTimeoutMs: pipeline.sttForceFinalizeTimeoutMs }
|
|
118
163
|
: {}),
|
|
164
|
+
...(wiring.delayCueAfterMs !== undefined ? { delayCueAfterMs: wiring.delayCueAfterMs } : {}),
|
|
119
165
|
});
|
|
120
166
|
session.registerPlugin("stt", stt.plugin);
|
|
121
|
-
session.registerPlugin(
|
|
167
|
+
session.registerPlugin(
|
|
168
|
+
"bridge",
|
|
169
|
+
new ReasoningBridge(reasoner, {
|
|
170
|
+
...(wiring.reasonerSessionStore
|
|
171
|
+
? { sessionStore: wiring.reasonerSessionStore, sessionId: ctx.sessionId }
|
|
172
|
+
: {}),
|
|
173
|
+
...(pipeline.speculative ? { speculative: true } : {}),
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
122
176
|
session.registerPlugin("tts", tts.plugin);
|
|
123
177
|
if (vad) session.registerPlugin("vad", vad.plugin);
|
|
124
178
|
if (eos) session.registerPlugin("eos", eos.plugin);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// G4 (RFC bimodel-delegate-seam) — durable reasoner session state over the
|
|
4
|
+
// Agent's DO-SQLite. Survives Durable Object eviction/hibernation, replacing
|
|
5
|
+
// consumer-side module-global memory stores (the SLIIT "F8" finding).
|
|
6
|
+
|
|
7
|
+
import type { ReasonerMessage, ReasonerSessionStore } from "@kuralle-syrinx/core";
|
|
8
|
+
|
|
9
|
+
/** The agents-SDK `Agent.sql` tagged-template surface (synchronous in the DO). */
|
|
10
|
+
export type SqlTag = <T = Record<string, unknown>>(
|
|
11
|
+
strings: TemplateStringsArray,
|
|
12
|
+
...values: (string | number | boolean | null)[]
|
|
13
|
+
) => T[];
|
|
14
|
+
|
|
15
|
+
interface HistoryRow {
|
|
16
|
+
role: string;
|
|
17
|
+
content: string;
|
|
18
|
+
tool_call_id: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface HandleRow {
|
|
22
|
+
handle: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `ReasonerSessionStore` backed by the Agent's SQLite. Also persists the latest
|
|
27
|
+
* provider-native resume handle (Gemini `sessionResumption`) per session.
|
|
28
|
+
*/
|
|
29
|
+
export class SqliteReasonerSessionStore implements ReasonerSessionStore {
|
|
30
|
+
constructor(private readonly sql: SqlTag) {
|
|
31
|
+
this.sql`CREATE TABLE IF NOT EXISTS syrinx_reasoner_history (
|
|
32
|
+
session_id TEXT NOT NULL,
|
|
33
|
+
seq INTEGER NOT NULL,
|
|
34
|
+
role TEXT NOT NULL,
|
|
35
|
+
content TEXT NOT NULL,
|
|
36
|
+
tool_call_id TEXT,
|
|
37
|
+
PRIMARY KEY (session_id, seq)
|
|
38
|
+
)`;
|
|
39
|
+
this.sql`CREATE TABLE IF NOT EXISTS syrinx_resume_handle (
|
|
40
|
+
session_id TEXT PRIMARY KEY,
|
|
41
|
+
handle TEXT NOT NULL
|
|
42
|
+
)`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
load(sessionId: string): readonly ReasonerMessage[] {
|
|
46
|
+
const rows = this.sql<HistoryRow>`
|
|
47
|
+
SELECT role, content, tool_call_id FROM syrinx_reasoner_history
|
|
48
|
+
WHERE session_id = ${sessionId} ORDER BY seq ASC`;
|
|
49
|
+
return rows.map((row) => ({
|
|
50
|
+
role: row.role as ReasonerMessage["role"],
|
|
51
|
+
content: row.content,
|
|
52
|
+
...(row.tool_call_id ? { toolCallId: row.tool_call_id } : {}),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
save(sessionId: string, messages: readonly ReasonerMessage[]): void {
|
|
57
|
+
this.sql`DELETE FROM syrinx_reasoner_history WHERE session_id = ${sessionId}`;
|
|
58
|
+
messages.forEach((message, seq) => {
|
|
59
|
+
this.sql`INSERT INTO syrinx_reasoner_history (session_id, seq, role, content, tool_call_id)
|
|
60
|
+
VALUES (${sessionId}, ${seq}, ${message.role}, ${message.content}, ${message.toolCallId ?? null})`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear(sessionId: string): void {
|
|
65
|
+
this.sql`DELETE FROM syrinx_reasoner_history WHERE session_id = ${sessionId}`;
|
|
66
|
+
this.sql`DELETE FROM syrinx_resume_handle WHERE session_id = ${sessionId}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
loadResumeHandle(sessionId: string): string | undefined {
|
|
70
|
+
const rows = this.sql<HandleRow>`
|
|
71
|
+
SELECT handle FROM syrinx_resume_handle WHERE session_id = ${sessionId}`;
|
|
72
|
+
return rows[0]?.handle;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
saveResumeHandle(sessionId: string, handle: string): void {
|
|
76
|
+
this.sql`DELETE FROM syrinx_resume_handle WHERE session_id = ${sessionId}`;
|
|
77
|
+
this.sql`INSERT INTO syrinx_resume_handle (session_id, handle) VALUES (${sessionId}, ${handle})`;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export {
|
|
|
8
8
|
type WithVoiceOptions,
|
|
9
9
|
type WithVoiceMembers,
|
|
10
10
|
type ToolCallStartContext,
|
|
11
|
+
type DelegateQueryContext,
|
|
12
|
+
type DelegateResultContext,
|
|
11
13
|
} from "./with-voice.js";
|
|
12
14
|
export type {
|
|
13
15
|
VoicePipeline,
|
|
@@ -15,6 +17,8 @@ export type {
|
|
|
15
17
|
CascadedPipeline,
|
|
16
18
|
CascadedStage,
|
|
17
19
|
VoicePipelineContext,
|
|
20
|
+
VoiceSessionWiring,
|
|
18
21
|
} from "./build-session.js";
|
|
22
|
+
export { SqliteReasonerSessionStore, type SqlTag } from "./durable-history.js";
|
|
19
23
|
export { connectionManagedSocket } from "./connection-socket.js";
|
|
20
24
|
export type { VoiceConnection, ConnectionSocketController } from "./connection-socket.js";
|
package/src/r2-recorder.test.ts
CHANGED
|
@@ -8,15 +8,54 @@ interface PutCall {
|
|
|
8
8
|
body: Uint8Array | string;
|
|
9
9
|
}
|
|
10
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
|
+
|
|
11
18
|
function fakeBucket() {
|
|
12
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
|
+
|
|
13
43
|
const bucket = {
|
|
14
44
|
async put(key: string, body: Uint8Array | string) {
|
|
15
|
-
puts.push({ key, body });
|
|
45
|
+
puts.push({ key, body: typeof body === "string" ? body : body.slice() });
|
|
16
46
|
return {} as unknown;
|
|
17
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
|
+
},
|
|
18
56
|
} as unknown as R2Bucket;
|
|
19
|
-
|
|
57
|
+
|
|
58
|
+
return { bucket, puts, mpus };
|
|
20
59
|
}
|
|
21
60
|
|
|
22
61
|
function asString(body: Uint8Array | string): string {
|
|
@@ -51,9 +90,10 @@ describe("R2EdgeRecorder", () => {
|
|
|
51
90
|
expect(wavChannels(puts.find((p) => p.key.endsWith("user.wav"))!.body)).toBe(1);
|
|
52
91
|
|
|
53
92
|
const manifest = JSON.parse(asString(puts.find((p) => p.key.endsWith("manifest.json"))!.body)) as {
|
|
54
|
-
conversation: { channels: number };
|
|
93
|
+
conversation: { channels: number; omitted: boolean };
|
|
55
94
|
};
|
|
56
95
|
expect(manifest.conversation.channels).toBe(2);
|
|
96
|
+
expect(manifest.conversation.omitted).toBe(false);
|
|
57
97
|
});
|
|
58
98
|
|
|
59
99
|
it("time-aligns the assistant after the user instead of stacking at 0", async () => {
|
|
@@ -97,4 +137,49 @@ describe("R2EdgeRecorder", () => {
|
|
|
97
137
|
expect(manifest.user.byteLength).toBe(800);
|
|
98
138
|
expect(manifest.user.truncated).toBe(true);
|
|
99
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
|
+
});
|
|
100
185
|
});
|