shadow-claw 1.40.0 → 1.40.1

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 (86) hide show
  1. package/.well-known/mcp/server-card.json +2 -2
  2. package/.well-known/mcp/servers.json +1 -1
  3. package/.well-known/mcp.json +2 -2
  4. package/dist/cli/cli.js +2 -2
  5. package/dist/lib/components/index.js +2 -2
  6. package/dist/lib/components/shadow-claw-file-viewer/shadow-claw-file-viewer.d.ts +1 -1
  7. package/dist/lib/components/shadow-claw-file-viewer.js +1 -1
  8. package/dist/lib/components/shadow-claw.js +1 -1
  9. package/dist/lib/core/orchestrator/orchestrator-state.d.ts +2 -1
  10. package/dist/lib/core/orchestrator/orchestrator.d.ts +2 -1
  11. package/dist/lib/core/orchestrator/utils/operations/task.d.ts +1 -0
  12. package/dist/lib/index.js +2 -2
  13. package/dist/lib/{orchestrator-Bq9ft3uG.js → orchestrator-CMN43L0F.js} +9 -13
  14. package/dist/lib/orchestrator-CMN43L0F.js.map +1 -0
  15. package/dist/lib/orchestrator-D9f5fDCb.js.map +1 -1
  16. package/dist/lib/{shadow-claw-MumteAJy.js → shadow-claw-4x3LqlII.js} +2 -2
  17. package/dist/lib/{shadow-claw-MumteAJy.js.map → shadow-claw-4x3LqlII.js.map} +1 -1
  18. package/dist/lib/{shadow-claw-file-viewer-CMOnFmcP.js → shadow-claw-file-viewer-CuDhkVcL.js} +21 -21
  19. package/dist/lib/{shadow-claw-file-viewer-CMOnFmcP.js.map → shadow-claw-file-viewer-CuDhkVcL.js.map} +1 -1
  20. package/dist/lib/syncWebMcpRegistration-Bbph0XZl.js.map +1 -1
  21. package/dist/public/.well-known/mcp/server-card.json +2 -2
  22. package/dist/public/.well-known/mcp/servers.json +1 -1
  23. package/dist/public/.well-known/mcp.json +2 -2
  24. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  25. package/dist/public/docs/example/article/index.html +2 -2
  26. package/dist/public/docs/publishing/index.html +2 -2
  27. package/dist/public/docs/skill-creator/index.html +2 -2
  28. package/dist/public/index.html +2 -2
  29. package/dist/public/index.js +1 -1
  30. package/dist/public/main/index.html +2 -2
  31. package/dist/public/main/memory/index.html +2 -2
  32. package/dist/public/{orchestrator-BLkS_jpm.js → orchestrator-Dc_Q73La.js} +1 -1
  33. package/dist/public/service-worker.js +1 -1
  34. package/dist/public/{shadow-claw-DmrxnX3Q.js → shadow-claw-Dabb2AEp.js} +2 -2
  35. package/dist/public/{shadow-claw-file-viewer-a_uRA30F.js → shadow-claw-file-viewer-CDs1QGXk.js} +1 -1
  36. package/dist/server.js +1 -1
  37. package/package.json +1 -1
  38. package/src/cli/cli.ts +2 -2
  39. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.ts +31 -31
  40. package/src/core/effect.test.ts +19 -0
  41. package/src/core/orchestrator/orchestrator-state.ts +2 -1
  42. package/src/core/orchestrator/orchestrator.test.ts +686 -5
  43. package/src/core/orchestrator/orchestrator.ts +3 -10
  44. package/src/core/orchestrator/utils/compactContext.test.ts +423 -121
  45. package/src/core/orchestrator/utils/configurePeerJs.test.ts +27 -0
  46. package/src/core/orchestrator/utils/deliverResponse.test.ts +127 -0
  47. package/src/core/orchestrator/utils/dispatchSubagentInvoke.test.ts +23 -0
  48. package/src/core/orchestrator/utils/enqueue.test.ts +520 -86
  49. package/src/core/orchestrator/utils/handleWorkerMessage.test.ts +664 -144
  50. package/src/core/orchestrator/utils/initTasks.node.test.ts +147 -0
  51. package/src/core/orchestrator/utils/initTasks.test.ts +926 -191
  52. package/src/core/orchestrator/utils/initTasks.ts +5 -4
  53. package/src/core/orchestrator/utils/invokeAgent.test.ts +563 -110
  54. package/src/core/orchestrator/utils/invokeAgent.ts +8 -10
  55. package/src/core/orchestrator/utils/loadChannelConfigurations.test.ts +50 -0
  56. package/src/core/orchestrator/utils/operations/channel.test.ts +377 -170
  57. package/src/core/orchestrator/utils/operations/provider.test.ts +663 -85
  58. package/src/core/orchestrator/utils/operations/room.test.ts +74 -11
  59. package/src/core/orchestrator/utils/operations/task.test.ts +452 -56
  60. package/src/core/orchestrator/utils/operations/task.ts +1 -1
  61. package/src/core/orchestrator/utils/operations/vm.test.ts +66 -7
  62. package/src/core/orchestrator/utils/parseDirectToolCommand.test.ts +6 -0
  63. package/src/core/orchestrator/utils/settings.test.ts +248 -81
  64. package/src/core/orchestrator/utils/setupPushTaskListener.test.ts +58 -0
  65. package/src/core/orchestrator/utils/syncWebMcpRegistration.test.ts +341 -65
  66. package/src/core/orchestrator/utils/syncWebMcpRegistration.ts +4 -2
  67. package/src/server/routes/mcp.ts +2 -2
  68. package/src/stores/file-viewer.test.ts +11 -0
  69. package/src/stores/theme.test.ts +41 -2
  70. package/src/stores/toast.test.ts +25 -0
  71. package/src/worker/tools/bash/native-bash-executor.test.ts +31 -1
  72. package/src/worker/tools/git/utils/extractConflictPaths.test.ts +24 -0
  73. package/src/worker/tools/git/utils/parseConflictRegions.test.ts +46 -0
  74. package/src/worker/tools/git/utils/resolveCorsProxy.test.ts +26 -5
  75. package/src/worker/tools/git/utils/truncateSnippet.test.ts +16 -0
  76. package/src/worker/tools/node-transformers-executor.test.ts +105 -6
  77. package/src/worker/tools/remote-mcp/remote-mcp.test.ts +172 -47
  78. package/src/worker/tools/remote-mcp/utils/formatListToolsOutput.test.ts +19 -0
  79. package/src/worker/tools/remote-mcp/utils/isMcpReauthError.test.ts +45 -0
  80. package/src/worker/tools/remote-mcp/utils/resolveMcpReauth.test.ts +48 -0
  81. package/src/worker/tools/rooms/list-room-members.test.ts +24 -10
  82. package/src/worker/tools/tasks/list-tasks.test.ts +39 -8
  83. package/src/worker/tools/tasks/update-task.test.ts +78 -14
  84. package/src/worker/tools/ui/javascript.test.ts +70 -17
  85. package/src/worker/tools/ui/list-tool-profiles.test.ts +31 -9
  86. package/dist/lib/orchestrator-Bq9ft3uG.js.map +0 -1
