@alexkroman1/aai 0.12.3 → 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.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/CHANGELOG.md +174 -0
  3. package/dist/constants-VTFoymJ-.js +47 -0
  4. package/dist/host/_run-code.d.ts +1 -1
  5. package/dist/host/_runtime-conformance.d.ts +4 -5
  6. package/dist/host/builtin-tools.d.ts +11 -9
  7. package/dist/host/runtime-barrel.d.ts +15 -0
  8. package/dist/{direct-executor-DRRrZUp0.js → host/runtime-barrel.js} +453 -348
  9. package/dist/host/runtime-config.d.ts +42 -0
  10. package/dist/host/runtime.d.ts +119 -35
  11. package/dist/host/s2s.d.ts +14 -38
  12. package/dist/host/server.d.ts +16 -8
  13. package/dist/host/session-ctx.d.ts +55 -0
  14. package/dist/host/session.d.ts +20 -70
  15. package/dist/host/tool-executor.d.ts +20 -0
  16. package/dist/host/unstorage-kv.d.ts +1 -1
  17. package/dist/host/ws-handler.d.ts +4 -2
  18. package/dist/index.d.ts +9 -20
  19. package/dist/index.js +63 -2
  20. package/dist/{isolate → sdk}/_internal-types.d.ts +5 -9
  21. package/dist/{isolate → sdk}/constants.d.ts +6 -4
  22. package/dist/sdk/define.d.ts +66 -0
  23. package/dist/{isolate → sdk}/kv.d.ts +1 -49
  24. package/dist/sdk/manifest-barrel.d.ts +8 -0
  25. package/dist/sdk/manifest-barrel.js +52 -0
  26. package/dist/sdk/manifest.d.ts +50 -0
  27. package/dist/{isolate → sdk}/protocol.d.ts +59 -36
  28. package/dist/sdk/protocol.js +163 -0
  29. package/dist/{isolate → sdk}/system-prompt.d.ts +2 -2
  30. package/dist/sdk/types.d.ts +201 -0
  31. package/dist/sdk/ws-upgrade.d.ts +5 -0
  32. package/dist/{system-prompt-DYAYFW99.js → system-prompt-nik_iavo.js} +10 -10
  33. package/dist/types-Cfx_4QDK.js +39 -0
  34. package/dist/ws-upgrade-BeOQ7fXL.js +30 -0
  35. package/exports-no-dev-deps.test.ts +62 -0
  36. package/host/_mock-ws.ts +185 -0
  37. package/host/_run-code.ts +217 -0
  38. package/host/_runtime-conformance.ts +143 -0
  39. package/host/_test-utils.ts +276 -0
  40. package/host/builtin-tools.test.ts +774 -0
  41. package/host/builtin-tools.ts +255 -0
  42. package/host/cleanup.test.ts +422 -0
  43. package/host/fixture-replay.test.ts +463 -0
  44. package/host/fixtures/README.md +40 -0
  45. package/host/fixtures/greeting-session-sequence.json +40 -0
  46. package/host/fixtures/reply-audio-samples.json +42 -0
  47. package/host/fixtures/reply-lifecycle.json +21 -0
  48. package/host/fixtures/session-ready.json +48 -0
  49. package/host/fixtures/session-updated.json +45 -0
  50. package/host/fixtures/simple-question-sequence.json +73 -0
  51. package/host/fixtures/tool-call-sequence.json +114 -0
  52. package/host/fixtures/tool-calls.json +11 -0
  53. package/host/fixtures/tool-config-session-sequence.json +51 -0
  54. package/host/fixtures/user-speech-recognition.json +30 -0
  55. package/host/fixtures/web-search-sequence.json +122 -0
  56. package/host/integration.test.ts +222 -0
  57. package/host/runtime-barrel.ts +25 -0
  58. package/host/runtime-config.test.ts +71 -0
  59. package/host/runtime-config.ts +99 -0
  60. package/host/runtime.test.ts +641 -0
  61. package/host/runtime.ts +308 -0
  62. package/host/s2s-fixtures.test.ts +237 -0
  63. package/host/s2s.test.ts +562 -0
  64. package/host/s2s.ts +310 -0
  65. package/host/server-shutdown.test.ts +76 -0
  66. package/host/server.test.ts +116 -0
  67. package/host/server.ts +223 -0
  68. package/host/session-ctx.ts +107 -0
  69. package/host/session-fixture-replay.test.ts +136 -0
  70. package/host/session-prompt.test.ts +77 -0
  71. package/host/session.test.ts +590 -0
  72. package/host/session.ts +370 -0
  73. package/host/tool-executor.test.ts +124 -0
  74. package/host/tool-executor.ts +80 -0
  75. package/host/unstorage-kv.test.ts +99 -0
  76. package/host/unstorage-kv.ts +69 -0
  77. package/host/ws-handler.test.ts +739 -0
  78. package/host/ws-handler.ts +255 -0
  79. package/index.ts +16 -0
  80. package/package.json +24 -72
  81. package/sdk/_internal-types.test.ts +34 -0
  82. package/sdk/_internal-types.ts +115 -0
  83. package/sdk/compat-fixtures/README.md +26 -0
  84. package/sdk/compat-fixtures/v1.json +68 -0
  85. package/sdk/constants.ts +77 -0
  86. package/sdk/define.test.ts +57 -0
  87. package/sdk/define.ts +88 -0
  88. package/sdk/kv.ts +60 -0
  89. package/sdk/manifest-barrel.ts +12 -0
  90. package/sdk/manifest.test.ts +56 -0
  91. package/sdk/manifest.ts +89 -0
  92. package/sdk/protocol-compat.test.ts +187 -0
  93. package/sdk/protocol-snapshot.test.ts +199 -0
  94. package/sdk/protocol.test.ts +170 -0
  95. package/sdk/protocol.ts +223 -0
  96. package/sdk/schema-alignment.test.ts +191 -0
  97. package/sdk/system-prompt.test.ts +111 -0
  98. package/sdk/system-prompt.ts +74 -0
  99. package/sdk/tsconfig.json +12 -0
  100. package/sdk/types-inference.test.ts +122 -0
  101. package/sdk/types.test.ts +14 -0
  102. package/sdk/types.ts +226 -0
  103. package/sdk/utils.test.ts +52 -0
  104. package/sdk/utils.ts +20 -0
  105. package/sdk/ws-upgrade.test.ts +48 -0
  106. package/sdk/ws-upgrade.ts +13 -0
  107. package/tsconfig.build.json +14 -0
  108. package/tsconfig.json +10 -0
  109. package/tsdown.config.ts +26 -0
  110. package/vitest.config.ts +17 -0
  111. package/dist/host/_test-utils.d.ts +0 -73
  112. package/dist/host/direct-executor.d.ts +0 -130
  113. package/dist/host/index.d.ts +0 -19
  114. package/dist/host/index.js +0 -165
  115. package/dist/host/matchers.d.ts +0 -20
  116. package/dist/host/matchers.js +0 -41
  117. package/dist/host/server.js +0 -164
  118. package/dist/host/testing.d.ts +0 -294
  119. package/dist/host/testing.js +0 -2
  120. package/dist/host/vite-plugin.d.ts +0 -15
  121. package/dist/host/vite-plugin.js +0 -83
  122. package/dist/isolate/_kv-utils.d.ts +0 -10
  123. package/dist/isolate/_utils.js +0 -17
  124. package/dist/isolate/hooks.d.ts +0 -44
  125. package/dist/isolate/hooks.js +0 -58
  126. package/dist/isolate/index.d.ts +0 -18
  127. package/dist/isolate/index.js +0 -6
  128. package/dist/isolate/kv.js +0 -1
  129. package/dist/isolate/protocol.js +0 -2
  130. package/dist/isolate/types.d.ts +0 -418
  131. package/dist/isolate/types.js +0 -175
  132. package/dist/protocol-rcOrz7T3.js +0 -183
  133. package/dist/testing-BreLdpq-.js +0 -513
  134. package/dist/types.test-d.d.ts +0 -7
  135. /package/dist/{isolate/_utils.d.ts → sdk/utils.d.ts} +0 -0
