@alexkroman1/aai 0.12.2 → 1.0.2
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/.turbo/turbo-build.log +20 -0
- package/CHANGELOG.md +174 -0
- package/dist/constants-VTFoymJ-.js +47 -0
- package/dist/host/_run-code.d.ts +4 -2
- package/dist/host/_runtime-conformance.d.ts +4 -5
- package/dist/host/builtin-tools.d.ts +11 -7
- package/dist/host/runtime-barrel.d.ts +15 -0
- package/dist/{direct-executor-ZUU0Ke4j.js → host/runtime-barrel.js} +463 -345
- package/dist/host/runtime-config.d.ts +42 -0
- package/dist/host/runtime.d.ts +119 -35
- package/dist/host/s2s.d.ts +14 -38
- package/dist/host/server.d.ts +16 -8
- package/dist/host/session-ctx.d.ts +55 -0
- package/dist/host/session.d.ts +21 -70
- package/dist/host/tool-executor.d.ts +20 -0
- package/dist/host/unstorage-kv.d.ts +1 -1
- package/dist/host/ws-handler.d.ts +4 -2
- package/dist/index.d.ts +9 -20
- package/dist/index.js +63 -2
- package/dist/{isolate → sdk}/_internal-types.d.ts +6 -10
- package/dist/{isolate → sdk}/constants.d.ts +6 -4
- package/dist/sdk/define.d.ts +66 -0
- package/dist/{isolate → sdk}/kv.d.ts +1 -49
- package/dist/sdk/manifest-barrel.d.ts +8 -0
- package/dist/sdk/manifest-barrel.js +52 -0
- package/dist/sdk/manifest.d.ts +50 -0
- package/dist/{isolate → sdk}/protocol.d.ts +59 -36
- package/dist/sdk/protocol.js +163 -0
- package/dist/{isolate → sdk}/system-prompt.d.ts +3 -2
- package/dist/sdk/types.d.ts +201 -0
- package/dist/sdk/ws-upgrade.d.ts +5 -0
- package/dist/{system-prompt-CVJSQJiA.js → system-prompt-nik_iavo.js} +11 -10
- package/dist/types-Cfx_4QDK.js +39 -0
- package/dist/ws-upgrade-BeOQ7fXL.js +30 -0
- package/exports-no-dev-deps.test.ts +62 -0
- package/host/_mock-ws.ts +185 -0
- package/host/_run-code.ts +217 -0
- package/host/_runtime-conformance.ts +143 -0
- package/host/_test-utils.ts +276 -0
- package/host/builtin-tools.test.ts +774 -0
- package/host/builtin-tools.ts +255 -0
- package/host/cleanup.test.ts +422 -0
- package/host/fixture-replay.test.ts +463 -0
- package/host/fixtures/README.md +40 -0
- package/host/fixtures/greeting-session-sequence.json +40 -0
- package/host/fixtures/reply-audio-samples.json +42 -0
- package/host/fixtures/reply-lifecycle.json +21 -0
- package/host/fixtures/session-ready.json +48 -0
- package/host/fixtures/session-updated.json +45 -0
- package/host/fixtures/simple-question-sequence.json +73 -0
- package/host/fixtures/tool-call-sequence.json +114 -0
- package/host/fixtures/tool-calls.json +11 -0
- package/host/fixtures/tool-config-session-sequence.json +51 -0
- package/host/fixtures/user-speech-recognition.json +30 -0
- package/host/fixtures/web-search-sequence.json +122 -0
- package/host/integration.test.ts +222 -0
- package/host/runtime-barrel.ts +25 -0
- package/host/runtime-config.test.ts +71 -0
- package/host/runtime-config.ts +99 -0
- package/host/runtime.test.ts +641 -0
- package/host/runtime.ts +308 -0
- package/host/s2s-fixtures.test.ts +237 -0
- package/host/s2s.test.ts +562 -0
- package/host/s2s.ts +310 -0
- package/host/server-shutdown.test.ts +76 -0
- package/host/server.test.ts +116 -0
- package/host/server.ts +223 -0
- package/host/session-ctx.ts +107 -0
- package/host/session-fixture-replay.test.ts +136 -0
- package/host/session-prompt.test.ts +77 -0
- package/host/session.test.ts +590 -0
- package/host/session.ts +370 -0
- package/host/tool-executor.test.ts +124 -0
- package/host/tool-executor.ts +80 -0
- package/host/unstorage-kv.test.ts +99 -0
- package/host/unstorage-kv.ts +69 -0
- package/host/ws-handler.test.ts +739 -0
- package/host/ws-handler.ts +255 -0
- package/index.ts +16 -0
- package/package.json +28 -72
- package/sdk/_internal-types.test.ts +34 -0
- package/sdk/_internal-types.ts +115 -0
- package/sdk/compat-fixtures/README.md +26 -0
- package/sdk/compat-fixtures/v1.json +68 -0
- package/sdk/constants.ts +77 -0
- package/sdk/define.test.ts +57 -0
- package/sdk/define.ts +88 -0
- package/sdk/kv.ts +60 -0
- package/sdk/manifest-barrel.ts +12 -0
- package/sdk/manifest.test.ts +56 -0
- package/sdk/manifest.ts +89 -0
- package/sdk/protocol-compat.test.ts +187 -0
- package/sdk/protocol-snapshot.test.ts +199 -0
- package/sdk/protocol.test.ts +170 -0
- package/sdk/protocol.ts +223 -0
- package/sdk/schema-alignment.test.ts +191 -0
- package/sdk/system-prompt.test.ts +111 -0
- package/sdk/system-prompt.ts +74 -0
- package/sdk/tsconfig.json +12 -0
- package/sdk/types-inference.test.ts +122 -0
- package/sdk/types.test.ts +14 -0
- package/sdk/types.ts +226 -0
- package/sdk/utils.test.ts +52 -0
- package/sdk/utils.ts +20 -0
- package/sdk/ws-upgrade.test.ts +48 -0
- package/sdk/ws-upgrade.ts +13 -0
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +26 -0
- package/vitest.config.ts +17 -0
- package/dist/host/_test-utils.d.ts +0 -73
- package/dist/host/direct-executor.d.ts +0 -128
- package/dist/host/index.d.ts +0 -18
- package/dist/host/index.js +0 -165
- package/dist/host/matchers.d.ts +0 -20
- package/dist/host/matchers.js +0 -41
- package/dist/host/server.js +0 -164
- package/dist/host/testing.d.ts +0 -294
- package/dist/host/testing.js +0 -2
- package/dist/host/vite-plugin.d.ts +0 -15
- package/dist/host/vite-plugin.js +0 -83
- package/dist/isolate/_kv-utils.d.ts +0 -10
- package/dist/isolate/_utils.js +0 -17
- package/dist/isolate/hooks.d.ts +0 -44
- package/dist/isolate/hooks.js +0 -58
- package/dist/isolate/index.d.ts +0 -18
- package/dist/isolate/index.js +0 -6
- package/dist/isolate/kv.js +0 -1
- package/dist/isolate/protocol.js +0 -2
- package/dist/isolate/types.d.ts +0 -418
- package/dist/isolate/types.js +0 -175
- package/dist/protocol-rcOrz7T3.js +0 -183
- package/dist/testing-Bb2B5Uob.js +0 -513
- package/dist/types.test-d.d.ts +0 -7
- /package/dist/{isolate/_utils.d.ts → sdk/utils.d.ts} +0 -0
package/host/runtime.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Copyright 2025 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* Agent runtime — the execution engine for voice agents.
|
|
4
|
+
*
|
|
5
|
+
* {@link createRuntime} builds the single execution engine used by both
|
|
6
|
+
* self-hosted servers and the platform sandbox. It wires up tool execution,
|
|
7
|
+
* lifecycle hooks, and session management.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import pTimeout from "p-timeout";
|
|
11
|
+
import { createStorage } from "unstorage";
|
|
12
|
+
import { agentToolsToSchemas, type ToolSchema, toAgentConfig } from "../sdk/_internal-types.ts";
|
|
13
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS } from "../sdk/constants.ts";
|
|
14
|
+
import type { Kv } from "../sdk/kv.ts";
|
|
15
|
+
import type { ClientSink } from "../sdk/protocol.ts";
|
|
16
|
+
import { buildReadyConfig, type ReadyConfig } from "../sdk/protocol.ts";
|
|
17
|
+
import type { AgentDef } from "../sdk/types.ts";
|
|
18
|
+
import { toolError } from "../sdk/utils.ts";
|
|
19
|
+
import { resolveAllBuiltins } from "./builtin-tools.ts";
|
|
20
|
+
import type { Logger, S2SConfig } from "./runtime-config.ts";
|
|
21
|
+
import { consoleLogger, DEFAULT_S2S_CONFIG } from "./runtime-config.ts";
|
|
22
|
+
import type { CreateS2sWebSocket } from "./s2s.ts";
|
|
23
|
+
import { createS2sSession, type Session } from "./session.ts";
|
|
24
|
+
import { type ExecuteTool, executeToolCall } from "./tool-executor.ts";
|
|
25
|
+
import { createUnstorageKv } from "./unstorage-kv.ts";
|
|
26
|
+
import { type SessionWebSocket, wireSessionSocket } from "./ws-handler.ts";
|
|
27
|
+
|
|
28
|
+
// ─── Runtime adapter (formerly adapter.ts) ──────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Per-session options passed to {@link AgentRuntime.startSession}. */
|
|
31
|
+
export type SessionStartOptions = {
|
|
32
|
+
skipGreeting?: boolean;
|
|
33
|
+
resumeFrom?: string;
|
|
34
|
+
logContext?: Record<string, string>;
|
|
35
|
+
onOpen?: () => void;
|
|
36
|
+
onClose?: () => void;
|
|
37
|
+
/** Called with session ID after session cleanup, for guest state cleanup. */
|
|
38
|
+
onSessionEnd?: (sessionId: string) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Common interface for agent runtimes.
|
|
43
|
+
*
|
|
44
|
+
* Implemented by {@link createRuntime} and the platform sandbox.
|
|
45
|
+
*/
|
|
46
|
+
export type AgentRuntime = {
|
|
47
|
+
startSession(ws: SessionWebSocket, opts?: SessionStartOptions): void;
|
|
48
|
+
shutdown(): Promise<void>;
|
|
49
|
+
readonly readyConfig: ReadyConfig;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ─── Runtime implementation ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Create an in-memory KV store (default for self-hosted). */
|
|
55
|
+
function createLocalKv(): Kv {
|
|
56
|
+
return createUnstorageKv({ storage: createStorage() });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Configuration for {@link createRuntime}.
|
|
61
|
+
*
|
|
62
|
+
* Configures the agent, environment, KV store, logging, and S2S connection.
|
|
63
|
+
*
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
export type RuntimeOptions = {
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: accepts any state type
|
|
68
|
+
agent: AgentDef<any>;
|
|
69
|
+
env: Record<string, string>;
|
|
70
|
+
kv?: Kv | undefined;
|
|
71
|
+
/** Custom WebSocket factory for the S2S connection (useful for testing). */
|
|
72
|
+
createWebSocket?: CreateS2sWebSocket | undefined;
|
|
73
|
+
logger?: Logger | undefined;
|
|
74
|
+
s2sConfig?: S2SConfig | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* Timeout in ms for `session.start()` (S2S connection setup).
|
|
77
|
+
* Defaults to 10 000 (10 s).
|
|
78
|
+
*/
|
|
79
|
+
sessionStartTimeoutMs?: number | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Maximum time in milliseconds to wait for sessions to stop during
|
|
82
|
+
* {@link AgentRuntime.shutdown | shutdown()}. Defaults to `30_000` (30 s).
|
|
83
|
+
*/
|
|
84
|
+
shutdownTimeoutMs?: number | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Override tool execution. When provided, `createRuntime` skips building
|
|
87
|
+
* in-process tool definitions and uses this function instead. Used by the
|
|
88
|
+
* platform sandbox to RPC tool calls to the isolate.
|
|
89
|
+
*/
|
|
90
|
+
executeTool?: ExecuteTool | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Override tool schemas sent to the S2S API. Required when `executeTool`
|
|
93
|
+
* is provided (the host doesn't have the tool definitions to derive schemas).
|
|
94
|
+
*/
|
|
95
|
+
toolSchemas?: ToolSchema[] | undefined;
|
|
96
|
+
/** System prompt guidance for builtin tools. Passed through in sandbox mode. */
|
|
97
|
+
toolGuidance?: string[] | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* Pre-resolved builtin tool definitions. When provided alongside `executeTool`
|
|
100
|
+
* and `toolSchemas`, skips calling `resolveAllBuiltins` on the host.
|
|
101
|
+
*/
|
|
102
|
+
builtinDefs?: Record<string, import("../sdk/types.ts").ToolDef> | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Override the fetch implementation used by built-in tools (web_search,
|
|
105
|
+
* visit_webpage, fetch_json). Defaults to `globalThis.fetch`.
|
|
106
|
+
*
|
|
107
|
+
* In platform mode, pass an SSRF-safe fetch to prevent requests to
|
|
108
|
+
* private/internal networks. In self-hosted mode, users may provide
|
|
109
|
+
* their own fetch wrapper.
|
|
110
|
+
*/
|
|
111
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The agent runtime returned by {@link createRuntime}.
|
|
116
|
+
*
|
|
117
|
+
* Satisfies {@link AgentRuntime} for use by transport code, and also exposes
|
|
118
|
+
* lower-level helpers (`executeTool`, `hooks`, `toolSchemas`,
|
|
119
|
+
* `createSession`) for testing and advanced usage.
|
|
120
|
+
*
|
|
121
|
+
* @public
|
|
122
|
+
*/
|
|
123
|
+
export type Runtime = AgentRuntime & {
|
|
124
|
+
/** Execute a named tool with the given args, returning a JSON result string. */
|
|
125
|
+
executeTool: ExecuteTool;
|
|
126
|
+
/** Tool schemas registered with the S2S API (custom + built-in). */
|
|
127
|
+
toolSchemas: ToolSchema[];
|
|
128
|
+
/** Create a new voice session for a connected client (lower-level than startSession). */
|
|
129
|
+
createSession(opts: {
|
|
130
|
+
id: string;
|
|
131
|
+
agent: string;
|
|
132
|
+
client: ClientSink;
|
|
133
|
+
skipGreeting?: boolean;
|
|
134
|
+
resumeFrom?: string;
|
|
135
|
+
}): Session;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create an agent runtime — the execution engine for a voice agent.
|
|
140
|
+
*
|
|
141
|
+
* Merges built-in and custom tool definitions, builds tool schemas for the
|
|
142
|
+
* S2S API, and wires up lifecycle hooks.
|
|
143
|
+
*
|
|
144
|
+
* @param opts - Runtime configuration. See {@link RuntimeOptions}.
|
|
145
|
+
* @returns A {@link Runtime} with tool execution, hook invocation,
|
|
146
|
+
* schemas, and session management.
|
|
147
|
+
*
|
|
148
|
+
* @public
|
|
149
|
+
*/
|
|
150
|
+
export function createRuntime(opts: RuntimeOptions): Runtime {
|
|
151
|
+
const {
|
|
152
|
+
agent,
|
|
153
|
+
env,
|
|
154
|
+
kv = createLocalKv(),
|
|
155
|
+
createWebSocket,
|
|
156
|
+
logger = consoleLogger,
|
|
157
|
+
s2sConfig = DEFAULT_S2S_CONFIG,
|
|
158
|
+
sessionStartTimeoutMs,
|
|
159
|
+
shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS,
|
|
160
|
+
} = opts;
|
|
161
|
+
const agentConfig = toAgentConfig(agent);
|
|
162
|
+
const sessions = new Map<string, Session>();
|
|
163
|
+
const readyConfig: ReadyConfig = buildReadyConfig(s2sConfig);
|
|
164
|
+
|
|
165
|
+
// When overrides are provided (sandbox mode), skip in-process tool setup
|
|
166
|
+
let executeTool: ExecuteTool;
|
|
167
|
+
let toolSchemas: ToolSchema[];
|
|
168
|
+
let toolGuidance: string[] = [];
|
|
169
|
+
|
|
170
|
+
const builtinFetchOpt = opts.fetch ? { fetch: opts.fetch } : undefined;
|
|
171
|
+
|
|
172
|
+
if (opts.executeTool && opts.toolSchemas) {
|
|
173
|
+
// Sandbox mode — custom tools are RPC-backed; builtins run host-side
|
|
174
|
+
const builtinDefs =
|
|
175
|
+
opts.builtinDefs ?? resolveAllBuiltins(agent.builtinTools ?? [], builtinFetchOpt).defs;
|
|
176
|
+
const rpcExecuteTool = opts.executeTool;
|
|
177
|
+
const frozenEnv = Object.freeze({ ...env });
|
|
178
|
+
|
|
179
|
+
executeTool = async (name, args, sessionId, messages) => {
|
|
180
|
+
// Handle builtins on the host (where SSRF-safe fetch lives)
|
|
181
|
+
if (builtinDefs[name]) {
|
|
182
|
+
const tool = builtinDefs[name];
|
|
183
|
+
return executeToolCall(name, args, {
|
|
184
|
+
tool,
|
|
185
|
+
env: frozenEnv,
|
|
186
|
+
sessionId: sessionId ?? "",
|
|
187
|
+
kv,
|
|
188
|
+
messages,
|
|
189
|
+
logger,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// Delegate custom tools to the isolate via RPC
|
|
193
|
+
return rpcExecuteTool(name, args, sessionId, messages);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
toolSchemas = opts.toolSchemas;
|
|
197
|
+
toolGuidance = opts.toolGuidance ?? [];
|
|
198
|
+
} else {
|
|
199
|
+
// Self-hosted mode — in-process tool execution
|
|
200
|
+
const builtins = resolveAllBuiltins(agent.builtinTools ?? [], builtinFetchOpt);
|
|
201
|
+
const allTools: Record<string, AgentDef["tools"][string]> = {
|
|
202
|
+
...builtins.defs,
|
|
203
|
+
...agent.tools,
|
|
204
|
+
};
|
|
205
|
+
const customSchemas = agentToolsToSchemas(agent.tools ?? {});
|
|
206
|
+
toolSchemas = [...customSchemas, ...builtins.schemas];
|
|
207
|
+
toolGuidance = builtins.guidance;
|
|
208
|
+
|
|
209
|
+
const stateMap = new Map<string, Record<string, unknown>>();
|
|
210
|
+
const getState = (sid: string) => {
|
|
211
|
+
if (!stateMap.has(sid) && agent.state) stateMap.set(sid, agent.state());
|
|
212
|
+
return stateMap.get(sid) ?? {};
|
|
213
|
+
};
|
|
214
|
+
const frozenEnv = Object.freeze({ ...env });
|
|
215
|
+
|
|
216
|
+
executeTool = async (name, args, sessionId, messages) => {
|
|
217
|
+
const tool = allTools[name];
|
|
218
|
+
if (!tool) return toolError(`Unknown tool: ${name}`);
|
|
219
|
+
return executeToolCall(name, args, {
|
|
220
|
+
tool,
|
|
221
|
+
env: frozenEnv,
|
|
222
|
+
state: getState(sessionId ?? ""),
|
|
223
|
+
sessionId: sessionId ?? "",
|
|
224
|
+
kv,
|
|
225
|
+
messages,
|
|
226
|
+
logger,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function createSession(sessionOpts: {
|
|
232
|
+
id: string;
|
|
233
|
+
agent: string;
|
|
234
|
+
client: ClientSink;
|
|
235
|
+
skipGreeting?: boolean;
|
|
236
|
+
resumeFrom?: string;
|
|
237
|
+
}): Session {
|
|
238
|
+
const apiKey = env.ASSEMBLYAI_API_KEY ?? "";
|
|
239
|
+
return createS2sSession({
|
|
240
|
+
id: sessionOpts.id,
|
|
241
|
+
agent: sessionOpts.agent,
|
|
242
|
+
client: sessionOpts.client,
|
|
243
|
+
agentConfig,
|
|
244
|
+
toolSchemas,
|
|
245
|
+
toolGuidance,
|
|
246
|
+
apiKey,
|
|
247
|
+
s2sConfig,
|
|
248
|
+
executeTool,
|
|
249
|
+
...(createWebSocket ? { createWebSocket } : {}),
|
|
250
|
+
skipGreeting: sessionOpts.skipGreeting ?? false,
|
|
251
|
+
logger,
|
|
252
|
+
...(sessionOpts.resumeFrom ? { resumeFrom: sessionOpts.resumeFrom } : {}),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── AgentRuntime methods ──────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function startSession(ws: SessionWebSocket, startOpts?: SessionStartOptions): void {
|
|
259
|
+
const resumeFrom = startOpts?.resumeFrom;
|
|
260
|
+
wireSessionSocket(ws, {
|
|
261
|
+
sessions,
|
|
262
|
+
createSession: (sid, client) =>
|
|
263
|
+
createSession({
|
|
264
|
+
id: sid,
|
|
265
|
+
agent: agent.name,
|
|
266
|
+
client,
|
|
267
|
+
skipGreeting: startOpts?.skipGreeting ?? false,
|
|
268
|
+
...(resumeFrom ? { resumeFrom } : {}),
|
|
269
|
+
}),
|
|
270
|
+
readyConfig,
|
|
271
|
+
logger,
|
|
272
|
+
...(startOpts?.logContext ? { logContext: startOpts.logContext } : {}),
|
|
273
|
+
...(startOpts?.onOpen ? { onOpen: startOpts.onOpen } : {}),
|
|
274
|
+
...(startOpts?.onClose ? { onClose: startOpts.onClose } : {}),
|
|
275
|
+
...(startOpts?.onSessionEnd ? { onSessionEnd: startOpts.onSessionEnd } : {}),
|
|
276
|
+
...(sessionStartTimeoutMs !== undefined ? { sessionStartTimeoutMs } : {}),
|
|
277
|
+
...(resumeFrom ? { resumeFrom } : {}),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function shutdown(): Promise<void> {
|
|
282
|
+
if (sessions.size === 0) return;
|
|
283
|
+
try {
|
|
284
|
+
const results = await pTimeout(
|
|
285
|
+
Promise.allSettled([...sessions.values()].map((s) => s.stop())),
|
|
286
|
+
{ milliseconds: shutdownTimeoutMs },
|
|
287
|
+
);
|
|
288
|
+
for (const r of results) {
|
|
289
|
+
if (r.status === "rejected")
|
|
290
|
+
logger.warn(`Session stop failed during shutdown: ${r.reason}`);
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
logger.warn(
|
|
294
|
+
`Shutdown timeout (${shutdownTimeoutMs}ms) exceeded — force-closing ${sessions.size} remaining session(s)`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
sessions.clear();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
executeTool,
|
|
302
|
+
toolSchemas,
|
|
303
|
+
createSession,
|
|
304
|
+
startSession,
|
|
305
|
+
shutdown,
|
|
306
|
+
readyConfig,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { loadFixture, silentLogger } from "./_test-utils.ts";
|
|
3
|
+
import type { S2sWebSocket } from "./s2s.ts";
|
|
4
|
+
import { connectS2s } from "./s2s.ts";
|
|
5
|
+
|
|
6
|
+
/** EventTarget-based WebSocket stub (standard API, no `.on()` adapter needed). */
|
|
7
|
+
function createWebSocketStub() {
|
|
8
|
+
const target = new EventTarget();
|
|
9
|
+
return Object.assign(target, {
|
|
10
|
+
readyState: 0,
|
|
11
|
+
send: vi.fn(),
|
|
12
|
+
close: vi.fn(),
|
|
13
|
+
addEventListener: target.addEventListener.bind(target) as S2sWebSocket["addEventListener"],
|
|
14
|
+
/** Simulate a server-side event for testing. */
|
|
15
|
+
emit(event: string, ...args: unknown[]) {
|
|
16
|
+
const builders: Record<string, () => Event> = {
|
|
17
|
+
open: () => new Event("open"),
|
|
18
|
+
message: () => new MessageEvent("message", { data: args[0] }),
|
|
19
|
+
close: () => {
|
|
20
|
+
const ev = new Event("close");
|
|
21
|
+
if (typeof args[0] === "number") Object.assign(ev, { code: args[0] });
|
|
22
|
+
if (typeof args[1] === "string") Object.assign(ev, { reason: args[1] });
|
|
23
|
+
return ev;
|
|
24
|
+
},
|
|
25
|
+
error: () => {
|
|
26
|
+
const msg = args[0] instanceof Error ? args[0].message : String(args[0]);
|
|
27
|
+
const ev = new Event("error");
|
|
28
|
+
Object.defineProperty(ev, "message", { value: msg });
|
|
29
|
+
return ev;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const build = builders[event];
|
|
33
|
+
if (build) target.dispatchEvent(build());
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const s2sConfig = { wssUrl: "wss://fake", inputSampleRate: 16_000, outputSampleRate: 16_000 };
|
|
39
|
+
|
|
40
|
+
function createTestS2s() {
|
|
41
|
+
const raw = createWebSocketStub();
|
|
42
|
+
const createWebSocket = () => {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
raw.readyState = 1;
|
|
45
|
+
raw.emit("open");
|
|
46
|
+
}, 0);
|
|
47
|
+
return raw;
|
|
48
|
+
};
|
|
49
|
+
return { raw, createWebSocket, logger: { ...silentLogger } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function setupHandle() {
|
|
53
|
+
const { raw, createWebSocket, logger } = createTestS2s();
|
|
54
|
+
const handle = await connectS2s({
|
|
55
|
+
apiKey: "test-key",
|
|
56
|
+
config: s2sConfig,
|
|
57
|
+
createWebSocket,
|
|
58
|
+
logger,
|
|
59
|
+
});
|
|
60
|
+
return { raw, handle, logger };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Fixture-based tests (real API responses from Kokoro TTS audio) ─────
|
|
64
|
+
|
|
65
|
+
describe("real API fixtures", () => {
|
|
66
|
+
/**
|
|
67
|
+
* Replay all fixture messages through the S2S handle and collect events.
|
|
68
|
+
* Events are collected from both the 'ready'/'replyStarted'/'sessionExpired' special
|
|
69
|
+
* events and the unified 'event' emitter, tagged with their source type.
|
|
70
|
+
*/
|
|
71
|
+
async function replayFixture(fixtureName: string) {
|
|
72
|
+
const { raw, handle } = await setupHandle();
|
|
73
|
+
const events: { type: string; payload: unknown }[] = [];
|
|
74
|
+
|
|
75
|
+
// Special events that are NOT in the 'event' emitter
|
|
76
|
+
handle.on("ready", (p) => events.push({ type: "ready", payload: p }));
|
|
77
|
+
handle.on("replyStarted", (p) => events.push({ type: "replyStarted", payload: p }));
|
|
78
|
+
handle.on("sessionExpired", () => events.push({ type: "sessionExpired", payload: undefined }));
|
|
79
|
+
handle.on("audio", (p) => events.push({ type: "audio", payload: p }));
|
|
80
|
+
handle.on("error", (p) => events.push({ type: "error", payload: p }));
|
|
81
|
+
|
|
82
|
+
// All protocol-shaped events via the unified 'event' emitter
|
|
83
|
+
handle.on("event", (event) => events.push({ type: event.type, payload: event }));
|
|
84
|
+
|
|
85
|
+
const fixtures = loadFixture<Record<string, unknown>[]>(fixtureName);
|
|
86
|
+
for (const msg of fixtures) {
|
|
87
|
+
raw.emit("message", Buffer.from(JSON.stringify(msg)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { events, fixtures, raw, handle };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Session lifecycle ──────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
test("parses real session.ready messages with extra fields (timestamp, config)", async () => {
|
|
96
|
+
const { events } = await replayFixture("session-ready.json");
|
|
97
|
+
|
|
98
|
+
const readyEvents = events.filter((e) => e.type === "ready");
|
|
99
|
+
expect(readyEvents.length).toBeGreaterThan(0);
|
|
100
|
+
expect((readyEvents[0]?.payload as { sessionId: string }).sessionId).toMatch(/^sess_/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parses real session.updated messages — they are now silently dropped", async () => {
|
|
104
|
+
const { events } = await replayFixture("session-updated.json");
|
|
105
|
+
|
|
106
|
+
// session.updated is no longer dispatched — it is dropped in s2s.ts
|
|
107
|
+
const updatedEvents = events.filter((e) => e.type === "session.updated");
|
|
108
|
+
expect(updatedEvents.length).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Greeting session ───────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
test("greeting session produces correct event sequence", async () => {
|
|
114
|
+
const { events } = await replayFixture("greeting-session-sequence.json");
|
|
115
|
+
|
|
116
|
+
const types = events.map((e) => e.type);
|
|
117
|
+
// session.updated is dropped, so first non-audio event is 'ready'
|
|
118
|
+
expect(types[0]).toBe("ready");
|
|
119
|
+
expect(types[1]).toBe("replyStarted");
|
|
120
|
+
expect(types.filter((t) => t === "agent_transcript").length).toBeGreaterThan(0);
|
|
121
|
+
expect(types).toContain("agent_transcript");
|
|
122
|
+
expect(types.at(-1)).toBe("reply_done");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Reply lifecycle ────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
test("real transcript.agent has _interrupted field", async () => {
|
|
128
|
+
const { events } = await replayFixture("reply-lifecycle.json");
|
|
129
|
+
|
|
130
|
+
const transcripts = events.filter((e) => e.type === "agent_transcript");
|
|
131
|
+
expect(transcripts.length).toBe(1);
|
|
132
|
+
const payload = transcripts[0]?.payload as {
|
|
133
|
+
type: string;
|
|
134
|
+
text: string;
|
|
135
|
+
_interrupted: boolean;
|
|
136
|
+
};
|
|
137
|
+
expect(payload._interrupted).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Audio ──────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
test("real reply.audio messages decode to Uint8Array", async () => {
|
|
143
|
+
const { events } = await replayFixture("reply-audio-samples.json");
|
|
144
|
+
|
|
145
|
+
const audioEvents = events.filter((e) => e.type === "audio");
|
|
146
|
+
expect(audioEvents.length).toBeGreaterThan(0);
|
|
147
|
+
for (const e of audioEvents) {
|
|
148
|
+
expect((e.payload as { audio: Uint8Array }).audio).toBeInstanceOf(Uint8Array);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── User speech recognition (from Kokoro TTS audio) ────────────────────
|
|
153
|
+
|
|
154
|
+
test("user speech events from real STT (Kokoro-generated audio)", async () => {
|
|
155
|
+
const { events } = await replayFixture("user-speech-recognition.json");
|
|
156
|
+
|
|
157
|
+
const types = events.map((e) => e.type);
|
|
158
|
+
expect(types).toContain("speech_started");
|
|
159
|
+
expect(types).toContain("speech_stopped");
|
|
160
|
+
expect(types).toContain("user_transcript");
|
|
161
|
+
|
|
162
|
+
// Verify the STT correctly transcribed the Kokoro audio
|
|
163
|
+
const transcripts = events.filter((e) => e.type === "user_transcript");
|
|
164
|
+
const texts = transcripts.map((e) => (e.payload as { text: string }).text);
|
|
165
|
+
expect(texts.some((t) => t.toLowerCase().includes("space"))).toBe(true);
|
|
166
|
+
expect(texts.some((t) => t.toLowerCase().includes("weather"))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── Simple question flow ───────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
test("simple question: greeting → user speech → agent response", async () => {
|
|
172
|
+
const { events } = await replayFixture("simple-question-sequence.json");
|
|
173
|
+
|
|
174
|
+
const types = events.map((e) => e.type);
|
|
175
|
+
|
|
176
|
+
// Session setup: session.updated is dropped; first events are ready + replyStarted
|
|
177
|
+
expect(types[0]).toBe("ready");
|
|
178
|
+
expect(types[1]).toBe("replyStarted");
|
|
179
|
+
|
|
180
|
+
// Greeting reply
|
|
181
|
+
expect(types).toContain("replyStarted");
|
|
182
|
+
|
|
183
|
+
// User speech recognition
|
|
184
|
+
expect(types).toContain("speech_started");
|
|
185
|
+
expect(types).toContain("user_transcript");
|
|
186
|
+
|
|
187
|
+
// Agent response
|
|
188
|
+
const agentTranscripts = events.filter((e) => e.type === "agent_transcript");
|
|
189
|
+
expect(agentTranscripts.length).toBe(2); // greeting + answer
|
|
190
|
+
|
|
191
|
+
// Two complete reply cycles (greeting + answer)
|
|
192
|
+
expect(types.filter((t) => t === "reply_done").length).toBe(2);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── Tool call flow ─────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
test("tool call: user asks weather → tool_call event dispatched with parsed args", async () => {
|
|
198
|
+
const { events } = await replayFixture("tool-calls.json");
|
|
199
|
+
|
|
200
|
+
const toolCallEvents = events.filter((e) => e.type === "tool_call");
|
|
201
|
+
expect(toolCallEvents.length).toBe(1);
|
|
202
|
+
const tc = toolCallEvents[0]?.payload as {
|
|
203
|
+
toolCallId: string;
|
|
204
|
+
toolName: string;
|
|
205
|
+
args: Record<string, unknown>;
|
|
206
|
+
};
|
|
207
|
+
expect(tc.toolName).toBe("get_weather");
|
|
208
|
+
expect(tc.args.city).toBe("San Francisco");
|
|
209
|
+
expect(tc.toolCallId).toMatch(/^chatcmpl-tool-/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("tool call sequence: greeting → user speech → tool call → agent response", async () => {
|
|
213
|
+
const { events } = await replayFixture("tool-call-sequence.json");
|
|
214
|
+
|
|
215
|
+
const types = events.map((e) => e.type);
|
|
216
|
+
|
|
217
|
+
// Session setup: session.updated dropped; first events are ready + replyStarted
|
|
218
|
+
expect(types[0]).toBe("ready");
|
|
219
|
+
expect(types[1]).toBe("replyStarted");
|
|
220
|
+
|
|
221
|
+
// User speech was recognized
|
|
222
|
+
expect(types).toContain("user_transcript");
|
|
223
|
+
const userTx = events.find((e) => e.type === "user_transcript");
|
|
224
|
+
expect((userTx?.payload as { text: string }).text.toLowerCase()).toContain("weather");
|
|
225
|
+
|
|
226
|
+
// Tool was called
|
|
227
|
+
expect(types).toContain("tool_call");
|
|
228
|
+
const toolCall = events.find((e) => e.type === "tool_call");
|
|
229
|
+
expect((toolCall?.payload as { toolName: string }).toolName).toBe("get_weather");
|
|
230
|
+
|
|
231
|
+
// Agent responded after tool result
|
|
232
|
+
const agentTxs = events.filter((e) => e.type === "agent_transcript");
|
|
233
|
+
expect(agentTxs.length).toBe(2); // greeting + tool response
|
|
234
|
+
const toolResponse = agentTxs.at(-1)?.payload as { text: string };
|
|
235
|
+
expect(toolResponse.text.toLowerCase()).toContain("san francisco");
|
|
236
|
+
});
|
|
237
|
+
});
|