@lobu/worker 6.1.1 → 7.0.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.
Files changed (82) hide show
  1. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  2. package/dist/embedded/just-bash-bootstrap.js +26 -2
  3. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  4. package/dist/gateway/gateway-integration.js +4 -4
  5. package/dist/gateway/gateway-integration.js.map +1 -1
  6. package/dist/gateway/message-batcher.d.ts.map +1 -1
  7. package/dist/gateway/message-batcher.js +3 -5
  8. package/dist/gateway/message-batcher.js.map +1 -1
  9. package/dist/gateway/sse-client.d.ts +1 -0
  10. package/dist/gateway/sse-client.d.ts.map +1 -1
  11. package/dist/gateway/sse-client.js +8 -0
  12. package/dist/gateway/sse-client.js.map +1 -1
  13. package/dist/openclaw/worker.d.ts +0 -1
  14. package/dist/openclaw/worker.d.ts.map +1 -1
  15. package/dist/openclaw/worker.js +18 -75
  16. package/dist/openclaw/worker.js.map +1 -1
  17. package/dist/shared/tool-implementations.d.ts.map +1 -1
  18. package/dist/shared/tool-implementations.js +37 -13
  19. package/dist/shared/tool-implementations.js.map +1 -1
  20. package/package.json +14 -4
  21. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  22. package/src/__tests__/custom-tools.test.ts +92 -0
  23. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  24. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  25. package/src/__tests__/embedded-tools.test.ts +744 -0
  26. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  27. package/src/__tests__/exec-sandbox.test.ts +550 -0
  28. package/src/__tests__/generated-media.test.ts +142 -0
  29. package/src/__tests__/instructions.test.ts +60 -0
  30. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  31. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  32. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  33. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  34. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  35. package/src/__tests__/memory-flush.test.ts +64 -0
  36. package/src/__tests__/message-batcher.test.ts +247 -0
  37. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  38. package/src/__tests__/model-resolver.test.ts +156 -0
  39. package/src/__tests__/processor-harden.test.ts +269 -0
  40. package/src/__tests__/processor.test.ts +225 -0
  41. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  42. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  43. package/src/__tests__/sandbox-leak.test.ts +167 -0
  44. package/src/__tests__/setup.ts +102 -0
  45. package/src/__tests__/sse-client-harden.test.ts +588 -0
  46. package/src/__tests__/sse-client.test.ts +90 -0
  47. package/src/__tests__/tool-implementations.test.ts +196 -0
  48. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  49. package/src/__tests__/tool-policy.test.ts +269 -0
  50. package/src/__tests__/worker.test.ts +89 -0
  51. package/src/core/error-handler.ts +62 -0
  52. package/src/core/project-scanner.ts +65 -0
  53. package/src/core/types.ts +128 -0
  54. package/src/core/workspace.ts +89 -0
  55. package/src/embedded/exec-sandbox.ts +372 -0
  56. package/src/embedded/just-bash-bootstrap.ts +543 -0
  57. package/src/embedded/mcp-cli-commands.ts +402 -0
  58. package/src/gateway/gateway-integration.ts +298 -0
  59. package/src/gateway/message-batcher.ts +123 -0
  60. package/src/gateway/sse-client.ts +951 -0
  61. package/src/gateway/types.ts +68 -0
  62. package/src/index.ts +141 -0
  63. package/src/instructions/builder.ts +45 -0
  64. package/src/instructions/providers.ts +27 -0
  65. package/src/modules/lifecycle.ts +92 -0
  66. package/src/openclaw/custom-tools.ts +315 -0
  67. package/src/openclaw/instructions.ts +36 -0
  68. package/src/openclaw/model-resolver.ts +150 -0
  69. package/src/openclaw/plugin-loader.ts +427 -0
  70. package/src/openclaw/processor.ts +198 -0
  71. package/src/openclaw/sandbox-leak.ts +105 -0
  72. package/src/openclaw/session-context.ts +320 -0
  73. package/src/openclaw/tool-policy.ts +248 -0
  74. package/src/openclaw/tools.ts +277 -0
  75. package/src/openclaw/worker.ts +1847 -0
  76. package/src/server.ts +334 -0
  77. package/src/shared/audio-provider-suggestions.ts +132 -0
  78. package/src/shared/processor-utils.ts +33 -0
  79. package/src/shared/provider-auth-hints.ts +68 -0
  80. package/src/shared/tool-display-config.ts +75 -0
  81. package/src/shared/tool-implementations.ts +940 -0
  82. package/src/shared/worker-env-keys.ts +8 -0