package/host/server.ts ADDED
@@ -0,0 +1,223 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Agent HTTP+WebSocket server.
4
+ *
5
+ * {@link createServer} wraps a {@link Runtime} with an HTTP + WebSocket
6
+ * server using only `node:http` and `ws` (no framework dependencies).
7
+ *
8
+ * **Internal module** — used by `aai-cli` dev server. Not a public API.
9
+ * Import via `aai/host`.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import http from "node:http";
14
+ import path from "node:path";
15
+ import escapeHtml from "escape-html";
16
+ import { lookup as mimeLookup } from "mime-types";
17
+ import { WebSocketServer } from "ws";
18
+ import { AGENT_CSP, MAX_WS_PAYLOAD_BYTES } from "../sdk/constants.ts";
19
+ import type { Kv } from "../sdk/kv.ts";
20
+ import { parseWsUpgradeParams } from "../sdk/ws-upgrade.ts";
21
+ import type { Runtime } from "./runtime.ts";
22
+ import type { Logger } from "./runtime-config.ts";
23
+ import { consoleLogger } from "./runtime-config.ts";
24
+ import type { SessionWebSocket } from "./ws-handler.ts";
25
+
26
+ export { createRuntime, type Runtime, type RuntimeOptions } from "./runtime.ts";
27
+
28
+ /**
29
+ * Configuration for {@link createServer}.
30
+ * @internal
31
+ */
32
+ type ServerOptions = {
33
+ runtime: Runtime;
34
+ name?: string;
35
+ kv?: Kv;
36
+ clientHtml?: string;
37
+ clientDir?: string;
38
+ logger?: Logger;
39
+ };
40
+
41
+ /**
42
+ * Handle returned by {@link createServer}.
43
+ * @internal
44
+ */
45
+ export type AgentServer = {
46
+ listen(port?: number): Promise<void>;
47
+ close(): Promise<void>;
48
+ port: number | undefined;
49
+ };
50
+
51
+ // ── Static file serving ─────────────────────────────────────────────────
52
+
53
+ async function serveStatic(
54
+ dir: string,
55
+ req: http.IncomingMessage,
56
+ res: http.ServerResponse,
57
+ ): Promise<boolean> {
58
+ const url = req.url?.split("?")[0] ?? "/";
59
+ const filePath = path.join(dir, url === "/" ? "index.html" : url);
60
+
61
+ // Prevent path traversal — use resolved dir + separator to avoid prefix
62
+ // collisions (e.g. dir="/app/static" matching "/app/static-secrets/…").
63
+ const resolved = path.resolve(dir);
64
+ if (!filePath.startsWith(resolved + path.sep) && filePath !== resolved) return false;
65
+
66
+ try {
67
+ const stat = await fs.promises.stat(filePath);
68
+ if (!stat.isFile()) return false;
69
+ const ext = path.extname(filePath).toLowerCase();
70
+ const mime = mimeLookup(ext) || "application/octet-stream";
71
+ res.writeHead(200, { "Content-Type": mime, "Content-Length": stat.size });
72
+ fs.createReadStream(filePath).pipe(res);
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ // ── Server ──────────────────────────────────────────────────────────────
80
+
81
+ function handleKvGet(kv: Kv, req: http.IncomingMessage, res: http.ServerResponse): void {
82
+ const fullUrl = new URL(req.url ?? "/", "http://localhost");
83
+ const key = fullUrl.searchParams.get("key");
84
+ if (!key) {
85
+ res.writeHead(400, { "Content-Type": "application/json" });
86
+ res.end(JSON.stringify({ error: "Missing key query parameter" }));
87
+ return;
88
+ }
89
+ kv.get(key)
90
+ .then((value) => {
91
+ if (value === null) {
92
+ res.writeHead(404, { "Content-Type": "application/json" });
93
+ res.end("null");
94
+ } else {
95
+ res.writeHead(200, { "Content-Type": "application/json" });
96
+ res.end(JSON.stringify(value));
97
+ }
98
+ })
99
+ .catch(() => {
100
+ res.writeHead(500, { "Content-Type": "application/json" });
101
+ res.end(JSON.stringify({ error: "KV error" }));
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Create an HTTP + WebSocket server for an agent.
107
+ *
108
+ * @internal Used by aai-cli dev server.
109
+ */
110
+ export function createServer(options: ServerOptions): AgentServer {
111
+ const { runtime, clientHtml, clientDir, logger = consoleLogger, kv } = options;
112
+ const name = options.name ?? "agent";
113
+
114
+ if (clientHtml && clientDir) {
115
+ throw new Error("clientHtml and clientDir are mutually exclusive");
116
+ }
117
+
118
+ // Pre-compute the default HTML page once (the agent name never changes).
119
+ const escapedName = escapeHtml(name);
120
+ const defaultHtml =
121
+ clientHtml ??
122
+ `<!DOCTYPE html><html><body><h1>${escapedName}</h1><p>Agent server running.</p></body></html>`;
123
+
124
+ const httpServer = http.createServer((req, res) => {
125
+ const url = req.url?.split("?")[0] ?? "/";
126
+ const method = req.method ?? "GET";
127
+
128
+ // Security headers
129
+ res.setHeader("Content-Security-Policy", AGENT_CSP);
130
+ res.setHeader("X-Content-Type-Options", "nosniff");
131
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
132
+
133
+ // Health endpoint
134
+ if (method === "GET" && url === "/health") {
135
+ res.writeHead(200, { "Content-Type": "application/json" });
136
+ res.end(JSON.stringify({ status: "ok", name }));
137
+ return;
138
+ }
139
+
140
+ // KV endpoint
141
+ if (kv && method === "GET" && url === "/kv") {
142
+ handleKvGet(kv, req, res);
143
+ return;
144
+ }
145
+
146
+ // Routes that may need async handling
147
+ void handleRequest(req, res, url, method);
148
+ });
149
+
150
+ async function handleRequest(
151
+ req: http.IncomingMessage,
152
+ res: http.ServerResponse,
153
+ url: string,
154
+ method: string,
155
+ ): Promise<void> {
156
+ // Static files from client dir
157
+ if (clientDir && (await serveStatic(clientDir, req, res))) return;
158
+
159
+ // Default HTML
160
+ if (method === "GET" && url === "/") {
161
+ res.writeHead(200, { "Content-Type": "text/html" });
162
+ res.end(defaultHtml);
163
+ return;
164
+ }
165
+
166
+ // 404
167
+ logger.error(`${method} ${url} 404`);
168
+ res.writeHead(404, { "Content-Type": "application/json" });
169
+ res.end(JSON.stringify({ error: "Not found" }));
170
+ }
171
+
172
+ // WebSocket upgrade via ws
173
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD_BYTES });
174
+
175
+ httpServer.on("upgrade", (req, socket, head) => {
176
+ const url = req.url?.split("?")[0] ?? "";
177
+ if (!url.startsWith("/websocket")) return;
178
+
179
+ wss.handleUpgrade(req, socket, head, (ws) => {
180
+ const startOpts = parseWsUpgradeParams(req.url ?? "");
181
+
182
+ logger.info(`WS upgrade ${url}${startOpts.skipGreeting ? " (resume)" : ""}`);
183
+
184
+ runtime.startSession(ws as unknown as SessionWebSocket, startOpts);
185
+ });
186
+ });
187
+
188
+ let listenPort: number | undefined;
189
+
190
+ return {
191
+ get port() {
192
+ return listenPort;
193
+ },
194
+
195
+ async listen(port = 3000) {
196
+ await new Promise<void>((resolve, reject) => {
197
+ httpServer.on("error", reject);
198
+ httpServer.listen(port, () => {
199
+ const addr = httpServer.address();
200
+ listenPort = typeof addr === "object" && addr ? addr.port : port;
201
+ resolve();
202
+ });
203
+ });
204
+ },
205
+
206
+ async close() {
207
+ try {
208
+ await runtime.shutdown();
209
+ } finally {
210
+ try {
211
+ wss.close();
212
+ } finally {
213
+ if (listenPort !== undefined) {
214
+ await new Promise<void>((resolve, reject) => {
215
+ httpServer.close((err) => (err ? reject(err) : resolve()));
216
+ });
217
+ }
218
+ listenPort = undefined;
219
+ }
220
+ }
221
+ },
222
+ };
223
+ }
@@ -0,0 +1,107 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /** Session context builder — extracted from session.ts. */
3
+
4
+ import type { AgentConfig, ExecuteTool } from "../sdk/_internal-types.ts";
5
+ import { DEFAULT_MAX_HISTORY } from "../sdk/constants.ts";
6
+ import type { ClientSink } from "../sdk/protocol.ts";
7
+ import type { Message } from "../sdk/types.ts";
8
+ import { toolError } from "../sdk/utils.ts";
9
+ import type { Logger } from "./runtime-config.ts";
10
+ import type { S2sHandle } from "./s2s.ts";
11
+
12
+ type PendingTool = { callId: string; result: string };
13
+
14
+ /** Per-reply mutable state — reset on beginReply/cancelReply. */
15
+ export type ReplyState = {
16
+ pendingTools: PendingTool[];
17
+ toolCallCount: number;
18
+ currentReplyId: string | null;
19
+ };
20
+
21
+ /** Immutable dependencies injected at session creation. */
22
+ export type SessionDeps = {
23
+ readonly id: string;
24
+ readonly agent: string;
25
+ readonly client: ClientSink;
26
+ readonly agentConfig: AgentConfig;
27
+ readonly executeTool: ExecuteTool;
28
+ readonly log: Logger;
29
+ readonly maxHistory: number;
30
+ };
31
+
32
+ /**
33
+ * Session context threaded through event handlers.
34
+ *
35
+ * Split into three layers:
36
+ * - {@link SessionDeps} — immutable dependencies (set once)
37
+ * - {@link ReplyState} via `reply` — per-reply mutable state (reset on beginReply/cancelReply)
38
+ * - Remaining fields — connection, conversation, and lifecycle methods
39
+ */
40
+ export type S2sSessionCtx = SessionDeps & {
41
+ s2s: S2sHandle | null;
42
+ reply: ReplyState;
43
+ turnPromise: Promise<void> | null;
44
+ conversationMessages: Message[];
45
+
46
+ consumeToolCallStep(name: string, replyId: string | null): string | null;
47
+ pushMessages(...msgs: Message[]): void;
48
+ beginReply(replyId: string): void;
49
+ cancelReply(): void;
50
+ chainTurn(p: Promise<void>): void;
51
+ };
52
+
53
+ export function buildCtx(opts: {
54
+ id: string;
55
+ agent: string;
56
+ client: ClientSink;
57
+ agentConfig: AgentConfig;
58
+ executeTool: ExecuteTool;
59
+ log: Logger;
60
+ maxHistory?: number | undefined;
61
+ }): S2sSessionCtx {
62
+ const { agentConfig, log } = opts;
63
+ const maxHistory = opts.maxHistory ?? DEFAULT_MAX_HISTORY;
64
+ const ctx: S2sSessionCtx = {
65
+ ...opts,
66
+ s2s: null,
67
+ reply: { pendingTools: [], toolCallCount: 0, currentReplyId: null },
68
+ turnPromise: null,
69
+ conversationMessages: [],
70
+ maxHistory,
71
+ consumeToolCallStep(_name, replyId) {
72
+ // Guard 1: reject tool calls from interrupted/stale replies
73
+ if (replyId === null || replyId !== ctx.reply.currentReplyId) {
74
+ return toolError("Reply was interrupted. Discarding stale tool call.");
75
+ }
76
+ // Guard 2: enforce maxSteps (default 5, set in manifest.ts) to prevent
77
+ // runaway tool-call loops within a single LLM reply
78
+ const maxSteps = agentConfig.maxSteps;
79
+ ctx.reply.toolCallCount++;
80
+ if (maxSteps !== undefined && ctx.reply.toolCallCount > maxSteps) {
81
+ log.info("maxSteps exceeded, refusing tool call", {
82
+ toolCallCount: ctx.reply.toolCallCount,
83
+ maxSteps,
84
+ });
85
+ return toolError("Maximum tool steps reached. Please respond to the user now.");
86
+ }
87
+ return null;
88
+ },
89
+ pushMessages(...msgs: Message[]) {
90
+ ctx.conversationMessages.push(...msgs);
91
+ if (maxHistory > 0 && ctx.conversationMessages.length > maxHistory) {
92
+ ctx.conversationMessages.splice(0, ctx.conversationMessages.length - maxHistory);
93
+ }
94
+ },
95
+ beginReply(replyId: string) {
96
+ ctx.reply = { pendingTools: [], toolCallCount: 0, currentReplyId: replyId };
97
+ ctx.turnPromise = null;
98
+ },
99
+ cancelReply() {
100
+ ctx.reply = { pendingTools: [], toolCallCount: 0, currentReplyId: null };
101
+ },
102
+ chainTurn(p: Promise<void>) {
103
+ ctx.turnPromise = (ctx.turnPromise ?? Promise.resolve()).then(() => p);
104
+ },
105
+ };
106
+ return ctx;
107
+ }
@@ -0,0 +1,136 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import {
3
+ flush,
4
+ loadFixture,
5
+ makeClient,
6
+ makeMockHandle,
7
+ makeSessionOpts,
8
+ replayFixtureMessages,
9
+ } from "./_test-utils.ts";
10
+ import { _internals, createS2sSession, type S2sSessionOptions } from "./session.ts";
11
+
12
+ // ─── Session-level fixture replay tests ─────────────────────────────────────
13
+ //
14
+ // These replay real AssemblyAI S2S messages (recorded with Kokoro TTS audio)
15
+ // through the full session orchestration layer — the same setupListeners /
16
+ // handleToolCall / handleReplyDone code path that production uses.
17
+
18
+ describe("fixture replay through session", () => {
19
+ let connectSpy: ReturnType<typeof vi.spyOn>;
20
+
21
+ function setupReplay(overrides?: Partial<S2sSessionOptions>) {
22
+ const mockHandle = makeMockHandle();
23
+ connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
24
+ const client = makeClient();
25
+ const opts = makeSessionOpts({ client, ...overrides });
26
+ const session = createS2sSession(opts);
27
+ return { session, client, mockHandle, opts };
28
+ }
29
+
30
+ afterEach(() => {
31
+ connectSpy?.mockRestore();
32
+ });
33
+
34
+ test("greeting session: client receives speech events and chat messages", async () => {
35
+ const { session, client, mockHandle } = setupReplay();
36
+ await session.start();
37
+
38
+ const messages = loadFixture("greeting-session-sequence.json");
39
+ replayFixtureMessages(mockHandle, messages);
40
+
41
+ // Client should have received speech_started/stopped for the greeting
42
+ const types = client.events.map((e) => (e as { type: string }).type);
43
+ expect(types).toContain("agent_transcript"); // final agent transcript
44
+ expect(types).toContain("reply_done"); // reply completed
45
+ });
46
+
47
+ test("simple question: user transcript builds conversation history", async () => {
48
+ const { session, client, mockHandle } = setupReplay();
49
+ await session.start();
50
+
51
+ const messages = loadFixture("simple-question-sequence.json");
52
+ replayFixtureMessages(mockHandle, messages);
53
+ await flush();
54
+
55
+ // Client should see both greeting and answer as agent_transcript events
56
+ const chatEvents = client.events.filter(
57
+ (e) => (e as { type: string }).type === "agent_transcript",
58
+ );
59
+ expect(chatEvents.length).toBe(2); // greeting + answer
60
+ });
61
+
62
+ test("tool call: session executes tool, buffers result, sends after replyDone", async () => {
63
+ const executeTool = vi.fn(async () =>
64
+ JSON.stringify({ city: "San Francisco", temperature: "72°F", condition: "sunny" }),
65
+ );
66
+
67
+ const { session, client, mockHandle } = setupReplay({ executeTool });
68
+ await session.start();
69
+
70
+ const messages = loadFixture("tool-call-sequence.json");
71
+ replayFixtureMessages(mockHandle, messages);
72
+
73
+ // Wait for tool execution to complete
74
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
75
+ await session.waitForTurn();
76
+
77
+ // Tool was called with the right name and args
78
+ expect(executeTool).toHaveBeenCalledWith(
79
+ "get_weather",
80
+ expect.objectContaining({ city: "San Francisco" }),
81
+ expect.any(String), // session ID
82
+ expect.any(Array), // messages
83
+ );
84
+
85
+ // Client received tool_call and tool_call_done events
86
+ const toolStart = client.events.find((e) => (e as { type: string }).type === "tool_call") as
87
+ | { toolName: string; args: Record<string, unknown> }
88
+ | undefined;
89
+ expect(toolStart).toBeDefined();
90
+ expect(toolStart?.toolName).toBe("get_weather");
91
+
92
+ const toolDone = client.events.find((e) => (e as { type: string }).type === "tool_call_done") as
93
+ | { result: string }
94
+ | undefined;
95
+ expect(toolDone).toBeDefined();
96
+
97
+ // Tool result was sent back to S2S after replyDone
98
+ await vi.waitFor(() => expect(mockHandle.sendToolResult).toHaveBeenCalled());
99
+ });
100
+
101
+ test("tool call: conversation messages accumulate correctly", async () => {
102
+ const executeTool = vi.fn(async () => JSON.stringify({ result: "ok" }));
103
+ const { session, mockHandle } = setupReplay({ executeTool });
104
+ await session.start();
105
+
106
+ const messages = loadFixture("tool-call-sequence.json");
107
+ replayFixtureMessages(mockHandle, messages);
108
+ await vi.waitFor(() => expect(executeTool).toHaveBeenCalled());
109
+ await session.waitForTurn();
110
+
111
+ // The conversation messages passed to executeTool should include
112
+ // the user's transcript (from STT recognition of Kokoro audio)
113
+ const call = executeTool.mock.calls[0] as unknown as [
114
+ string,
115
+ unknown,
116
+ string,
117
+ { role: string; content: string }[],
118
+ ];
119
+ const userMsg = call[3]?.find((m) => m.role === "user");
120
+ expect(userMsg?.content.toLowerCase()).toContain("weather");
121
+ });
122
+
123
+ test("user speech recognition events reach the client", async () => {
124
+ const { session, client, mockHandle } = setupReplay();
125
+ await session.start();
126
+
127
+ const messages = loadFixture("user-speech-recognition.json");
128
+ replayFixtureMessages(mockHandle, messages);
129
+ await flush();
130
+
131
+ const types = client.events.map((e) => (e as { type: string }).type);
132
+ expect(types).toContain("speech_started");
133
+ expect(types).toContain("speech_stopped");
134
+ expect(types).toContain("user_transcript"); // triggers orchestration
135
+ });
136
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildSystemPrompt } from "../sdk/system-prompt.ts";
3
+ import { DEFAULT_SYSTEM_PROMPT } from "../sdk/types.ts";
4
+ import { makeConfig } from "./_test-utils.ts";
5
+
6
+ describe("buildSystemPrompt", () => {
7
+ test("starts with DEFAULT_SYSTEM_PROMPT when no custom instructions", () => {
8
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false });
9
+ expect(result.startsWith(DEFAULT_SYSTEM_PROMPT)).toBe(true);
10
+ });
11
+
12
+ test("does not include agent-specific instructions section for default instructions", () => {
13
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false });
14
+ expect(result).not.toContain("Agent-Specific Instructions:");
15
+ });
16
+
17
+ test("appends custom agent instructions", () => {
18
+ const custom = "You are a pirate. Always speak like one.";
19
+ const result = buildSystemPrompt(makeConfig({ systemPrompt: custom }), { hasTools: false });
20
+ expect(result).toContain("Agent-Specific Instructions:");
21
+ expect(result).toContain(custom);
22
+ });
23
+
24
+ test("includes tool preamble when hasTools is true", () => {
25
+ const result = buildSystemPrompt(makeConfig(), { hasTools: true });
26
+ expect(result).toContain("ALWAYS say a brief natural phrase BEFORE the tool call");
27
+ });
28
+
29
+ test("omits tool preamble when hasTools is false", () => {
30
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false });
31
+ expect(result).not.toContain("ALWAYS say a brief natural phrase BEFORE the tool call");
32
+ });
33
+
34
+ test("appends voice rules when voice is true", () => {
35
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false, voice: true });
36
+ expect(result).toContain("CRITICAL OUTPUT RULES");
37
+ expect(result).toContain("NEVER use markdown");
38
+ });
39
+
40
+ test("omits voice rules when voice is false", () => {
41
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false, voice: false });
42
+ expect(result).not.toContain("CRITICAL OUTPUT RULES");
43
+ });
44
+
45
+ test("omits voice rules when voice is undefined", () => {
46
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false });
47
+ expect(result).not.toContain("CRITICAL OUTPUT RULES");
48
+ });
49
+
50
+ test("includes today's date", () => {
51
+ const today = new Date().toLocaleDateString("en-US", {
52
+ weekday: "long",
53
+ year: "numeric",
54
+ month: "long",
55
+ day: "numeric",
56
+ });
57
+ const result = buildSystemPrompt(makeConfig(), { hasTools: false });
58
+ expect(result).toContain(`Today's date is ${today}.`);
59
+ });
60
+
61
+ test("voice + hasTools includes both voice rules and tool preamble", () => {
62
+ const result = buildSystemPrompt(makeConfig(), { hasTools: true, voice: true });
63
+ expect(result).toContain("CRITICAL OUTPUT RULES");
64
+ expect(result).toContain("ALWAYS say a brief natural phrase BEFORE the tool call");
65
+ });
66
+
67
+ test("custom instructions + voice + tools includes all sections", () => {
68
+ const result = buildSystemPrompt(makeConfig({ systemPrompt: "Be concise." }), {
69
+ hasTools: true,
70
+ voice: true,
71
+ });
72
+ expect(result).toContain("Agent-Specific Instructions:");
73
+ expect(result).toContain("Be concise.");
74
+ expect(result).toContain("CRITICAL OUTPUT RULES");
75
+ expect(result).toContain("ALWAYS say a brief natural phrase BEFORE the tool call");
76
+ });
77
+ });