@@ -61,6 +61,18 @@ describe("setupPushTaskListener", () => {
61
61
  {} as ShadowClawDatabase,
62
62
  ),
63
63
  ).not.toThrow();
64
+
65
+ Object.defineProperty(global, "navigator", {
66
+ value: {},
67
+ configurable: true,
68
+ });
69
+
70
+ expect(() =>
71
+ setupPushTaskListener(
72
+ mockOrchestrator as Orchestrator,
73
+ {} as ShadowClawDatabase,
74
+ ),
75
+ ).not.toThrow();
64
76
  });
65
77
 
66
78
  it("should register a message listener if serviceWorker exists", () => {
@@ -203,6 +215,52 @@ describe("setupPushTaskListener", () => {
203
215
  );
204
216
  });
205
217
 
218
+ it("should ignore events without data or without groupId", () => {
219
+ listener({ data: null });
220
+ listener({ data: { type: "scheduled-task-trigger", taskId: "t1" } });
221
+ expect(mockOrchestrator.schedulerTriggeredGroups?.size).toBe(0);
222
+ });
223
+
224
+ it("should create default taskId and prompt for tool task when omitted", async () => {
225
+ mockOrchestratorStore.tasks = [];
226
+
227
+ listener({
228
+ data: {
229
+ type: "scheduled-task-trigger",
230
+ groupId: "g2",
231
+ taskType: "tools",
232
+ tools: ["t1"],
233
+ },
234
+ });
235
+
236
+ await new Promise(process.nextTick);
237
+
238
+ expect(mockOrchestratorStore.runTask).toHaveBeenCalledWith(
239
+ expect.objectContaining({
240
+ groupId: "g2",
241
+ type: "tools",
242
+ tools: ["t1"],
243
+ prompt: "",
244
+ }),
245
+ );
246
+ });
247
+
248
+ it("should do nothing if neither task, tools, nor prompt are present", async () => {
249
+ mockOrchestratorStore.tasks = [];
250
+
251
+ listener({
252
+ data: {
253
+ type: "scheduled-task-trigger",
254
+ groupId: "g-empty",
255
+ },
256
+ });
257
+
258
+ await new Promise(process.nextTick);
259
+
260
+ expect(mockOrchestratorStore.runTask).not.toHaveBeenCalled();
261
+ expect(mockSubmitMessage).not.toHaveBeenCalled();
262
+ });
263
+
206
264
  it("should handle error in runTaskHandler", async () => {
207
265
  mockOrchestratorStore.tasks = [];
208
266
  mockSubmitMessage.mockImplementation(() => {
@@ -1,34 +1,72 @@
1
1
  import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
2
 
3
- const mockRegisterWebMcpTools = jest.fn() as any;
4
- const mockUnregisterWebMcpTools = jest.fn() as any;
3
+ import { CONFIG_KEYS } from "../../../config/config.js";
4
+ import type { ShadowClawDatabase } from "../../../db/db.js";
5
+ import type { DeclarativeToolDefinition } from "../../../subsystems/tools/declarative.js";
6
+ import type { SkillRecord } from "../../../subsystems/skills/types.js";
7
+ import type { ToolDefinition } from "../../../subsystems/tools/tools.js";
8
+ import type { WebMcpMode } from "../../../subsystems/mcp/webmcp.js";
9
+ import { activate_skill } from "../../../subsystems/skills/tool.js";
10
+ import type { Orchestrator } from "../orchestrator.js";
11
+
12
+ const mockRegisterWebMcpTools = jest
13
+ .fn<
14
+ (
15
+ worker: Worker | null,
16
+ onMessage: (msg: unknown) => Promise<void>,
17
+ activeGroupId: string,
18
+ tools: ToolDefinition[],
19
+ ) => Promise<void>
20
+ >()
21
+ .mockResolvedValue(undefined);
22
+
23
+ const mockUnregisterWebMcpTools = jest.fn<() => void>();
24
+ const mockApplyWebMcpMode = jest.fn<(mode: WebMcpMode) => void>();
5
25
 
6
26
  jest.unstable_mockModule("../../../subsystems/mcp/webmcp.js", () => ({
7
27
  registerWebMcpTools: mockRegisterWebMcpTools,
8
28
  unregisterWebMcpTools: mockUnregisterWebMcpTools,
9
- setWebMcpMode: jest.fn(),
29
+ setWebMcpMode: mockApplyWebMcpMode,
10
30
  }));
11
31
 
12
- const mockHandleWorkerMessage = jest.fn() as any;
32
+ const mockHandleWorkerMessage = jest
33
+ .fn<
34
+ (
35
+ orchestrator: Orchestrator,
36
+ db: ShadowClawDatabase,
37
+ msg: unknown,
38
+ ) => Promise<void>
39
+ >()
40
+ .mockResolvedValue(undefined);
41
+
13
42
  jest.unstable_mockModule("./handleWorkerMessage.js", () => ({
14
43
  handleWorkerMessage: mockHandleWorkerMessage,
15
44
  }));
16
45
 
17
46
  jest.unstable_mockModule("../../effect.js", () => ({
18
- effect: (cb: any) => {
47
+ effect: (cb: () => void) => {
19
48
  cb();
20
49
  return jest.fn();
21
50
  },
22
51
  }));
23
52
 
24
- jest.unstable_mockModule("../../../stores/orchestrator.js", () => ({
25
- orchestratorStore: {
26
- activeGroupId: "group1",
27
- groups: [{ groupId: "group1", toolTags: ["tool1"] }, { groupId: "group2" }],
28
- },
53
+ const mockSetConfig = jest
54
+ .fn<(db: ShadowClawDatabase, key: string, value: string) => Promise<void>>()
55
+ .mockResolvedValue(undefined);
56
+
57
+ jest.unstable_mockModule("../../../db/setConfig.js", () => ({
58
+ setConfig: mockSetConfig,
29
59
  }));
30
60
 
31
- const mockDiscoverSkills = jest.fn() as any;
61
+ const mockDiscoverSkills = jest
62
+ .fn<
63
+ (
64
+ db: ShadowClawDatabase,
65
+ groupId: string,
66
+ ) => Promise<{ skills: SkillRecord[] }>
67
+ >()
68
+ .mockResolvedValue({ skills: [] });
69
+
32
70
  jest.unstable_mockModule(
33
71
  "../../../subsystems/skills/discoverSkills.js",
34
72
  () => ({
@@ -36,79 +74,147 @@ jest.unstable_mockModule(
36
74
  }),
37
75
  );
38
76
 
39
- const mockLoadDeclarativeTools = jest.fn() as any;
77
+ const mockLoadDeclarativeTools = jest
78
+ .fn<
79
+ (
80
+ db: ShadowClawDatabase,
81
+ groupId: string,
82
+ ) => Promise<{ tools: DeclarativeToolDefinition[] }>
83
+ >()
84
+ .mockResolvedValue({ tools: [] });
85
+
40
86
  jest.unstable_mockModule("../../../subsystems/tools/declarative.js", () => ({
41
87
  loadDeclarativeTools: mockLoadDeclarativeTools,
42
88
  }));
43
89
 
90
+ let activeGroupId = "group1";
91
+ const mockGroups: { groupId: string; toolTags?: string[] }[] = [
92
+ { groupId: "group1", toolTags: ["tool1", "enabled_decl_tool"] },
93
+ { groupId: "group2" },
94
+ ];
95
+ const mockAllTools: ToolDefinition[] = [
96
+ {
97
+ name: "tool1",
98
+ description: "Tool 1",
99
+ input_schema: { type: "object", properties: {} },
100
+ },
101
+ {
102
+ name: "tool2",
103
+ description: "Tool 2",
104
+ input_schema: { type: "object", properties: {} },
105
+ },
106
+ ];
107
+ let mockGlobalTools: ToolDefinition[] = [
108
+ {
109
+ name: "tool2",
110
+ description: "Tool 2",
111
+ input_schema: { type: "object", properties: {} },
112
+ },
113
+ ];
114
+ let declarativeToolsFail = false;
115
+
116
+ jest.unstable_mockModule("../../../stores/orchestrator.js", () => ({
117
+ orchestratorStore: {
118
+ get activeGroupId() {
119
+ return activeGroupId;
120
+ },
121
+ get groups() {
122
+ return mockGroups;
123
+ },
124
+ },
125
+ }));
126
+
44
127
  jest.unstable_mockModule("../../../stores/tools.js", () => ({
45
128
  toolsStore: {
46
- allTools: [{ name: "tool1" }, { name: "tool2" }],
47
- enabledTools: [{ name: "tool2" }],
129
+ get allTools() {
130
+ return mockAllTools;
131
+ },
132
+ get enabledTools() {
133
+ return mockGlobalTools;
134
+ },
48
135
  declarativeTools: [],
49
136
  declarativeToolNamesEnabled: null,
50
- refreshDeclarativeTools: jest.fn(async (db: any, groupId: any) => {
51
- const res = await mockLoadDeclarativeTools(db, groupId);
52
- return res?.tools || [];
53
- }),
54
- isDeclarativeToolEnabled: jest.fn(() => true),
137
+ refreshDeclarativeTools: jest.fn(
138
+ async (db: ShadowClawDatabase, gId: string) => {
139
+ if (declarativeToolsFail) {
140
+ throw new Error("OPFS declarative tools load failed");
141
+ }
142
+ const res = await mockLoadDeclarativeTools(db, gId);
143
+ return res?.tools || [];
144
+ },
145
+ ),
146
+ isDeclarativeToolEnabled: jest.fn(
147
+ (name: string) => name === "enabled_decl_tool",
148
+ ),
55
149
  },
56
150
  }));
57
151
 
58
- const { syncWebMcpRegistration } = await import("./syncWebMcpRegistration.js");
152
+ const { syncWebMcpRegistration, setWebMcpMode, setWebMcpToolsEnabled } =
153
+ await import("./syncWebMcpRegistration.js");
154
+
155
+ interface MockOrchestrator {
156
+ webMcpEffectCleanup: (() => void) | null;
157
+ webMcpToolsEnabled: boolean;
158
+ webMcpRegistrationLock: Promise<void>;
159
+ agentWorker: Worker | null;
160
+ }
59
161
 
60
162
  describe("syncWebMcpRegistration", () => {
61
- let mockOrchestrator: any;
62
- let mockDb: any;
163
+ let mockOrchestrator: MockOrchestrator;
164
+ let mockDb: ShadowClawDatabase;
63
165
 
64
166
  beforeEach(() => {
65
167
  jest.clearAllMocks();
66
- mockDb = {};
168
+ activeGroupId = "group1";
169
+ declarativeToolsFail = false;
170
+ mockGlobalTools = [
171
+ {
172
+ name: "tool2",
173
+ description: "Tool 2",
174
+ input_schema: { type: "object", properties: {} },
175
+ },
176
+ ];
177
+ mockDb = {} as unknown as ShadowClawDatabase;
67
178
  mockOrchestrator = {
68
179
  webMcpEffectCleanup: jest.fn(),
69
180
  webMcpToolsEnabled: true,
70
181
  webMcpRegistrationLock: Promise.resolve(),
71
- agentWorker: {},
72
- handleWorkerMessage: jest.fn(),
182
+ agentWorker: {} as unknown as Worker,
73
183
  };
74
184
  mockDiscoverSkills.mockResolvedValue({ skills: [] });
75
185
  mockLoadDeclarativeTools.mockResolvedValue({
76
186
  tools: [
77
187
  {
78
- name: "generate_random_number",
79
- description: "Generate random number",
188
+ name: "enabled_decl_tool",
189
+ description: "Enabled Declarative Tool",
190
+ input_schema: { type: "object", properties: {} },
191
+ execution: { type: "bash", command: "echo test" },
192
+ },
193
+ {
194
+ name: "disabled_decl_tool",
195
+ description: "Disabled Declarative Tool",
196
+ input_schema: { type: "object", properties: {} },
197
+ execution: { type: "bash", command: "echo disabled" },
80
198
  },
81
- { name: "unpinned_decl_tool", description: "Unpinned tool" },
82
199
  ],
83
200
  });
84
201
  });
85
202
 
86
203
  it("should clean up existing effect and unregister if not enabled", () => {
87
204
  mockOrchestrator.webMcpToolsEnabled = false;
205
+ const cleanupMock = mockOrchestrator.webMcpEffectCleanup!;
88
206
 
89
- const cleanupMock = mockOrchestrator.webMcpEffectCleanup;
90
-
91
- syncWebMcpRegistration(mockOrchestrator, mockDb);
207
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
92
208
 
93
209
  expect(cleanupMock).toHaveBeenCalled();
94
210
  expect(mockOrchestrator.webMcpEffectCleanup).toBeNull();
95
211
  expect(mockUnregisterWebMcpTools).toHaveBeenCalled();
96
212
  });
97
213
 
98
- it("should setup effect and register tools (including declarative tools filtered by toolTags) if enabled", async () => {
99
- // Add generate_random_number to group1's toolTags
100
- const orchestratorStoreModule =
101
- await import("../../../stores/orchestrator.js");
102
- orchestratorStoreModule.orchestratorStore.groups[0].toolTags = [
103
- "tool1",
104
- "generate_random_number",
105
- ];
106
-
107
- syncWebMcpRegistration(mockOrchestrator, mockDb);
214
+ it("should setup effect and register tools filtered by group toolTags", async () => {
215
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
108
216
 
109
217
  expect(mockOrchestrator.webMcpEffectCleanup).toBeDefined();
110
-
111
- // The effect should run and chain onto webMcpRegistrationLock
112
218
  await mockOrchestrator.webMcpRegistrationLock;
113
219
 
114
220
  expect(mockUnregisterWebMcpTools).toHaveBeenCalled();
@@ -117,47 +223,100 @@ describe("syncWebMcpRegistration", () => {
117
223
  expect.any(Function),
118
224
  "group1",
119
225
  [
120
- { name: "tool1" },
121
226
  {
122
- name: "generate_random_number",
123
- description: "Generate random number",
227
+ name: "tool1",
228
+ description: "Tool 1",
229
+ input_schema: { type: "object", properties: {} },
124
230
  },
231
+ expect.objectContaining({
232
+ name: "enabled_decl_tool",
233
+ description: "Enabled Declarative Tool",
234
+ }),
125
235
  ],
126
236
  );
127
237
 
128
- // Call the callback to test coverage of handleWorkerMessage
129
- const cb = mockRegisterWebMcpTools.mock.calls[0][1];
130
- await cb("test message");
238
+ const onMessageCb = mockRegisterWebMcpTools.mock.calls[0][1];
239
+ await onMessageCb({ type: "worker-message" });
131
240
  expect(mockHandleWorkerMessage).toHaveBeenCalledWith(
132
- mockOrchestrator,
241
+ mockOrchestrator as unknown as Orchestrator,
133
242
  mockDb,
134
- "test message",
243
+ { type: "worker-message" },
135
244
  );
136
245
  });
137
246
 
138
- it("should handle error in registration lock promise", async () => {
139
- mockRegisterWebMcpTools.mockRejectedValueOnce(new Error("Test error"));
140
- const consoleError = jest
141
- .spyOn(console, "error")
142
- .mockImplementation(() => {});
247
+ it("should register globalTools and enabledDeclarativeTools when group has no toolTags", async () => {
248
+ activeGroupId = "group2";
249
+
250
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
251
+ await mockOrchestrator.webMcpRegistrationLock;
252
+
253
+ expect(mockRegisterWebMcpTools).toHaveBeenCalledWith(
254
+ mockOrchestrator.agentWorker,
255
+ expect.any(Function),
256
+ "group2",
257
+ [
258
+ {
259
+ name: "tool2",
260
+ description: "Tool 2",
261
+ input_schema: { type: "object", properties: {} },
262
+ },
263
+ expect.objectContaining({
264
+ name: "enabled_decl_tool",
265
+ description: "Enabled Declarative Tool",
266
+ }),
267
+ ],
268
+ );
269
+ });
143
270
 
144
- syncWebMcpRegistration(mockOrchestrator, mockDb);
271
+ it("should register globalTools and enabledDeclarativeTools when activeGroupId is not found in groups", async () => {
272
+ activeGroupId = "unknown-group";
145
273
 
274
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
146
275
  await mockOrchestrator.webMcpRegistrationLock;
147
- expect(consoleError).toHaveBeenCalledWith(
148
- "WebMCP registration failed:",
149
- expect.any(Error),
276
+
277
+ expect(mockRegisterWebMcpTools).toHaveBeenCalledWith(
278
+ mockOrchestrator.agentWorker,
279
+ expect.any(Function),
280
+ "unknown-group",
281
+ [
282
+ {
283
+ name: "tool2",
284
+ description: "Tool 2",
285
+ input_schema: { type: "object", properties: {} },
286
+ },
287
+ expect.objectContaining({
288
+ name: "enabled_decl_tool",
289
+ description: "Enabled Declarative Tool",
290
+ }),
291
+ ],
150
292
  );
293
+ });
151
294
 
152
- consoleError.mockRestore();
295
+ it("should ignore OPFS load errors in refreshDeclarativeTools and discoverSkills", async () => {
296
+ declarativeToolsFail = true;
297
+ mockDiscoverSkills.mockRejectedValueOnce(new Error("OPFS skills failure"));
298
+
299
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
300
+ await mockOrchestrator.webMcpRegistrationLock;
301
+
302
+ expect(mockRegisterWebMcpTools).toHaveBeenCalled();
153
303
  });
154
304
 
155
- it("should append activate_skill tool to WebMCP tools when skills are discovered", async () => {
156
- mockDiscoverSkills.mockResolvedValue({
157
- skills: [{ name: "my-skill", description: "My custom skill" }],
305
+ it("should append activate_skill when skills are discovered and tool is not present", async () => {
306
+ mockDiscoverSkills.mockResolvedValueOnce({
307
+ skills: [
308
+ {
309
+ name: "test-skill",
310
+ description: "Test skill description",
311
+ path: "skills/test/SKILL.md",
312
+ basePath: "skills/test",
313
+ userInvocable: true,
314
+ disableModelInvocation: false,
315
+ },
316
+ ],
158
317
  });
159
318
 
160
- syncWebMcpRegistration(mockOrchestrator, mockDb);
319
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
161
320
  await mockOrchestrator.webMcpRegistrationLock;
162
321
 
163
322
  expect(mockRegisterWebMcpTools).toHaveBeenCalledWith(
@@ -169,4 +328,121 @@ describe("syncWebMcpRegistration", () => {
169
328
  ]),
170
329
  );
171
330
  });
331
+
332
+ it("should not duplicate activate_skill if already present in tool list", async () => {
333
+ mockDiscoverSkills.mockResolvedValueOnce({
334
+ skills: [
335
+ {
336
+ name: "test-skill",
337
+ description: "Test skill description",
338
+ path: "skills/test/SKILL.md",
339
+ basePath: "skills/test",
340
+ userInvocable: true,
341
+ disableModelInvocation: false,
342
+ },
343
+ ],
344
+ });
345
+ activeGroupId = "group2";
346
+ mockGlobalTools = [
347
+ {
348
+ name: "tool2",
349
+ description: "Tool 2",
350
+ input_schema: { type: "object", properties: {} },
351
+ },
352
+ activate_skill,
353
+ ];
354
+
355
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
356
+ await mockOrchestrator.webMcpRegistrationLock;
357
+
358
+ const registeredTools = mockRegisterWebMcpTools.mock.calls[0][3];
359
+ const activateSkillCount = registeredTools.filter(
360
+ (t) => t.name === "activate_skill",
361
+ ).length;
362
+ expect(activateSkillCount).toBe(1);
363
+ });
364
+
365
+ it("should catch and log errors in registration lock promise", async () => {
366
+ mockRegisterWebMcpTools.mockRejectedValueOnce(
367
+ new Error("Registration error"),
368
+ );
369
+ const consoleError = jest
370
+ .spyOn(console, "error")
371
+ .mockImplementation(() => {});
372
+
373
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
374
+ await mockOrchestrator.webMcpRegistrationLock;
375
+
376
+ expect(consoleError).toHaveBeenCalledWith(
377
+ "WebMCP registration failed:",
378
+ expect.any(Error),
379
+ );
380
+ consoleError.mockRestore();
381
+ });
382
+
383
+ it("should proceed without calling cleanup if webMcpEffectCleanup is null", async () => {
384
+ mockOrchestrator.webMcpEffectCleanup = null;
385
+
386
+ syncWebMcpRegistration(mockOrchestrator as unknown as Orchestrator, mockDb);
387
+ await mockOrchestrator.webMcpRegistrationLock;
388
+
389
+ expect(mockRegisterWebMcpTools).toHaveBeenCalled();
390
+ });
391
+
392
+ describe("setWebMcpMode", () => {
393
+ it("should unregister tools, apply mode, save config, and trigger sync", async () => {
394
+ const syncDeps = {
395
+ orchestrator: mockOrchestrator as unknown as Orchestrator,
396
+ };
397
+
398
+ await setWebMcpMode(mockOrchestrator, mockDb, "native", syncDeps);
399
+ await mockOrchestrator.webMcpRegistrationLock;
400
+
401
+ expect(mockUnregisterWebMcpTools).toHaveBeenCalled();
402
+ expect(mockApplyWebMcpMode).toHaveBeenCalledWith("native");
403
+ expect(mockSetConfig).toHaveBeenCalledWith(
404
+ mockDb,
405
+ CONFIG_KEYS.WEBMCP_MODE,
406
+ "native",
407
+ );
408
+ expect(mockRegisterWebMcpTools).toHaveBeenCalled();
409
+ });
410
+ });
411
+
412
+ describe("setWebMcpToolsEnabled", () => {
413
+ it("should update enabled state to true, persist config, and sync", async () => {
414
+ mockOrchestrator.webMcpToolsEnabled = false;
415
+ const syncDeps = {
416
+ orchestrator: mockOrchestrator as unknown as Orchestrator,
417
+ };
418
+
419
+ await setWebMcpToolsEnabled(mockOrchestrator, mockDb, true, syncDeps);
420
+ await mockOrchestrator.webMcpRegistrationLock;
421
+
422
+ expect(mockOrchestrator.webMcpToolsEnabled).toBe(true);
423
+ expect(mockSetConfig).toHaveBeenCalledWith(
424
+ mockDb,
425
+ CONFIG_KEYS.WEBMCP_TOOLS_ENABLED,
426
+ "true",
427
+ );
428
+ expect(mockRegisterWebMcpTools).toHaveBeenCalled();
429
+ });
430
+
431
+ it("should update enabled state to false, persist config, and sync", async () => {
432
+ mockOrchestrator.webMcpToolsEnabled = true;
433
+ const syncDeps = {
434
+ orchestrator: mockOrchestrator as unknown as Orchestrator,
435
+ };
436
+
437
+ await setWebMcpToolsEnabled(mockOrchestrator, mockDb, false, syncDeps);
438
+
439
+ expect(mockOrchestrator.webMcpToolsEnabled).toBe(false);
440
+ expect(mockSetConfig).toHaveBeenCalledWith(
441
+ mockDb,
442
+ CONFIG_KEYS.WEBMCP_TOOLS_ENABLED,
443
+ "false",
444
+ );
445
+ expect(mockUnregisterWebMcpTools).toHaveBeenCalled();
446
+ });
447
+ });
172
448
  });
@@ -14,6 +14,8 @@ import { CONFIG_KEYS } from "../../../config/config.js";
14
14
  import { setConfig } from "../../../db/setConfig.js";
15
15
 
16
16
  import type { ShadowClawDatabase } from "../../../db/db.js";
17
+ import type { DeclarativeToolDefinition } from "../../../subsystems/tools/declarative.js";
18
+ import type { SkillRecord } from "../../../subsystems/skills/types.js";
17
19
  import type { WebMcpMode } from "../../../subsystems/mcp/webmcp.js";
18
20
  import type { OrchestratorState } from "../orchestrator-state.js";
19
21
  import type { Orchestrator } from "../orchestrator.js";
@@ -60,7 +62,7 @@ export function syncWebMcpRegistration(
60
62
  // Small delay to allow the browser's ModelContext to process the unregistrations.
61
63
  await new Promise((resolve) => setTimeout(resolve, 0));
62
64
 
63
- let declarativeTools: any[] = [];
65
+ let declarativeTools: DeclarativeToolDefinition[] = [];
64
66
  try {
65
67
  declarativeTools = await toolsStore.refreshDeclarativeTools(
66
68
  db,
@@ -80,7 +82,7 @@ export function syncWebMcpRegistration(
80
82
  ? candidateTools.filter((t) => group.toolTags!.includes(t.name))
81
83
  : [...globalTools, ...enabledDeclarativeTools];
82
84
 
83
- let skillDiscovery: { skills: any[] } = { skills: [] };
85
+ let skillDiscovery: { skills: SkillRecord[] } = { skills: [] };
84
86
  try {
85
87
  skillDiscovery = await discoverSkills(db, activeGroupId);
86
88
  } catch {
@@ -256,7 +256,7 @@ export function registerMcpRoutes(
256
256
  name: "shadow-claw",
257
257
  version: getPackageVersion(),
258
258
  description:
259
- "Browser-native personal AI assistant with local and remote LLM orchestration, Web Workers, OPFS storage, and agentic tool execution.",
259
+ "Multi-runtime AI assistant with local and remote LLM orchestration, Web Workers, OPFS storage, and agentic tool execution.",
260
260
  title: "ShadowClaw MCP Server",
261
261
  websiteUrl: `${origin}/shadow-claw/`,
262
262
  repository: {
@@ -332,7 +332,7 @@ export function registerMcpRoutes(
332
332
  servers: [
333
333
  {
334
334
  name: "shadow-claw",
335
- description: "ShadowClaw Browser-Native AI Assistant MCP Server",
335
+ description: "ShadowClaw Multi-Runtime AI Assistant MCP Server",
336
336
  endpoint: `${origin}/mcp`,
337
337
  capabilities: ["tools", "extensions"],
338
338
  card: `${origin}/.well-known/mcp.json`,
@@ -155,4 +155,15 @@ describe("FileViewerStore", () => {
155
155
  mimeType: "text/plain",
156
156
  });
157
157
  });
158
+
159
+ it("handles empty path and extension-less filenames", async () => {
160
+ (readGroupFile as any).mockResolvedValue("empty-content");
161
+ const s = new FileViewerStore();
162
+
163
+ expect(s.getPreviewBinaryMimeType("")).toBe("");
164
+ expect(s.getPreviewBinaryMimeType("noextension")).toBe("");
165
+
166
+ await s.openFile({} as any, "");
167
+ expect(s.file?.name).toBe("");
168
+ });
158
169
  });