@@ -0,0 +1,744 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type { BashOperations } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ createMcpAuthToolDefinitions,
14
+ createMcpToolDefinitions,
15
+ } from "../openclaw/custom-tools";
16
+ import {
17
+ getOpenClawSessionContext,
18
+ invalidateSessionContextCache,
19
+ } from "../openclaw/session-context";
20
+ import { createOpenClawTools } from "../openclaw/tools";
21
+ import { callMcpTool } from "../shared/tool-implementations";
22
+
23
+ let tempDir: string;
24
+
25
+ beforeEach(() => {
26
+ tempDir = mkdtempSync(join(tmpdir(), "embedded-tools-"));
27
+ });
28
+
29
+ afterEach(() => {
30
+ rmSync(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // createOpenClawTools — tool count and names
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe("createOpenClawTools", () => {
38
+ test("returns 7 tools (read, write, edit, bash, grep, find, ls)", () => {
39
+ const tools = createOpenClawTools(tempDir);
40
+ expect(tools).toHaveLength(7);
41
+ const names = tools.map((t) => t.name);
42
+ expect(names).toContain("read");
43
+ expect(names).toContain("write");
44
+ expect(names).toContain("edit");
45
+ expect(names).toContain("bash");
46
+ expect(names).toContain("grep");
47
+ expect(names).toContain("find");
48
+ expect(names).toContain("ls");
49
+ });
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Bash tool with custom BashOperations
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe("bash tool with BashOperations", () => {
57
+ test("uses provided BashOperations exec", async () => {
58
+ let capturedCommand = "";
59
+ let capturedCwd = "";
60
+ const mockBashOps: BashOperations = {
61
+ exec: async (command, cwd, { onData }) => {
62
+ capturedCommand = command;
63
+ capturedCwd = cwd;
64
+ onData(Buffer.from("mock output\n"));
65
+ return { exitCode: 0 };
66
+ },
67
+ };
68
+
69
+ const tools = createOpenClawTools(tempDir, {
70
+ bashOperations: mockBashOps,
71
+ });
72
+ const bashTool = tools.find((t) => t.name === "bash")!;
73
+ expect(bashTool).toBeDefined();
74
+
75
+ const result = await bashTool.execute(
76
+ "call-1",
77
+ { command: "echo hello" },
78
+ undefined,
79
+ undefined
80
+ );
81
+ expect(capturedCommand).toContain("echo hello");
82
+ expect(capturedCwd).toBe(tempDir);
83
+ // Result should contain the mock output
84
+ const text = result.content
85
+ .filter((c: any) => c.type === "text")
86
+ .map((c: any) => c.text)
87
+ .join("\n");
88
+ expect(text).toContain("mock output");
89
+ });
90
+
91
+ test("passes command string through correctly", async () => {
92
+ const commands: string[] = [];
93
+ const mockBashOps: BashOperations = {
94
+ exec: async (command, _cwd, { onData }) => {
95
+ commands.push(command);
96
+ onData(Buffer.from("ok\n"));
97
+ return { exitCode: 0 };
98
+ },
99
+ };
100
+
101
+ const tools = createOpenClawTools(tempDir, {
102
+ bashOperations: mockBashOps,
103
+ });
104
+ const bashTool = tools.find((t) => t.name === "bash")!;
105
+
106
+ await bashTool.execute(
107
+ "call-2",
108
+ { command: "ls -la /tmp && echo done" },
109
+ undefined,
110
+ undefined
111
+ );
112
+ expect(commands.length).toBeGreaterThanOrEqual(1);
113
+ expect(commands[0]).toContain("ls -la /tmp && echo done");
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // File tools with real filesystem
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe("file tools use real filesystem", () => {
122
+ test("read tool reads a real file", async () => {
123
+ const filePath = join(tempDir, "hello.txt");
124
+ writeFileSync(filePath, "hello world");
125
+
126
+ const tools = createOpenClawTools(tempDir);
127
+ const readTool = tools.find((t) => t.name === "read")!;
128
+ const result = await readTool.execute(
129
+ "call-read",
130
+ { path: filePath },
131
+ undefined,
132
+ undefined
133
+ );
134
+ const text = result.content
135
+ .filter((c: any) => c.type === "text")
136
+ .map((c: any) => c.text)
137
+ .join("\n");
138
+ expect(text).toContain("hello world");
139
+ });
140
+
141
+ test("write tool creates a real file", async () => {
142
+ const filePath = join(tempDir, "output.txt");
143
+
144
+ const tools = createOpenClawTools(tempDir);
145
+ const writeTool = tools.find((t) => t.name === "write")!;
146
+ await writeTool.execute(
147
+ "call-write",
148
+ { path: filePath, content: "new content" },
149
+ undefined,
150
+ undefined
151
+ );
152
+ expect(existsSync(filePath)).toBe(true);
153
+ expect(readFileSync(filePath, "utf-8")).toContain("new content");
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Bash tool proxy hint wrapper
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe("bash tool proxy hint", () => {
162
+ test("blocks direct package installs with error", async () => {
163
+ const mockBashOps: BashOperations = {
164
+ exec: async (_command, _cwd, { onData }) => {
165
+ onData(Buffer.from("ok\n"));
166
+ return { exitCode: 0 };
167
+ },
168
+ };
169
+
170
+ const tools = createOpenClawTools(tempDir, {
171
+ bashOperations: mockBashOps,
172
+ });
173
+ const bashTool = tools.find((t) => t.name === "bash")!;
174
+
175
+ const blockedCommands = [
176
+ "apt install curl",
177
+ "sudo apt-get install -y ffmpeg",
178
+ "brew install node",
179
+ "nix-shell -p python3",
180
+ "pip install requests",
181
+ "npm install -g @openai/codex",
182
+ "bash -lc 'pnpm add zod'",
183
+ ];
184
+
185
+ for (const cmd of blockedCommands) {
186
+ await expect(
187
+ bashTool.execute("call-block", { command: cmd }, undefined, undefined)
188
+ ).rejects.toThrow("DIRECT PACKAGE INSTALL BLOCKED");
189
+ }
190
+ });
191
+
192
+ test("adds proxy hint for HTTP 403 from proxy errors", async () => {
193
+ const mockBashOps: BashOperations = {
194
+ exec: async () => {
195
+ throw new Error("Received HTTP code 403 from proxy after CONNECT");
196
+ },
197
+ };
198
+
199
+ const tools = createOpenClawTools(tempDir, {
200
+ bashOperations: mockBashOps,
201
+ });
202
+ const bashTool = tools.find((t) => t.name === "bash")!;
203
+
204
+ await expect(
205
+ bashTool.execute(
206
+ "call-proxy",
207
+ { command: "curl https://blocked.example.com" },
208
+ undefined,
209
+ undefined
210
+ )
211
+ ).rejects.toThrow("DOMAIN BLOCKED BY PROXY");
212
+ });
213
+
214
+ test("blocks direct gateway API access from Bash", async () => {
215
+ const mockBashOps: BashOperations = {
216
+ exec: async (_command, _cwd, { onData }) => {
217
+ onData(Buffer.from("ok\n"));
218
+ return { exitCode: 0 };
219
+ },
220
+ };
221
+
222
+ const originalDispatcherUrl = process.env.DISPATCHER_URL;
223
+ const originalWorkerToken = process.env.WORKER_TOKEN;
224
+ process.env.DISPATCHER_URL = "http://gateway:8080";
225
+ process.env.WORKER_TOKEN = "secret-token";
226
+
227
+ try {
228
+ const tools = createOpenClawTools(tempDir, {
229
+ bashOperations: mockBashOps,
230
+ });
231
+ const bashTool = tools.find((t) => t.name === "bash")!;
232
+
233
+ const blockedCommands = [
234
+ 'curl "$DISPATCHER_URL/mcp/github/tools/list_repos" -H "Authorization: Bearer $WORKER_TOKEN"',
235
+ "curl http://gateway:8080/internal/device-auth/status?mcpId=github",
236
+ 'node -e \'fetch("http://gateway:8080/mcp/github/tools/list_repos", { method: "POST" })\'',
237
+ ];
238
+
239
+ for (const cmd of blockedCommands) {
240
+ await expect(
241
+ bashTool.execute(
242
+ "call-gateway",
243
+ { command: cmd },
244
+ undefined,
245
+ undefined
246
+ )
247
+ ).rejects.toThrow("DIRECT GATEWAY API ACCESS BLOCKED");
248
+ }
249
+ } finally {
250
+ if (originalDispatcherUrl === undefined) {
251
+ delete process.env.DISPATCHER_URL;
252
+ } else {
253
+ process.env.DISPATCHER_URL = originalDispatcherUrl;
254
+ }
255
+ if (originalWorkerToken === undefined) {
256
+ delete process.env.WORKER_TOKEN;
257
+ } else {
258
+ process.env.WORKER_TOKEN = originalWorkerToken;
259
+ }
260
+ }
261
+ });
262
+ });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // callMcpTool
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe("callMcpTool", () => {
269
+ const originalFetch = globalThis.fetch;
270
+ afterEach(() => {
271
+ globalThis.fetch = originalFetch;
272
+ });
273
+
274
+ const gw = {
275
+ gatewayUrl: "http://gateway:8080",
276
+ workerToken: "test-token-123",
277
+ channelId: "ch-1",
278
+ conversationId: "conv-1",
279
+ };
280
+
281
+ test("uses correct URL format", async () => {
282
+ let capturedUrl = "";
283
+ globalThis.fetch = async (url: any, _opts: any) => {
284
+ capturedUrl = typeof url === "string" ? url : url.toString();
285
+ return new Response(
286
+ JSON.stringify({
287
+ content: [{ type: "text", text: "ok" }],
288
+ }),
289
+ { status: 200, headers: { "Content-Type": "application/json" } }
290
+ );
291
+ };
292
+
293
+ await callMcpTool(gw, "lobu", "list_connections", { limit: 5 });
294
+ expect(capturedUrl).toBe(
295
+ "http://gateway:8080/mcp/lobu/tools/list_connections"
296
+ );
297
+ });
298
+
299
+ test("sends Authorization Bearer header", async () => {
300
+ let capturedHeaders: Record<string, string> = {};
301
+ globalThis.fetch = async (_url: any, opts: any) => {
302
+ capturedHeaders = opts?.headers || {};
303
+ return new Response(
304
+ JSON.stringify({
305
+ content: [{ type: "text", text: "ok" }],
306
+ }),
307
+ { status: 200, headers: { "Content-Type": "application/json" } }
308
+ );
309
+ };
310
+
311
+ await callMcpTool(gw, "lobu", "test_tool", {});
312
+ expect(capturedHeaders.Authorization).toBe("Bearer test-token-123");
313
+ expect(capturedHeaders["Content-Type"]).toBe("application/json");
314
+ });
315
+
316
+ test("formats successful response as TextResult", async () => {
317
+ globalThis.fetch = async () =>
318
+ new Response(
319
+ JSON.stringify({
320
+ content: [
321
+ { type: "text", text: "line 1" },
322
+ { type: "text", text: "line 2" },
323
+ ],
324
+ }),
325
+ { status: 200, headers: { "Content-Type": "application/json" } }
326
+ );
327
+
328
+ const result = await callMcpTool(gw, "mcp1", "my_tool", {});
329
+ expect(result.content).toHaveLength(1);
330
+ expect(result.content[0].type).toBe("text");
331
+ expect(result.content[0].text).toContain("line 1");
332
+ expect(result.content[0].text).toContain("line 2");
333
+ });
334
+
335
+ test("handles error response (isError=true)", async () => {
336
+ globalThis.fetch = async () =>
337
+ new Response(
338
+ JSON.stringify({
339
+ isError: true,
340
+ content: [{ type: "text", text: "something went wrong" }],
341
+ }),
342
+ { status: 200, headers: { "Content-Type": "application/json" } }
343
+ );
344
+
345
+ const result = await callMcpTool(gw, "mcp1", "fail_tool", {});
346
+ expect(result.content[0].text).toContain("Error:");
347
+ expect(result.content[0].text).toContain("something went wrong");
348
+ });
349
+
350
+ test("handles non-ok HTTP response", async () => {
351
+ globalThis.fetch = async () =>
352
+ new Response(
353
+ JSON.stringify({
354
+ error: "not found",
355
+ content: [],
356
+ }),
357
+ { status: 404, headers: { "Content-Type": "application/json" } }
358
+ );
359
+
360
+ const result = await callMcpTool(gw, "mcp1", "missing_tool", {});
361
+ expect(result.content[0].text).toContain("Error:");
362
+ });
363
+ });
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // MCP auth tools
367
+ // ---------------------------------------------------------------------------
368
+
369
+ describe("createMcpAuthToolDefinitions", () => {
370
+ const originalFetch = globalThis.fetch;
371
+
372
+ afterEach(() => {
373
+ globalThis.fetch = originalFetch;
374
+ });
375
+
376
+ const gw = {
377
+ gatewayUrl: "http://gateway:8080",
378
+ workerToken: "worker-token",
379
+ channelId: "channel-1",
380
+ conversationId: "conv-1",
381
+ platform: "slack",
382
+ };
383
+
384
+ test("creates login, login_check, and logout tools for auth-capable MCPs", () => {
385
+ const tools = createMcpAuthToolDefinitions(
386
+ [
387
+ {
388
+ id: "github",
389
+ name: "GitHub",
390
+ requiresAuth: true,
391
+ authenticated: false,
392
+ configured: true,
393
+ },
394
+ {
395
+ id: "plain",
396
+ name: "Plain",
397
+ requiresAuth: false,
398
+ authenticated: false,
399
+ configured: true,
400
+ },
401
+ ] as any,
402
+ gw
403
+ );
404
+
405
+ expect(tools.map((tool) => tool.name)).toEqual([
406
+ "github_login",
407
+ "github_login_check",
408
+ "github_logout",
409
+ ]);
410
+ });
411
+
412
+ test("skips auth tools that would collide with existing tool names", () => {
413
+ const tools = createMcpAuthToolDefinitions(
414
+ [
415
+ {
416
+ id: "lobu",
417
+ name: "Lobu",
418
+ requiresAuth: true,
419
+ authenticated: false,
420
+ configured: true,
421
+ },
422
+ ] as any,
423
+ gw,
424
+ new Set(["lobu_login"])
425
+ );
426
+
427
+ expect(tools.map((tool) => tool.name)).toEqual([
428
+ "lobu_login_check",
429
+ "lobu_logout",
430
+ ]);
431
+ });
432
+
433
+ test("login tool returns a structured login_started payload", async () => {
434
+ globalThis.fetch = async (input, init) => {
435
+ const url = typeof input === "string" ? input : input.url;
436
+ if (url.endsWith("/internal/device-auth/status?mcpId=github")) {
437
+ return new Response(JSON.stringify({ authenticated: false }), {
438
+ status: 200,
439
+ headers: { "Content-Type": "application/json" },
440
+ });
441
+ }
442
+ if (
443
+ url.endsWith("/internal/device-auth/start") &&
444
+ init?.method === "POST"
445
+ ) {
446
+ return new Response(
447
+ JSON.stringify({
448
+ userCode: "CODE-123",
449
+ verificationUri: "https://example.com/device",
450
+ verificationUriComplete: "https://example.com/device?code=CODE-123",
451
+ expiresIn: 600,
452
+ }),
453
+ {
454
+ status: 200,
455
+ headers: { "Content-Type": "application/json" },
456
+ }
457
+ );
458
+ }
459
+ if (
460
+ url.endsWith("/internal/interactions/create") &&
461
+ init?.method === "POST"
462
+ ) {
463
+ return new Response(JSON.stringify({ id: "link-123" }), {
464
+ status: 200,
465
+ headers: { "Content-Type": "application/json" },
466
+ });
467
+ }
468
+ throw new Error(`Unexpected fetch: ${url}`);
469
+ };
470
+
471
+ const [loginTool] = createMcpAuthToolDefinitions(
472
+ [
473
+ {
474
+ id: "github",
475
+ name: "GitHub",
476
+ requiresAuth: true,
477
+ authenticated: false,
478
+ configured: true,
479
+ },
480
+ ] as any,
481
+ gw
482
+ );
483
+
484
+ const result = await loginTool!.execute("call-1", {}, undefined, undefined);
485
+ const text = result.content
486
+ .filter((c: any) => c.type === "text")
487
+ .map((c: any) => c.text)
488
+ .join("\n");
489
+ const parsed = JSON.parse(text);
490
+
491
+ expect(parsed.status).toBe("login_started");
492
+ expect(parsed.mcp_id).toBe("github");
493
+ expect(parsed.user_code).toBe("CODE-123");
494
+ expect(parsed.interaction_posted).toBe(true);
495
+ });
496
+ });
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // createMcpToolDefinitions
500
+ // ---------------------------------------------------------------------------
501
+
502
+ describe("createMcpToolDefinitions", () => {
503
+ const gw = {
504
+ gatewayUrl: "http://gateway:8080",
505
+ workerToken: "tok",
506
+ channelId: "ch",
507
+ conversationId: "conv",
508
+ };
509
+
510
+ test("creates N ToolDefinitions for N MCP tools", () => {
511
+ const mcpTools = {
512
+ lobu: [
513
+ { name: "list_connections", description: "List connections" },
514
+ { name: "manage_connections", description: "Manage connections" },
515
+ ],
516
+ another: [{ name: "do_stuff" }],
517
+ };
518
+
519
+ const defs = createMcpToolDefinitions(mcpTools, gw);
520
+ expect(defs).toHaveLength(3);
521
+ });
522
+
523
+ test("tool names match MCP tool names", () => {
524
+ const mcpTools = {
525
+ lobu: [
526
+ { name: "list_connections", description: "List" },
527
+ { name: "create_issue", description: "Create" },
528
+ ],
529
+ };
530
+
531
+ const defs = createMcpToolDefinitions(mcpTools, gw);
532
+ const names = defs.map((d) => d.name);
533
+ expect(names).toContain("list_connections");
534
+ expect(names).toContain("create_issue");
535
+ });
536
+
537
+ test("tool label includes mcpId", () => {
538
+ const mcpTools = {
539
+ lobu: [{ name: "test_tool", description: "Test" }],
540
+ };
541
+
542
+ const defs = createMcpToolDefinitions(mcpTools, gw);
543
+ expect(defs[0].label).toBe("lobu/test_tool");
544
+ });
545
+
546
+ test("tool description includes mcpId when no description provided", () => {
547
+ const mcpTools = {
548
+ myserver: [{ name: "unnamed_tool" }],
549
+ };
550
+
551
+ const defs = createMcpToolDefinitions(mcpTools, gw);
552
+ expect(defs[0].description).toContain("myserver");
553
+ });
554
+
555
+ test("uses provided description when available", () => {
556
+ const mcpTools = {
557
+ lobu: [{ name: "list_connections", description: "List all connections" }],
558
+ };
559
+
560
+ const defs = createMcpToolDefinitions(mcpTools, gw);
561
+ expect(defs[0].description).toBe("List all connections");
562
+ });
563
+
564
+ test("prepends mcpContext instructions to tool descriptions", () => {
565
+ const mcpTools = {
566
+ lobu: [
567
+ { name: "store_memory", description: "Store a memory entry" },
568
+ { name: "recall_memory", description: "Recall stored memories" },
569
+ ],
570
+ other: [{ name: "do_thing", description: "Does a thing" }],
571
+ };
572
+ const mcpContext = {
573
+ lobu: "Check memory at conversation start",
574
+ };
575
+
576
+ const defs = createMcpToolDefinitions(mcpTools, gw, mcpContext);
577
+
578
+ expect(defs[0].description).toBe(
579
+ "[Check memory at conversation start] Store a memory entry"
580
+ );
581
+ expect(defs[1].description).toBe(
582
+ "[Check memory at conversation start] Recall stored memories"
583
+ );
584
+ // "other" has no context — description unchanged
585
+ expect(defs[2].description).toBe("Does a thing");
586
+ });
587
+
588
+ test("works without mcpContext (backwards compatible)", () => {
589
+ const mcpTools = {
590
+ lobu: [{ name: "test_tool", description: "Original desc" }],
591
+ };
592
+
593
+ const defs = createMcpToolDefinitions(mcpTools, gw);
594
+ expect(defs[0].description).toBe("Original desc");
595
+
596
+ const defs2 = createMcpToolDefinitions(mcpTools, gw, undefined);
597
+ expect(defs2[0].description).toBe("Original desc");
598
+
599
+ const defs3 = createMcpToolDefinitions(mcpTools, gw, {});
600
+ expect(defs3[0].description).toBe("Original desc");
601
+ });
602
+
603
+ test("execute calls callMcpTool with correct args", async () => {
604
+ const originalFetch = globalThis.fetch;
605
+ let capturedUrl = "";
606
+ let capturedBody = "";
607
+ globalThis.fetch = async (url: any, opts: any) => {
608
+ capturedUrl = typeof url === "string" ? url : url.toString();
609
+ capturedBody = opts?.body || "";
610
+ return new Response(
611
+ JSON.stringify({
612
+ content: [{ type: "text", text: "result data" }],
613
+ }),
614
+ { status: 200, headers: { "Content-Type": "application/json" } }
615
+ );
616
+ };
617
+
618
+ try {
619
+ const mcpTools = {
620
+ lobu: [{ name: "list_connections", description: "List" }],
621
+ };
622
+
623
+ const defs = createMcpToolDefinitions(mcpTools, gw);
624
+ const tool = defs[0];
625
+
626
+ const result = await tool.execute(
627
+ "call-id",
628
+ { limit: 10 },
629
+ undefined,
630
+ undefined,
631
+ {} as any
632
+ );
633
+
634
+ expect(capturedUrl).toBe(
635
+ "http://gateway:8080/mcp/lobu/tools/list_connections"
636
+ );
637
+ expect(JSON.parse(capturedBody)).toEqual({ limit: 10 });
638
+ expect(result.content[0].text).toContain("result data");
639
+ } finally {
640
+ globalThis.fetch = originalFetch;
641
+ }
642
+ });
643
+ });
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Session context cache TTL
647
+ // ---------------------------------------------------------------------------
648
+
649
+ describe("session context cache TTL", () => {
650
+ const originalFetch = globalThis.fetch;
651
+ const originalDateNow = Date.now;
652
+
653
+ function makeSessionResponse() {
654
+ return {
655
+ agentInstructions: "test agent",
656
+ platformInstructions: "test platform",
657
+ networkInstructions: "test network",
658
+ skillsInstructions: "test skills",
659
+ mcpStatus: [],
660
+ mcpTools: {},
661
+ mcpInstructions: {},
662
+ mcpContext: { lobu: "Check memory" },
663
+ providerConfig: {},
664
+ skillsConfig: [],
665
+ };
666
+ }
667
+
668
+ beforeEach(() => {
669
+ invalidateSessionContextCache();
670
+ process.env.DISPATCHER_URL = "http://gateway:8080";
671
+ process.env.WORKER_TOKEN = "test-token";
672
+ });
673
+
674
+ afterEach(() => {
675
+ globalThis.fetch = originalFetch;
676
+ Date.now = originalDateNow;
677
+ delete process.env.DISPATCHER_URL;
678
+ delete process.env.WORKER_TOKEN;
679
+ invalidateSessionContextCache();
680
+ });
681
+
682
+ test("caches result and returns it on second call", async () => {
683
+ let fetchCount = 0;
684
+ globalThis.fetch = async () => {
685
+ fetchCount++;
686
+ return new Response(JSON.stringify(makeSessionResponse()), {
687
+ status: 200,
688
+ headers: { "Content-Type": "application/json" },
689
+ });
690
+ };
691
+
692
+ const first = await getOpenClawSessionContext();
693
+ const second = await getOpenClawSessionContext();
694
+
695
+ expect(fetchCount).toBe(1);
696
+ expect(first.mcpContext).toEqual({ lobu: "Check memory" });
697
+ expect(second.mcpContext).toEqual({ lobu: "Check memory" });
698
+ });
699
+
700
+ test("re-fetches after cache TTL expires (5 minutes)", async () => {
701
+ let fetchCount = 0;
702
+ let currentTime = 1000000;
703
+ Date.now = () => currentTime;
704
+
705
+ globalThis.fetch = async () => {
706
+ fetchCount++;
707
+ return new Response(JSON.stringify(makeSessionResponse()), {
708
+ status: 200,
709
+ headers: { "Content-Type": "application/json" },
710
+ });
711
+ };
712
+
713
+ await getOpenClawSessionContext();
714
+ expect(fetchCount).toBe(1);
715
+
716
+ // Still within TTL (4 minutes later)
717
+ currentTime += 4 * 60 * 1000;
718
+ await getOpenClawSessionContext();
719
+ expect(fetchCount).toBe(1);
720
+
721
+ // Past TTL (6 minutes from original)
722
+ currentTime += 2 * 60 * 1000;
723
+ await getOpenClawSessionContext();
724
+ expect(fetchCount).toBe(2);
725
+ });
726
+
727
+ test("invalidateSessionContextCache forces re-fetch", async () => {
728
+ let fetchCount = 0;
729
+ globalThis.fetch = async () => {
730
+ fetchCount++;
731
+ return new Response(JSON.stringify(makeSessionResponse()), {
732
+ status: 200,
733
+ headers: { "Content-Type": "application/json" },
734
+ });
735
+ };
736
+
737
+ await getOpenClawSessionContext();
738
+ expect(fetchCount).toBe(1);
739
+
740
+ invalidateSessionContextCache();
741
+ await getOpenClawSessionContext();
742
+ expect(fetchCount).toBe(2);
743
+ });
744
+ });