chainlesschain 0.45.70 → 0.45.75

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 (89) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/Analytics-B4OM8S8X.css +1 -0
  4. package/src/assets/web-panel/assets/Analytics-sBrYoc3A.js +3 -0
  5. package/src/assets/web-panel/assets/AppLayout-2RCrdXxl.js +1 -0
  6. package/src/assets/web-panel/assets/AppLayout-D9pBLPC3.css +1 -0
  7. package/src/assets/web-panel/assets/Backup-D68fenbD.js +1 -0
  8. package/src/assets/web-panel/assets/Backup-fZqtfC1m.css +1 -0
  9. package/src/assets/web-panel/assets/{Chat-DXtvKoM0.js → Chat-B2nB8o_F.js} +1 -1
  10. package/src/assets/web-panel/assets/{Cron-BJ4ODHOy.js → Cron-CNs03iHJ.js} +2 -2
  11. package/src/assets/web-panel/assets/{Dashboard-BZd4wDPQ.js → Dashboard-DanoHPSI.js} +2 -2
  12. package/src/assets/web-panel/assets/Git-CCMVr3Y8.js +2 -0
  13. package/src/assets/web-panel/assets/Git-DGcuBXST.css +1 -0
  14. package/src/assets/web-panel/assets/{Logs-CSeKZEG_.js → Logs-BY6A0UNG.js} +2 -2
  15. package/src/assets/web-panel/assets/{McpTools-BYQAK11r.js → McpTools-CrBVYlg6.js} +2 -2
  16. package/src/assets/web-panel/assets/{Memory-gkUAPyuZ.js → Memory-CWx3SpUt.js} +2 -2
  17. package/src/assets/web-panel/assets/{Notes-bjNrQgAo.js → Notes-1LcGD49x.js} +2 -2
  18. package/src/assets/web-panel/assets/Organization-DdOOM4ic.css +1 -0
  19. package/src/assets/web-panel/assets/Organization-Dx2DhbkM.js +4 -0
  20. package/src/assets/web-panel/assets/P2P-B16fjqfJ.js +2 -0
  21. package/src/assets/web-panel/assets/P2P-OEzOeMZX.css +1 -0
  22. package/src/assets/web-panel/assets/Permissions-BQbC9FzG.js +4 -0
  23. package/src/assets/web-panel/assets/Permissions-C9WlkGl-.css +1 -0
  24. package/src/assets/web-panel/assets/Projects-CjhZbNYm.js +2 -0
  25. package/src/assets/web-panel/assets/Projects-DxKelI5h.css +1 -0
  26. package/src/assets/web-panel/assets/Providers-BEakqcO5.css +1 -0
  27. package/src/assets/web-panel/assets/Providers-ivOAQtHM.js +2 -0
  28. package/src/assets/web-panel/assets/RssFeed-BlFC20eg.css +1 -0
  29. package/src/assets/web-panel/assets/RssFeed-BrsErdrU.js +3 -0
  30. package/src/assets/web-panel/assets/Security-DnEvJU5h.js +4 -0
  31. package/src/assets/web-panel/assets/Security-Dwxw7rfP.css +1 -0
  32. package/src/assets/web-panel/assets/{Services-CS0oMdxh.js → Services-7jQywNbl.js} +2 -2
  33. package/src/assets/web-panel/assets/Skills-CLlblJcG.js +1 -0
  34. package/src/assets/web-panel/assets/{Tasks-qULws8pc.js → Tasks-CmJBC1cf.js} +1 -1
  35. package/src/assets/web-panel/assets/Templates-DOY_oZnm.css +1 -0
  36. package/src/assets/web-panel/assets/Templates-RXT8-DNk.js +1 -0
  37. package/src/assets/web-panel/assets/Wallet-3iYASEx_.js +4 -0
  38. package/src/assets/web-panel/assets/Wallet-DnIumafl.css +1 -0
  39. package/src/assets/web-panel/assets/WebAuthn-CNPl2VQR.css +1 -0
  40. package/src/assets/web-panel/assets/WebAuthn-s3Hzd9db.js +5 -0
  41. package/src/assets/web-panel/assets/{antd-CJSBocer.js → antd-gZyc63Qr.js} +114 -114
  42. package/src/assets/web-panel/assets/chat-DWBA4-cl.js +1 -0
  43. package/src/assets/web-panel/assets/index-CyGtHm63.js +2 -0
  44. package/src/assets/web-panel/assets/{markdown-Bo5cVN4u.js → markdown-Bv7nG63L.js} +1 -1
  45. package/src/assets/web-panel/assets/ws-CU7Gvoom.js +1 -0
  46. package/src/assets/web-panel/index.html +2 -2
  47. package/src/commands/doctor.js +33 -151
  48. package/src/commands/mcp.js +1 -1
  49. package/src/commands/plugin.js +1 -1
  50. package/src/commands/session.js +106 -7
  51. package/src/commands/status.js +39 -69
  52. package/src/gateways/ws/session-protocol.js +1 -1
  53. package/src/gateways/ws/ws-agent-handler.js +484 -0
  54. package/src/gateways/ws/ws-server.js +758 -4
  55. package/src/gateways/ws/ws-session-gateway.js +1432 -1
  56. package/src/harness/mcp-client.js +417 -0
  57. package/src/harness/mock-llm-provider.js +167 -0
  58. package/src/harness/plugin-manager.js +434 -0
  59. package/src/lib/agent-core.js +25 -1902
  60. package/src/lib/hashline.js +208 -0
  61. package/src/lib/jsonl-session-store.js +11 -0
  62. package/src/lib/mcp-client.js +14 -412
  63. package/src/lib/plugin-manager.js +29 -428
  64. package/src/lib/prompt-compressor.js +11 -0
  65. package/src/lib/session-hooks.js +61 -0
  66. package/src/lib/skill-loader.js +4 -0
  67. package/src/lib/skill-mcp.js +190 -0
  68. package/src/lib/workflow-state-reader.js +94 -0
  69. package/src/lib/ws-agent-handler.js +8 -472
  70. package/src/lib/ws-server.js +12 -756
  71. package/src/lib/ws-session-manager.js +8 -1417
  72. package/src/repl/agent-repl.js +27 -3
  73. package/src/runtime/agent-core.js +1760 -0
  74. package/src/runtime/agent-runtime.js +3 -1
  75. package/src/runtime/coding-agent-contract-shared.cjs +496 -0
  76. package/src/runtime/coding-agent-contract.js +49 -229
  77. package/src/runtime/coding-agent-policy.cjs +54 -5
  78. package/src/runtime/diagnostics.js +317 -0
  79. package/src/runtime/index.js +3 -0
  80. package/src/tools/index.js +3 -0
  81. package/src/tools/legacy-agent-tools.js +5 -0
  82. package/src/assets/web-panel/assets/AppLayout-B_tkw3Pn.js +0 -1
  83. package/src/assets/web-panel/assets/AppLayout-CFP4dGIJ.css +0 -1
  84. package/src/assets/web-panel/assets/Providers-Brm-S_hS.css +0 -1
  85. package/src/assets/web-panel/assets/Providers-Dbf57Tbv.js +0 -1
  86. package/src/assets/web-panel/assets/Skills-B2fgruv8.js +0 -1
  87. package/src/assets/web-panel/assets/chat-DnH09sSR.js +0 -1
  88. package/src/assets/web-panel/assets/index-IK-oro0g.js +0 -2
  89. package/src/assets/web-panel/assets/ws-DjelKkD6.js +0 -1
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Lightweight MCP (Model Context Protocol) client.
3
+ * Implements JSON-RPC 2.0 over stdio transport without external SDK dependency.
4
+ *
5
+ * Canonical location (moved from src/lib/mcp-client.js as part of the
6
+ * CLI Runtime Convergence roadmap, Phase 3). src/lib/mcp-client.js is now a
7
+ * thin re-export shim for backwards compatibility.
8
+ */
9
+
10
+ import { spawn } from "child_process";
11
+ import { EventEmitter } from "events";
12
+
13
+ /**
14
+ * MCP Server connection states.
15
+ */
16
+ export const ServerState = {
17
+ DISCONNECTED: "disconnected",
18
+ CONNECTING: "connecting",
19
+ CONNECTED: "connected",
20
+ ERROR: "error",
21
+ };
22
+
23
+ /**
24
+ * MCP Client — manages connections to MCP servers.
25
+ */
26
+ export class MCPClient extends EventEmitter {
27
+ constructor() {
28
+ super();
29
+ this.servers = new Map(); // name → { process, state, tools, resources, config }
30
+ this._nextId = 1;
31
+ }
32
+
33
+ /**
34
+ * Connect to an MCP server via stdio transport.
35
+ * @param {string} name - Server name
36
+ * @param {object} config - { command, args?, env? }
37
+ */
38
+ async connect(name, config) {
39
+ if (this.servers.has(name)) {
40
+ throw new Error(`Server "${name}" already connected`);
41
+ }
42
+
43
+ const entry = {
44
+ config,
45
+ state: ServerState.CONNECTING,
46
+ process: null,
47
+ tools: [],
48
+ resources: [],
49
+ prompts: [],
50
+ _pending: new Map(),
51
+ _buffer: "",
52
+ };
53
+
54
+ this.servers.set(name, entry);
55
+
56
+ try {
57
+ const proc = spawn(config.command, config.args || [], {
58
+ stdio: ["pipe", "pipe", "pipe"],
59
+ env: { ...process.env, ...(config.env || {}) },
60
+ });
61
+
62
+ entry.process = proc;
63
+
64
+ proc.stdout.on("data", (data) => {
65
+ this._handleData(name, data.toString("utf8"));
66
+ });
67
+
68
+ proc.stderr.on("data", (data) => {
69
+ this.emit("server-error", { name, error: data.toString("utf8") });
70
+ });
71
+
72
+ proc.on("close", (code) => {
73
+ entry.state = ServerState.DISCONNECTED;
74
+ this.emit("server-disconnected", { name, code });
75
+ });
76
+
77
+ proc.on("error", (err) => {
78
+ entry.state = ServerState.ERROR;
79
+ this.emit("server-error", { name, error: err.message });
80
+ });
81
+
82
+ // Initialize MCP protocol
83
+ const initResult = await this._sendRequest(name, "initialize", {
84
+ protocolVersion: "2024-11-05",
85
+ capabilities: { tools: {}, resources: {} },
86
+ clientInfo: { name: "chainlesschain-cli", version: "0.37.9" },
87
+ });
88
+
89
+ // Send initialized notification
90
+ this._sendNotification(name, "notifications/initialized", {});
91
+
92
+ entry.state = ServerState.CONNECTED;
93
+ entry.serverInfo = initResult?.serverInfo || {};
94
+ entry.capabilities = initResult?.capabilities || {};
95
+
96
+ // Fetch available tools
97
+ try {
98
+ const toolsResult = await this._sendRequest(name, "tools/list", {});
99
+ entry.tools = toolsResult?.tools || [];
100
+ } catch {
101
+ // Server may not support tools
102
+ }
103
+
104
+ // Fetch available resources
105
+ try {
106
+ const resourcesResult = await this._sendRequest(
107
+ name,
108
+ "resources/list",
109
+ {},
110
+ );
111
+ entry.resources = resourcesResult?.resources || [];
112
+ } catch {
113
+ // Server may not support resources
114
+ }
115
+
116
+ this.emit("server-connected", { name, tools: entry.tools.length });
117
+ return {
118
+ name,
119
+ state: entry.state,
120
+ tools: entry.tools,
121
+ resources: entry.resources,
122
+ serverInfo: entry.serverInfo,
123
+ };
124
+ } catch (err) {
125
+ entry.state = ServerState.ERROR;
126
+ this.servers.delete(name);
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Disconnect from an MCP server.
133
+ */
134
+ async disconnect(name) {
135
+ const entry = this.servers.get(name);
136
+ if (!entry) return false;
137
+
138
+ if (entry.process) {
139
+ entry.process.kill();
140
+ }
141
+
142
+ entry.state = ServerState.DISCONNECTED;
143
+ this.servers.delete(name);
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Disconnect from all servers.
149
+ */
150
+ async disconnectAll() {
151
+ const names = [...this.servers.keys()];
152
+ for (const name of names) {
153
+ await this.disconnect(name);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * List all connected servers.
159
+ */
160
+ listServers() {
161
+ const result = [];
162
+ for (const [name, entry] of this.servers) {
163
+ result.push({
164
+ name,
165
+ state: entry.state,
166
+ tools: entry.tools.length,
167
+ resources: entry.resources.length,
168
+ serverInfo: entry.serverInfo || {},
169
+ });
170
+ }
171
+ return result;
172
+ }
173
+
174
+ /**
175
+ * List tools from a specific server or all servers.
176
+ */
177
+ listTools(serverName) {
178
+ if (serverName) {
179
+ const entry = this.servers.get(serverName);
180
+ if (!entry) throw new Error(`Server "${serverName}" not found`);
181
+ return entry.tools.map((t) => ({ ...t, server: serverName }));
182
+ }
183
+
184
+ const allTools = [];
185
+ for (const [name, entry] of this.servers) {
186
+ for (const tool of entry.tools) {
187
+ allTools.push({ ...tool, server: name });
188
+ }
189
+ }
190
+ return allTools;
191
+ }
192
+
193
+ /**
194
+ * Call a tool on a specific server.
195
+ * @param {string} serverName - Server name
196
+ * @param {string} toolName - Tool name
197
+ * @param {object} args - Tool arguments
198
+ */
199
+ async callTool(serverName, toolName, args = {}) {
200
+ const entry = this.servers.get(serverName);
201
+ if (!entry) throw new Error(`Server "${serverName}" not found`);
202
+ if (entry.state !== ServerState.CONNECTED) {
203
+ throw new Error(`Server "${serverName}" is not connected`);
204
+ }
205
+
206
+ const result = await this._sendRequest(serverName, "tools/call", {
207
+ name: toolName,
208
+ arguments: args,
209
+ });
210
+
211
+ return result;
212
+ }
213
+
214
+ /**
215
+ * Read a resource from a server.
216
+ */
217
+ async readResource(serverName, uri) {
218
+ const entry = this.servers.get(serverName);
219
+ if (!entry) throw new Error(`Server "${serverName}" not found`);
220
+
221
+ const result = await this._sendRequest(serverName, "resources/read", {
222
+ uri,
223
+ });
224
+ return result;
225
+ }
226
+
227
+ // ─── Internal JSON-RPC transport ──────────────────────────────
228
+
229
+ _sendRequest(serverName, method, params) {
230
+ return new Promise((resolve, reject) => {
231
+ const entry = this.servers.get(serverName);
232
+ if (!entry || !entry.process) {
233
+ return reject(new Error("Server not available"));
234
+ }
235
+
236
+ const id = this._nextId++;
237
+ const message = JSON.stringify({
238
+ jsonrpc: "2.0",
239
+ id,
240
+ method,
241
+ params,
242
+ });
243
+
244
+ entry._pending.set(id, { resolve, reject });
245
+
246
+ // Set timeout
247
+ const timeout = setTimeout(() => {
248
+ entry._pending.delete(id);
249
+ reject(new Error(`Request timeout: ${method}`));
250
+ }, 30000);
251
+
252
+ entry._pending.get(id).timeout = timeout;
253
+
254
+ try {
255
+ entry.process.stdin.write(message + "\n");
256
+ } catch (err) {
257
+ clearTimeout(timeout);
258
+ entry._pending.delete(id);
259
+ reject(err);
260
+ }
261
+ });
262
+ }
263
+
264
+ _sendNotification(serverName, method, params) {
265
+ const entry = this.servers.get(serverName);
266
+ if (!entry || !entry.process) return;
267
+
268
+ const message = JSON.stringify({
269
+ jsonrpc: "2.0",
270
+ method,
271
+ params,
272
+ });
273
+
274
+ try {
275
+ entry.process.stdin.write(message + "\n");
276
+ } catch {
277
+ // Ignore notification errors
278
+ }
279
+ }
280
+
281
+ _handleData(serverName, data) {
282
+ const entry = this.servers.get(serverName);
283
+ if (!entry) return;
284
+
285
+ entry._buffer += data;
286
+
287
+ // Process complete JSON lines
288
+ const lines = entry._buffer.split("\n");
289
+ entry._buffer = lines.pop() || "";
290
+
291
+ for (const line of lines) {
292
+ const trimmed = line.trim();
293
+ if (!trimmed) continue;
294
+
295
+ try {
296
+ const msg = JSON.parse(trimmed);
297
+ this._handleMessage(serverName, msg);
298
+ } catch {
299
+ // Skip malformed lines
300
+ }
301
+ }
302
+ }
303
+
304
+ _handleMessage(serverName, msg) {
305
+ const entry = this.servers.get(serverName);
306
+ if (!entry) return;
307
+
308
+ // Response to a request
309
+ if (msg.id !== undefined && entry._pending.has(msg.id)) {
310
+ const { resolve, reject, timeout } = entry._pending.get(msg.id);
311
+ clearTimeout(timeout);
312
+ entry._pending.delete(msg.id);
313
+
314
+ if (msg.error) {
315
+ reject(new Error(msg.error.message || "Unknown error"));
316
+ } else {
317
+ resolve(msg.result);
318
+ }
319
+ return;
320
+ }
321
+
322
+ // Server notification
323
+ if (msg.method) {
324
+ this.emit("notification", {
325
+ server: serverName,
326
+ method: msg.method,
327
+ params: msg.params,
328
+ });
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * MCP server configuration storage.
335
+ * Persists server configs in the database.
336
+ */
337
+ export class MCPServerConfig {
338
+ constructor(db) {
339
+ this.db = db;
340
+ this._ensureTable();
341
+ }
342
+
343
+ _ensureTable() {
344
+ this.db.exec(`
345
+ CREATE TABLE IF NOT EXISTS mcp_servers (
346
+ name TEXT PRIMARY KEY,
347
+ command TEXT NOT NULL,
348
+ args TEXT DEFAULT '[]',
349
+ env TEXT DEFAULT '{}',
350
+ auto_connect INTEGER DEFAULT 0,
351
+ created_at TEXT DEFAULT (datetime('now')),
352
+ updated_at TEXT DEFAULT (datetime('now'))
353
+ )
354
+ `);
355
+ }
356
+
357
+ add(name, config) {
358
+ this.db
359
+ .prepare(
360
+ "INSERT OR REPLACE INTO mcp_servers (name, command, args, env, auto_connect, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now'))",
361
+ )
362
+ .run(
363
+ name,
364
+ config.command,
365
+ JSON.stringify(config.args || []),
366
+ JSON.stringify(config.env || {}),
367
+ config.autoConnect ? 1 : 0,
368
+ );
369
+ }
370
+
371
+ remove(name) {
372
+ const result = this.db
373
+ .prepare("DELETE FROM mcp_servers WHERE name = ?")
374
+ .run(name);
375
+ return result.changes > 0;
376
+ }
377
+
378
+ get(name) {
379
+ const row = this.db
380
+ .prepare("SELECT * FROM mcp_servers WHERE name = ?")
381
+ .get(name);
382
+ if (!row) return null;
383
+ return {
384
+ name: row.name,
385
+ command: row.command,
386
+ args: JSON.parse(row.args || "[]"),
387
+ env: JSON.parse(row.env || "{}"),
388
+ autoConnect: row.auto_connect === 1,
389
+ };
390
+ }
391
+
392
+ list() {
393
+ const rows = this.db
394
+ .prepare("SELECT * FROM mcp_servers ORDER BY name")
395
+ .all();
396
+ return rows.map((row) => ({
397
+ name: row.name,
398
+ command: row.command,
399
+ args: JSON.parse(row.args || "[]"),
400
+ env: JSON.parse(row.env || "{}"),
401
+ autoConnect: row.auto_connect === 1,
402
+ }));
403
+ }
404
+
405
+ getAutoConnect() {
406
+ const rows = this.db
407
+ .prepare("SELECT * FROM mcp_servers WHERE auto_connect = ? ORDER BY name")
408
+ .all(1);
409
+ return rows.map((row) => ({
410
+ name: row.name,
411
+ command: row.command,
412
+ args: JSON.parse(row.args || "[]"),
413
+ env: JSON.parse(row.env || "{}"),
414
+ autoConnect: true,
415
+ }));
416
+ }
417
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Mock LLM Provider — Phase 7 Parity Harness foundation.
3
+ *
4
+ * Produces a deterministic `chatWithTools`-shaped function from a scripted
5
+ * sequence of responses. Each script entry describes what the mock returns
6
+ * for the next LLM call, in order. This is the foundation for golden-
7
+ * transcript parity tests in `packages/cli/__tests__/integration/parity-*`.
8
+ *
9
+ * Design goals:
10
+ * - Zero network, zero provider SDK — pure in-memory dispatch
11
+ * - Same return shape as real `chatWithTools`: `{ message: { role, content, tool_calls? } }`
12
+ * - Supports tool_calls to drive multi-turn agent loops
13
+ * - Runs out of steps cleanly (throws a descriptive error) so tests can't
14
+ * silently loop forever
15
+ * - Optional match predicates per step let tests assert that the loop
16
+ * is feeding back the expected messages before the mock replies
17
+ *
18
+ * Example:
19
+ *
20
+ * const mock = createMockLLMProvider([
21
+ * {
22
+ * // First LLM call — ask for a tool invocation
23
+ * response: {
24
+ * message: {
25
+ * role: "assistant",
26
+ * content: "",
27
+ * tool_calls: [
28
+ * {
29
+ * id: "call_1",
30
+ * type: "function",
31
+ * function: {
32
+ * name: "read_file",
33
+ * arguments: JSON.stringify({ path: "README.md" }),
34
+ * },
35
+ * },
36
+ * ],
37
+ * },
38
+ * },
39
+ * },
40
+ * {
41
+ * // Second LLM call — produce the final assistant text
42
+ * expect: (messages) =>
43
+ * messages.some((m) => m.role === "tool" && m.name === "read_file"),
44
+ * response: {
45
+ * message: { role: "assistant", content: "Done." },
46
+ * },
47
+ * },
48
+ * ]);
49
+ *
50
+ * // Drop-in replacement for chatWithTools:
51
+ * await agentLoop(messages, { ...opts, chatFn: mock.chatFn });
52
+ *
53
+ * mock.assertDrained(); // verify the loop consumed every scripted response
54
+ */
55
+
56
+ /**
57
+ * @typedef {object} MockScriptStep
58
+ * @property {(messages: Array<object>, options: object) => boolean} [expect]
59
+ * Optional predicate run against the messages passed to the mock. If it
60
+ * returns false, the mock throws — this lets tests assert that the loop
61
+ * is in the expected state before it receives the next scripted reply.
62
+ * @property {object} response
63
+ * The value the mock returns. Must match the `chatWithTools` shape:
64
+ * `{ message: { role, content, tool_calls? } }`.
65
+ */
66
+
67
+ /**
68
+ * Create a mock LLM provider backed by a scripted sequence.
69
+ *
70
+ * @param {Array<MockScriptStep>} script
71
+ * @returns {{ chatFn: Function, calls: Array<{messages, options}>, assertDrained: Function, remaining: () => number }}
72
+ */
73
+ export function createMockLLMProvider(script) {
74
+ if (!Array.isArray(script)) {
75
+ throw new TypeError("Mock LLM script must be an array");
76
+ }
77
+
78
+ const calls = [];
79
+ let cursor = 0;
80
+
81
+ const chatFn = async function mockChatWithTools(messages, options) {
82
+ if (cursor >= script.length) {
83
+ throw new Error(
84
+ `Mock LLM script exhausted after ${cursor} call(s). The agent loop made ` +
85
+ `an unexpected extra LLM call. Add another script step or tighten the ` +
86
+ `loop's stop condition.`,
87
+ );
88
+ }
89
+
90
+ const step = script[cursor];
91
+ cursor += 1;
92
+
93
+ // Snapshot-before-mutation so test assertions see exactly what the mock
94
+ // was called with (agentLoop mutates its messages array in place).
95
+ const snapshot = messages.map((m) => ({ ...m }));
96
+ calls.push({ messages: snapshot, options });
97
+
98
+ if (typeof step.expect === "function") {
99
+ const ok = step.expect(snapshot, options);
100
+ if (!ok) {
101
+ throw new Error(
102
+ `Mock LLM script step ${cursor - 1} expectation failed. Messages ` +
103
+ `passed to the mock did not match the expected predicate. Received ` +
104
+ `${snapshot.length} messages; last role = "${snapshot[snapshot.length - 1]?.role}".`,
105
+ );
106
+ }
107
+ }
108
+
109
+ if (!step.response || !step.response.message) {
110
+ throw new Error(
111
+ `Mock LLM script step ${cursor - 1} is missing response.message. ` +
112
+ `Every step must return a chatWithTools-shaped { message: {...} }.`,
113
+ );
114
+ }
115
+
116
+ // Deep clone the response so tests can safely reuse script objects across
117
+ // multiple runs without the loop mutating their contents.
118
+ return {
119
+ message: JSON.parse(JSON.stringify(step.response.message)),
120
+ };
121
+ };
122
+
123
+ return {
124
+ chatFn,
125
+ calls,
126
+ remaining: () => script.length - cursor,
127
+ assertDrained() {
128
+ if (cursor !== script.length) {
129
+ throw new Error(
130
+ `Mock LLM script not fully consumed: ${cursor}/${script.length} steps called. ` +
131
+ `The agent loop stopped before reaching the end of the script.`,
132
+ );
133
+ }
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Convenience builder for a single tool_call assistant message.
140
+ * @param {string} toolName
141
+ * @param {object} args
142
+ * @param {string} [callId]
143
+ */
144
+ export function mockToolCallMessage(toolName, args, callId = "call_1") {
145
+ return {
146
+ role: "assistant",
147
+ content: "",
148
+ tool_calls: [
149
+ {
150
+ id: callId,
151
+ type: "function",
152
+ function: {
153
+ name: toolName,
154
+ arguments: JSON.stringify(args),
155
+ },
156
+ },
157
+ ],
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Convenience builder for a final assistant text message.
163
+ * @param {string} text
164
+ */
165
+ export function mockTextMessage(text) {
166
+ return { role: "assistant", content: text };
167
+ }