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
@@ -1,4 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import type { ProviderConfig } from "../../../config/config.js";
3
+ import type { ShadowClawDatabase } from "../../../db/db.js";
4
+ import type { Orchestrator } from "../orchestrator.js";
2
5
 
3
6
  jest.unstable_mockModule("./operations/channel.js", () => ({
4
7
  applyAllChannelRunningStates: jest.fn(),
@@ -13,10 +16,12 @@ jest.unstable_mockModule("./operations/channel.js", () => ({
13
16
  shouldRunChannel: jest.fn(),
14
17
  }));
15
18
 
16
- const mockDeliverResponse = (jest.fn() as any).mockResolvedValue(undefined);
17
- const mockDeliverIntermediateResponse = (jest.fn() as any).mockResolvedValue(
18
- undefined,
19
- );
19
+ const mockDeliverResponse = jest
20
+ .fn<(...args: unknown[]) => Promise<unknown>>()
21
+ .mockResolvedValue(undefined);
22
+ const mockDeliverIntermediateResponse = jest
23
+ .fn<(...args: unknown[]) => Promise<unknown>>()
24
+ .mockResolvedValue(undefined);
20
25
 
21
26
  jest.unstable_mockModule("./deliverResponse.js", () => ({
22
27
  deliverResponse: mockDeliverResponse,
@@ -32,25 +37,33 @@ jest.unstable_mockModule("./operations/room.js", () => ({
32
37
  listRooms: jest.fn(),
33
38
  }));
34
39
 
35
- const mockIsLlamafileResolutionError = jest.fn() as any;
36
- const mockDetectProviderHelpType = jest.fn() as any;
37
- const mockIsTransformersJsResolutionError = jest.fn() as any;
38
-
39
- const mockDeleteTask = jest.fn() as any;
40
- const mockGetAllTasks = jest.fn() as any;
41
- const mockRoomIdFromGroupId = jest.fn() as any;
42
- const mockSaveTask = jest.fn() as any;
43
- const mockGetOrCreateSubscriberId = jest.fn() as any;
44
-
45
- const mockSyncTaskToServer = jest.fn() as any;
46
- const mockDeleteTaskFromServer = jest.fn() as any;
47
-
48
- const mockGetRemoteMcpConnection = jest.fn() as any;
49
- const mockReconnectMcpOAuth = jest.fn() as any;
50
- const mockGetPushUrl = jest.fn() as any;
51
- const mockGetConfig = jest.fn() as any;
52
-
53
- const mockShowToast = jest.fn() as any;
40
+ const mockIsLlamafileResolutionError =
41
+ jest.fn<(...args: unknown[]) => boolean>();
42
+ const mockDetectProviderHelpType =
43
+ jest.fn<(...args: unknown[]) => string | null>();
44
+ const mockIsTransformersJsResolutionError =
45
+ jest.fn<(...args: unknown[]) => boolean>();
46
+
47
+ const mockDeleteTask = jest.fn<(...args: unknown[]) => Promise<unknown>>();
48
+ const mockGetAllTasks = jest.fn<(...args: unknown[]) => Promise<unknown>>();
49
+ const mockRoomIdFromGroupId = jest.fn<(...args: unknown[]) => string>();
50
+ const mockSaveTask = jest.fn<(...args: unknown[]) => Promise<unknown>>();
51
+ const mockGetOrCreateSubscriberId =
52
+ jest.fn<(...args: unknown[]) => Promise<string>>();
53
+
54
+ const mockSyncTaskToServer =
55
+ jest.fn<(...args: unknown[]) => Promise<boolean>>();
56
+ const mockDeleteTaskFromServer =
57
+ jest.fn<(...args: unknown[]) => Promise<boolean>>();
58
+
59
+ const mockGetRemoteMcpConnection =
60
+ jest.fn<(...args: unknown[]) => Promise<unknown>>();
61
+ const mockReconnectMcpOAuth =
62
+ jest.fn<(...args: unknown[]) => Promise<unknown>>();
63
+ const mockGetPushUrl = jest.fn<(...args: unknown[]) => Promise<string>>();
64
+ const mockGetConfig = jest.fn<(...args: unknown[]) => Promise<unknown>>();
65
+
66
+ const mockShowToast = jest.fn<(...args: unknown[]) => unknown>();
54
67
 
55
68
  jest.unstable_mockModule(
56
69
  "../../../components/common/help/llamafile.js",
@@ -111,14 +124,14 @@ jest.unstable_mockModule("../../../db/rooms.js", () => ({
111
124
  roomIdFromGroupId: mockRoomIdFromGroupId,
112
125
  ROOM_PREFIX: "room:",
113
126
  roomGroupId: (id: string) => `room:${id}`,
114
- getRoomMetadata: (jest.fn() as any).mockResolvedValue([]),
115
- saveRoomMetadata: (jest.fn() as any).mockResolvedValue(undefined),
116
- getRoom: (jest.fn() as any).mockResolvedValue(null),
117
- upsertRoom: (jest.fn() as any).mockResolvedValue(undefined),
118
- createRoom: (jest.fn() as any).mockResolvedValue({}),
119
- addRoomMember: (jest.fn() as any).mockResolvedValue(null),
120
- removeRoomMember: (jest.fn() as any).mockResolvedValue(null),
121
- deleteRoom: (jest.fn() as any).mockResolvedValue(undefined),
127
+ getRoomMetadata: jest.fn<() => Promise<unknown[]>>().mockResolvedValue([]),
128
+ saveRoomMetadata: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
129
+ getRoom: jest.fn<() => Promise<null>>().mockResolvedValue(null),
130
+ upsertRoom: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
131
+ createRoom: jest.fn<() => Promise<unknown>>().mockResolvedValue({}),
132
+ addRoomMember: jest.fn<() => Promise<null>>().mockResolvedValue(null),
133
+ removeRoomMember: jest.fn<() => Promise<null>>().mockResolvedValue(null),
134
+ deleteRoom: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
122
135
  }));
123
136
 
124
137
  jest.unstable_mockModule("../../../db/saveTask.js", () => ({
@@ -129,10 +142,16 @@ jest.unstable_mockModule("../../../db/getOrCreateSubscriberId.js", () => ({
129
142
  getOrCreateSubscriberId: mockGetOrCreateSubscriberId,
130
143
  }));
131
144
 
145
+ interface MockOrchestratorStore {
146
+ runTask: jest.Mock;
147
+ }
148
+
149
+ const mockOrchestratorStore: MockOrchestratorStore = {
150
+ runTask: jest.fn(),
151
+ };
152
+
132
153
  jest.unstable_mockModule("../../../stores/orchestrator.js", () => ({
133
- orchestratorStore: {
134
- runTask: jest.fn(),
135
- },
154
+ orchestratorStore: mockOrchestratorStore,
136
155
  }));
137
156
 
138
157
  jest.unstable_mockModule(
@@ -144,8 +163,8 @@ jest.unstable_mockModule(
144
163
  );
145
164
 
146
165
  const mockToolsStore = {
147
- activateProfile: jest.fn(),
148
- setToolEnabled: jest.fn(),
166
+ activateProfile: jest.fn<(...args: unknown[]) => Promise<void>>(),
167
+ setToolEnabled: jest.fn<(...args: unknown[]) => Promise<void>>(),
149
168
  enabledTools: ["t1"],
150
169
  systemPromptOverride: "override",
151
170
  };
@@ -156,7 +175,9 @@ jest.unstable_mockModule("../../../stores/tools.js", () => ({
156
175
 
157
176
  jest.unstable_mockModule("../../../subsystems/mcp/mcp-connections.js", () => ({
158
177
  getRemoteMcpConnection: mockGetRemoteMcpConnection,
159
- listRemoteMcpConnections: (jest.fn() as any).mockResolvedValue([]),
178
+ listRemoteMcpConnections: jest
179
+ .fn<() => Promise<unknown[]>>()
180
+ .mockResolvedValue([]),
160
181
  }));
161
182
 
162
183
  jest.unstable_mockModule("../../../subsystems/mcp/mcp-reconnect.js", () => ({
@@ -178,9 +199,12 @@ jest.unstable_mockModule("../../../db/getConfig.js", () => ({
178
199
  getConfig: mockGetConfig,
179
200
  }));
180
201
 
181
- const mockGetApiKeyForRequest = jest.fn() as any;
182
- const mockGetProviderRuntimeHeaders = jest.fn() as any;
183
- const mockStopTransformersProgressPolling = jest.fn() as any;
202
+ const mockGetApiKeyForRequest =
203
+ jest.fn<(...args: unknown[]) => Promise<string | null>>();
204
+ const mockGetProviderRuntimeHeaders =
205
+ jest.fn<(...args: unknown[]) => Record<string, string>>();
206
+ const mockStopTransformersProgressPolling =
207
+ jest.fn<(...args: unknown[]) => unknown>();
184
208
 
185
209
  jest.unstable_mockModule("./operations/provider.js", () => ({
186
210
  getApiKeyForRequest: mockGetApiKeyForRequest,
@@ -191,26 +215,30 @@ jest.unstable_mockModule("./operations/provider.js", () => ({
191
215
  startTransformersProgressPolling: jest.fn(),
192
216
  }));
193
217
 
194
- const mockBuildHeaders = jest.fn() as any;
195
- const mockFormatRequest = jest.fn() as any;
196
- const mockParseResponse = jest.fn() as any;
218
+ const mockBuildHeaders =
219
+ jest.fn<(...args: unknown[]) => Record<string, string>>();
220
+ const mockFormatRequest =
221
+ jest.fn<(...args: unknown[]) => Record<string, unknown>>();
222
+ const mockParseResponse =
223
+ jest.fn<(...args: unknown[]) => Record<string, unknown>>();
197
224
 
198
225
  jest.unstable_mockModule("../../../subsystems/providers/providers.js", () => ({
199
226
  buildHeaders: mockBuildHeaders,
200
227
  formatRequest: mockFormatRequest,
201
228
  getContextLimit: jest.fn().mockReturnValue(128000),
202
- normalizeMeshLlmResult: jest.fn().mockImplementation((r: any) => r),
229
+ normalizeMeshLlmResult: jest.fn().mockImplementation((r) => r),
203
230
  parseResponse: mockParseResponse,
204
231
  }));
205
232
 
206
- const mockSummarizeText = jest.fn() as any;
207
- const mockWriteText = jest.fn() as any;
208
- const mockRewriteText = jest.fn() as any;
209
- const mockProofreadText = jest.fn() as any;
210
- const mockDetectLanguage = jest.fn() as any;
211
- const mockTranslateText = jest.fn() as any;
212
- const mockEmbedText = jest.fn() as any;
213
- const mockEnsureBuiltinAiPolyfills = jest.fn() as any;
233
+ const mockSummarizeText = jest.fn<(...args: unknown[]) => Promise<unknown>>();
234
+ const mockWriteText = jest.fn<(...args: unknown[]) => Promise<unknown>>();
235
+ const mockRewriteText = jest.fn<(...args: unknown[]) => Promise<unknown>>();
236
+ const mockProofreadText = jest.fn<(...args: unknown[]) => Promise<unknown>>();
237
+ const mockDetectLanguage = jest.fn<(...args: unknown[]) => Promise<unknown>>();
238
+ const mockTranslateText = jest.fn<(...args: unknown[]) => Promise<unknown>>();
239
+ const mockEmbedText = jest.fn<(...args: unknown[]) => Promise<unknown>>();
240
+ const mockEnsureBuiltinAiPolyfills =
241
+ jest.fn<(...args: unknown[]) => Promise<unknown>>();
214
242
 
215
243
  jest.unstable_mockModule(
216
244
  "../../../subsystems/providers/builtin-ai-tasks.js",
@@ -223,10 +251,12 @@ jest.unstable_mockModule(
223
251
  summarizeText: mockSummarizeText,
224
252
  translateText: mockTranslateText,
225
253
  writeText: mockWriteText,
226
- createTaskInstanceWithFallback: (jest.fn() as any).mockResolvedValue({}),
227
- getPromptApiFallbackModel: (jest.fn() as any).mockResolvedValue(
228
- "onnx-community/Qwen3-0.6B-ONNX",
229
- ),
254
+ createTaskInstanceWithFallback: jest
255
+ .fn<() => Promise<unknown>>()
256
+ .mockResolvedValue({}),
257
+ getPromptApiFallbackModel: jest
258
+ .fn<() => Promise<string>>()
259
+ .mockResolvedValue("onnx-community/Qwen3-0.6B-ONNX"),
230
260
  PROMPT_API_POLYFILL_MODEL: "onnx-community/Qwen3-0.6B-ONNX",
231
261
  }),
232
262
  );
@@ -235,28 +265,58 @@ const { handleWorkerMessage } = await import("./handleWorkerMessage.js");
235
265
  const { createRoom, inviteToRoom, leaveRoom } =
236
266
  await import("./operations/room.js");
237
267
 
268
+ interface PendingResolver {
269
+ resolve: (value: unknown) => void;
270
+ reject: (reason: unknown) => void;
271
+ }
272
+
273
+ const globalNativeAi = globalThis as typeof globalThis & {
274
+ pendingNativeAiResolvers?: Record<string, PendingResolver>;
275
+ LanguageModel?: {
276
+ create: () => Promise<{
277
+ prompt: (p?: string) => Promise<string>;
278
+ destroy: () => void;
279
+ }>;
280
+ };
281
+ };
282
+
283
+ const dummyProviderConfig: ProviderConfig = {
284
+ id: "test-prov",
285
+ name: "Test Provider",
286
+ baseUrl: "https://api.test.example/v1",
287
+ format: "openai",
288
+ requiresApiKey: true,
289
+ apiKeyHeader: "Authorization",
290
+ headers: {},
291
+ supportsStreaming: true,
292
+ defaultModel: "test-model",
293
+ };
294
+
238
295
  describe("handleWorkerMessage", () => {
239
- let mockOrchestrator: any;
240
- let mockDb: any;
296
+ let mockOrchestrator: Orchestrator;
297
+ let mockDb: ShadowClawDatabase;
241
298
 
242
299
  beforeEach(() => {
243
300
  jest.clearAllMocks();
244
301
  mockSyncTaskToServer.mockResolvedValue(true);
245
302
  mockDeleteTaskFromServer.mockResolvedValue(true);
246
303
  mockGetOrCreateSubscriberId.mockResolvedValue("sub-test");
247
- mockDb = {};
304
+
305
+ mockDb = {} as unknown as ShadowClawDatabase;
248
306
  mockOrchestrator = {
249
- transformersProgressPollers: new Map(),
307
+ transformersProgressPollers: new Map<string, unknown>(),
250
308
  clearProviderRequest: jest.fn(),
251
- inFlightTriggerByGroup: new Map(),
252
- inFlightEffectiveProviderByGroup: new Map(),
253
- deliverResponse: (jest.fn() as any).mockResolvedValue(undefined),
254
- deliverIntermediateResponse: (jest.fn() as any).mockResolvedValue(
255
- undefined,
256
- ),
309
+ inFlightTriggerByGroup: new Map<string, string>(),
310
+ inFlightEffectiveProviderByGroup: new Map<string, unknown>(),
311
+ deliverResponse: jest
312
+ .fn<(...args: unknown[]) => Promise<unknown>>()
313
+ .mockResolvedValue(undefined),
314
+ deliverIntermediateResponse: jest
315
+ .fn<(...args: unknown[]) => Promise<unknown>>()
316
+ .mockResolvedValue(undefined),
257
317
  setState: jest.fn(),
258
318
  events: { emit: jest.fn() },
259
- schedulerTriggeredGroups: new Set(),
319
+ schedulerTriggeredGroups: new Set<string>(),
260
320
  createRoom: jest.fn(),
261
321
  inviteToRoom: jest.fn(),
262
322
  leaveRoom: jest.fn(),
@@ -264,14 +324,23 @@ describe("handleWorkerMessage", () => {
264
324
  providerConfig: { requiresApiKey: true },
265
325
  router: {
266
326
  setTyping: jest.fn(),
267
- send: (jest.fn() as any).mockResolvedValue(undefined),
327
+ send: jest
328
+ .fn<(...args: unknown[]) => Promise<unknown>>()
329
+ .mockResolvedValue(undefined),
268
330
  findChannel: jest.fn(),
269
331
  },
270
- newSession: (jest.fn() as any).mockResolvedValue(undefined),
271
- handleCompactDone: (jest.fn() as any).mockResolvedValue(undefined),
332
+ newSession: jest
333
+ .fn<(...args: unknown[]) => Promise<unknown>>()
334
+ .mockResolvedValue(undefined),
335
+ handleCompactDone: jest
336
+ .fn<(...args: unknown[]) => Promise<unknown>>()
337
+ .mockResolvedValue(undefined),
272
338
  agentWorker: { postMessage: jest.fn() },
273
339
  roomManager: { broadcastA2UI: jest.fn() },
274
- };
340
+ maxTokens: 1000,
341
+ provider: "test-provider",
342
+ model: "test-model",
343
+ } as unknown as Orchestrator;
275
344
 
276
345
  mockGetPushUrl.mockResolvedValue("http://push");
277
346
  mockGetAllTasks.mockResolvedValue([]);
@@ -283,11 +352,17 @@ describe("handleWorkerMessage", () => {
283
352
  mockParseResponse.mockReturnValue({
284
353
  content: [{ type: "text", text: "parsed result" }],
285
354
  });
286
- global.fetch = (jest.fn() as any).mockResolvedValue({} as any) as any;
355
+ global.fetch = jest
356
+ .fn<(...args: unknown[]) => Promise<unknown>>()
357
+ .mockResolvedValue({} as unknown as Response) as typeof global.fetch;
287
358
  });
288
359
 
289
- const send = async (msg: any) =>
290
- handleWorkerMessage(mockOrchestrator, mockDb, msg);
360
+ const send = async (msg: { type: string; payload?: unknown }) =>
361
+ handleWorkerMessage(
362
+ mockOrchestrator,
363
+ mockDb,
364
+ msg as Record<string, unknown>,
365
+ );
291
366
 
292
367
  it("handles response", async () => {
293
368
  mockOrchestrator.inFlightTriggerByGroup.set("g1", "x");
@@ -344,15 +419,21 @@ describe("handleWorkerMessage", () => {
344
419
  );
345
420
  });
346
421
 
347
- it("handles tasks (create, update, delete)", async () => {
348
- // blocked by recursion
422
+ it("handles run-task", async () => {
423
+ await send({ type: "run-task", payload: { task: { id: "t1" } } });
424
+ expect(mockOrchestratorStore.runTask).toHaveBeenCalledWith(
425
+ { id: "t1" },
426
+ true,
427
+ );
428
+ });
429
+
430
+ it("handles task-created success and failures", async () => {
349
431
  mockOrchestrator.schedulerTriggeredGroups.add("g1");
350
432
  await send({ type: "task-created", payload: { task: { groupId: "g1" } } });
351
433
  expect(mockShowToast).toHaveBeenCalledWith(
352
434
  expect.stringContaining("blocked"),
353
435
  expect.any(Object),
354
436
  );
355
-
356
437
  mockOrchestrator.schedulerTriggeredGroups.clear();
357
438
 
358
439
  await send({ type: "task-created", payload: { task: { groupId: "g2" } } });
@@ -363,14 +444,64 @@ describe("handleWorkerMessage", () => {
363
444
  "sub-test",
364
445
  );
365
446
 
447
+ // Sync to server failed
448
+ mockSyncTaskToServer.mockResolvedValueOnce(false);
449
+ await send({ type: "task-created", payload: { task: { groupId: "g2" } } });
450
+ expect(mockShowToast).toHaveBeenCalledWith(
451
+ "Failed to sync task to server — task was not saved.",
452
+ { type: "error" },
453
+ );
454
+
455
+ // Save task throws error
456
+ mockSaveTask.mockRejectedValueOnce(new Error("Save failed"));
457
+ await send({ type: "task-created", payload: { task: { groupId: "g2" } } });
458
+ expect(mockShowToast).toHaveBeenCalledWith("Failed to save task.", {
459
+ type: "error",
460
+ });
461
+ });
462
+
463
+ it("handles update-task success and failures", async () => {
464
+ mockOrchestrator.schedulerTriggeredGroups.add("g1");
465
+ await send({ type: "update-task", payload: { task: { groupId: "g1" } } });
466
+ expect(mockShowToast).toHaveBeenCalledWith(
467
+ expect.stringContaining("Task update blocked"),
468
+ expect.any(Object),
469
+ );
470
+ mockOrchestrator.schedulerTriggeredGroups.clear();
471
+
366
472
  await send({ type: "update-task", payload: { task: { groupId: "g2" } } });
367
- expect(mockSaveTask).toHaveBeenCalledTimes(2);
473
+ expect(mockSaveTask).toHaveBeenCalled();
368
474
  expect(mockSyncTaskToServer).toHaveBeenLastCalledWith(
369
475
  mockOrchestrator,
370
476
  { groupId: "g2" },
371
477
  "sub-test",
372
478
  );
373
479
 
480
+ // Sync to server failed
481
+ mockSyncTaskToServer.mockResolvedValueOnce(false);
482
+ await send({ type: "update-task", payload: { task: { groupId: "g2" } } });
483
+ expect(mockShowToast).toHaveBeenCalledWith(
484
+ "Failed to sync task update to server — task was not updated.",
485
+ { type: "error" },
486
+ );
487
+
488
+ // Update throws error
489
+ mockSaveTask.mockRejectedValueOnce(new Error("Update failed"));
490
+ await send({ type: "update-task", payload: { task: { groupId: "g2" } } });
491
+ expect(mockShowToast).toHaveBeenCalledWith("Failed to update task.", {
492
+ type: "error",
493
+ });
494
+ });
495
+
496
+ it("handles delete-task success and failures", async () => {
497
+ mockOrchestrator.schedulerTriggeredGroups.add("g1");
498
+ await send({ type: "delete-task", payload: { id: "t1", groupId: "g1" } });
499
+ expect(mockShowToast).toHaveBeenCalledWith(
500
+ expect.stringContaining("Task deletion blocked"),
501
+ expect.any(Object),
502
+ );
503
+ mockOrchestrator.schedulerTriggeredGroups.clear();
504
+
374
505
  await send({ type: "delete-task", payload: { id: "t1", groupId: "g2" } });
375
506
  expect(mockDeleteTask).toHaveBeenCalled();
376
507
  expect(mockDeleteTaskFromServer).toHaveBeenCalledWith(
@@ -378,15 +509,50 @@ describe("handleWorkerMessage", () => {
378
509
  "t1",
379
510
  "sub-test",
380
511
  );
512
+
513
+ // Server deletion failed
514
+ mockDeleteTaskFromServer.mockResolvedValueOnce(false);
515
+ await send({ type: "delete-task", payload: { id: "t1", groupId: "g2" } });
516
+ expect(mockShowToast).toHaveBeenCalledWith(
517
+ "Failed to delete task from server — task kept in view.",
518
+ { type: "error" },
519
+ );
520
+
521
+ // Delete task throws
522
+ mockDeleteTask.mockRejectedValueOnce(new Error("Delete failed"));
523
+ await send({ type: "delete-task", payload: { id: "t1", groupId: "g2" } });
381
524
  });
382
525
 
383
- it("handles room actions", async () => {
526
+ it("handles task-list-request filtering by groupId", async () => {
527
+ mockGetAllTasks.mockResolvedValue([
528
+ { id: "t1", groupId: "g1" },
529
+ { id: "t2", groupId: "g2" },
530
+ ]);
531
+
532
+ await send({ type: "task-list-request", payload: { groupId: "g1" } });
533
+
534
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
535
+ type: "task-list-response",
536
+ payload: {
537
+ groupId: "g1",
538
+ tasks: [{ id: "t1", groupId: "g1" }],
539
+ },
540
+ });
541
+ });
542
+
543
+ it("handles room actions and edge cases", async () => {
384
544
  await send({
385
545
  type: "room-action",
386
546
  payload: { action: "create", name: "r1" },
387
547
  });
388
548
  expect(createRoom).toHaveBeenCalledWith(mockOrchestrator, "r1");
389
549
 
550
+ await send({
551
+ type: "room-action",
552
+ payload: { action: "create" },
553
+ });
554
+ expect(createRoom).toHaveBeenCalledWith(mockOrchestrator, "");
555
+
390
556
  await send({
391
557
  type: "room-action",
392
558
  payload: { action: "invite", roomId: "r1", peerId: "p1" },
@@ -398,9 +564,24 @@ describe("handleWorkerMessage", () => {
398
564
  payload: { action: "leave", roomId: "r1" },
399
565
  });
400
566
  expect(leaveRoom).toHaveBeenCalledWith(mockOrchestrator, "r1");
567
+
568
+ // Unknown action does not crash
569
+ await send({
570
+ type: "room-action",
571
+ payload: { action: "unknown" },
572
+ });
573
+
574
+ // Thrown error is caught cleanly
575
+ (leaveRoom as jest.Mock).mockImplementationOnce(() => {
576
+ throw new Error("Leave error");
577
+ });
578
+ await send({
579
+ type: "room-action",
580
+ payload: { action: "leave", roomId: "r1" },
581
+ });
401
582
  });
402
583
 
403
- it("handles errors", async () => {
584
+ it("handles errors and context limit warnings", async () => {
404
585
  await send({
405
586
  type: "error",
406
587
  payload: { groupId: "g1", error: "tokens_limit_reached" },
@@ -411,12 +592,25 @@ describe("handleWorkerMessage", () => {
411
592
  "g1",
412
593
  expect.stringContaining("context window"),
413
594
  );
595
+
596
+ // Non-context error
597
+ await send({
598
+ type: "error",
599
+ payload: { groupId: "g1", error: "Unrelated provider fault" },
600
+ });
601
+ expect(mockDeliverResponse).toHaveBeenCalledWith(
602
+ mockOrchestrator,
603
+ mockDb,
604
+ "g1",
605
+ "⚠️ Error: Unrelated provider fault",
606
+ );
414
607
  });
415
608
 
416
- it("handles llamafile error", async () => {
609
+ it("handles llamafile error and transformers_js_local error", async () => {
417
610
  mockOrchestrator.inFlightEffectiveProviderByGroup.set("g1", {
418
611
  providerId: "llamafile",
419
612
  model: "llamafile-model",
613
+ providerConfig: dummyProviderConfig,
420
614
  });
421
615
  mockIsLlamafileResolutionError.mockReturnValue(true);
422
616
  await send({
@@ -427,12 +621,50 @@ describe("handleWorkerMessage", () => {
427
621
  "provider-help",
428
622
  expect.objectContaining({ providerId: "llamafile" }),
429
623
  );
624
+
625
+ mockOrchestrator.inFlightEffectiveProviderByGroup.set("g2", {
626
+ providerId: "transformers_js_local",
627
+ model: "local-model",
628
+ providerConfig: dummyProviderConfig,
629
+ });
630
+ mockIsTransformersJsResolutionError.mockReturnValue(true);
631
+ await send({
632
+ type: "error",
633
+ payload: { groupId: "g2", error: "transformers local error" },
634
+ });
635
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith("provider-help", {
636
+ providerId: "transformers_js_local",
637
+ reason: "transformers local error",
638
+ });
639
+ });
640
+
641
+ it("handles generic provider help detection", async () => {
642
+ mockOrchestrator.inFlightEffectiveProviderByGroup.set("g1", {
643
+ providerId: "openai",
644
+ model: "gpt-4",
645
+ providerConfig: dummyProviderConfig,
646
+ });
647
+ mockIsLlamafileResolutionError.mockReturnValue(false);
648
+ mockIsTransformersJsResolutionError.mockReturnValue(false);
649
+ mockDetectProviderHelpType.mockReturnValue("api_key_missing");
650
+
651
+ await send({
652
+ type: "error",
653
+ payload: { groupId: "g1", error: "Invalid API key" },
654
+ });
655
+
656
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith("provider-help", {
657
+ providerId: "openai",
658
+ reason: "Invalid API key",
659
+ helpType: "api_key_missing",
660
+ });
430
661
  });
431
662
 
432
- it("handles simple events", async () => {
663
+ it("handles simple events and tool activities", async () => {
433
664
  await send({ type: "typing", payload: { groupId: "g1" } });
434
- expect(mockOrchestrator.router.setTyping).toHaveBeenCalledWith("g1", true);
665
+ expect(mockOrchestrator.router?.setTyping).toHaveBeenCalledWith("g1", true);
435
666
 
667
+ // write_file done
436
668
  await send({
437
669
  type: "tool-activity",
438
670
  payload: { groupId: "g1", tool: "write_file", status: "done" },
@@ -441,6 +673,26 @@ describe("handleWorkerMessage", () => {
441
673
  groupId: "g1",
442
674
  });
443
675
 
676
+ // bash done
677
+ await send({
678
+ type: "tool-activity",
679
+ payload: { groupId: "g1", tool: "bash", status: "done" },
680
+ });
681
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith("file-change", {
682
+ groupId: "g1",
683
+ });
684
+
685
+ // other tool / running status
686
+ (mockOrchestrator.events.emit as jest.Mock).mockClear();
687
+ await send({
688
+ type: "tool-activity",
689
+ payload: { groupId: "g1", tool: "read_file", status: "done" },
690
+ });
691
+ expect(mockOrchestrator.events.emit).not.toHaveBeenCalledWith(
692
+ "file-change",
693
+ expect.anything(),
694
+ );
695
+
444
696
  await send({
445
697
  type: "compact-done",
446
698
  payload: { groupId: "g1", summary: "s" },
@@ -451,56 +703,156 @@ describe("handleWorkerMessage", () => {
451
703
  "s",
452
704
  );
453
705
 
454
- await send({ type: "task-list-request", payload: { groupId: "g1" } });
455
- expect(mockOrchestrator.agentWorker.postMessage).toHaveBeenCalled();
456
-
457
706
  await send({ type: "clear-chat", payload: { groupId: "g1" } });
458
707
  expect(mockOrchestrator.newSession).toHaveBeenCalledWith(mockDb, "g1");
459
708
 
709
+ // Clear chat throws
710
+ (
711
+ mockOrchestrator.newSession as jest.Mock<
712
+ (...args: unknown[]) => Promise<unknown>
713
+ >
714
+ ).mockRejectedValueOnce(new Error("Clear error"));
715
+ await send({ type: "clear-chat", payload: { groupId: "g1" } });
716
+
460
717
  await send({ type: "show-toast", payload: { message: "msg" } });
461
- expect(mockShowToast).toHaveBeenCalled();
718
+ expect(mockShowToast).toHaveBeenCalledWith("msg", {
719
+ type: "info",
720
+ duration: undefined,
721
+ });
722
+
723
+ await send({
724
+ type: "model-download-progress",
725
+ payload: { progress: 0.5 },
726
+ });
727
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith(
728
+ "model-download-progress",
729
+ { progress: 0.5 },
730
+ );
731
+
732
+ await send({ type: "thinking-log", payload: { text: "thinking..." } });
733
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith("thinking-log", {
734
+ text: "thinking...",
735
+ });
736
+
737
+ await send({ type: "token-usage", payload: { tokens: 100 } });
738
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith("token-usage", {
739
+ tokens: 100,
740
+ });
462
741
  });
463
742
 
464
- it("handles manage-tools", async () => {
743
+ it("handles manage-tools with enable, disable, and default groupId", async () => {
465
744
  await send({
466
745
  type: "manage-tools",
467
746
  payload: { action: "activate_profile", profileId: "p1" },
468
747
  });
469
748
  expect(mockToolsStore.activateProfile).toHaveBeenCalledWith(mockDb, "p1");
749
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith(
750
+ expect.objectContaining({
751
+ payload: expect.objectContaining({
752
+ groupId: "br:main",
753
+ }),
754
+ }),
755
+ );
470
756
 
471
757
  await send({
472
758
  type: "manage-tools",
473
- payload: { action: "enable", toolNames: ["t1"] },
759
+ payload: { action: "enable", toolNames: ["t1"], groupId: "g1" },
474
760
  });
475
761
  expect(mockToolsStore.setToolEnabled).toHaveBeenCalledWith(
476
762
  mockDb,
477
763
  "t1",
478
764
  true,
479
765
  );
766
+
767
+ await send({
768
+ type: "manage-tools",
769
+ payload: { action: "disable", toolNames: ["t2"] },
770
+ });
771
+ expect(mockToolsStore.setToolEnabled).toHaveBeenCalledWith(
772
+ mockDb,
773
+ "t2",
774
+ false,
775
+ );
776
+
777
+ // Unrecognized action
778
+ await send({
779
+ type: "manage-tools",
780
+ payload: { action: "unknown_action" },
781
+ });
480
782
  });
481
783
 
482
- it("handles push notifs", async () => {
784
+ it("handles push notifications and recursion guard", async () => {
785
+ mockOrchestrator.schedulerTriggeredGroups.add("g1");
786
+ await send({
787
+ type: "send-notification",
788
+ payload: { title: "t", body: "b", groupId: "g1" },
789
+ });
790
+ expect(mockShowToast).toHaveBeenCalledWith(
791
+ expect.stringContaining("Notification blocked"),
792
+ expect.any(Object),
793
+ );
794
+ mockOrchestrator.schedulerTriggeredGroups.clear();
795
+
483
796
  await send({
484
797
  type: "send-notification",
485
798
  payload: { title: "t", body: "b" },
486
799
  });
487
800
  expect(mockGetPushUrl).toHaveBeenCalled();
801
+
802
+ // Fetch error handling
803
+ (
804
+ global.fetch as jest.Mock<(...args: unknown[]) => Promise<unknown>>
805
+ ).mockRejectedValueOnce(new Error("Push network error"));
806
+ await send({
807
+ type: "send-notification",
808
+ payload: { title: "t", body: "b" },
809
+ });
810
+ await new Promise(process.nextTick);
488
811
  });
489
812
 
490
- it("handles send-file", async () => {
813
+ it("handles send-file variations", async () => {
491
814
  await send({
492
815
  type: "send-file",
493
816
  payload: { groupId: "g1", path: "test.txt" },
494
817
  });
495
- // Need a tick since it's fire-and-forget IIFE
496
818
  await new Promise(process.nextTick);
497
- expect(mockOrchestrator.router.send).toHaveBeenCalled();
498
- });
819
+ expect(mockOrchestrator.router?.send).toHaveBeenCalledWith("g1", "", [
820
+ expect.objectContaining({ fileName: "test.txt" }),
821
+ ]);
499
822
 
500
- it("handles send-file rejection", async () => {
501
- mockOrchestrator.router.send.mockRejectedValueOnce(
502
- new Error("Send failed"),
823
+ // Empty path fallback
824
+ await send({
825
+ type: "send-file",
826
+ payload: { groupId: "g1", path: "" },
827
+ });
828
+ await new Promise(process.nextTick);
829
+ expect(mockOrchestrator.router?.send).toHaveBeenCalledWith("g1", "", [
830
+ expect.objectContaining({ fileName: "" }),
831
+ ]);
832
+
833
+ // Rejection with non-Error
834
+ (
835
+ mockOrchestrator.router?.send as jest.Mock<
836
+ (...args: unknown[]) => Promise<unknown>
837
+ >
838
+ ).mockRejectedValueOnce("raw string rejection");
839
+ await send({
840
+ type: "send-file",
841
+ payload: { groupId: "g1", path: "fail.txt" },
842
+ });
843
+ await new Promise(process.nextTick);
844
+ expect(mockShowToast).toHaveBeenCalledWith(
845
+ "Failed to send file to peer: raw string rejection",
846
+ expect.any(Object),
503
847
  );
848
+ });
849
+
850
+ it("handles send-file rejection with Error", async () => {
851
+ (
852
+ mockOrchestrator.router?.send as jest.Mock<
853
+ (...args: unknown[]) => Promise<unknown>
854
+ >
855
+ ).mockRejectedValueOnce(new Error("Send failed"));
504
856
  const consoleError = jest
505
857
  .spyOn(console, "error")
506
858
  .mockImplementation(() => {});
@@ -530,8 +882,8 @@ describe("handleWorkerMessage", () => {
530
882
  });
531
883
 
532
884
  it("handles render-component", async () => {
533
- mockOrchestrator.router.findChannel.mockReturnValue({
534
- sendA2UI: (jest.fn() as any).mockResolvedValue(undefined),
885
+ (mockOrchestrator.router?.findChannel as jest.Mock).mockReturnValue({
886
+ sendA2UI: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
535
887
  });
536
888
  mockRoomIdFromGroupId.mockReturnValue("r1");
537
889
 
@@ -549,11 +901,20 @@ describe("handleWorkerMessage", () => {
549
901
  expect.any(Object),
550
902
  );
551
903
  expect(mockOrchestrator.roomManager.broadcastA2UI).toHaveBeenCalled();
904
+
905
+ // peer: group where channel does not implement sendA2UI
906
+ (mockOrchestrator.router?.findChannel as jest.Mock).mockReturnValue({});
907
+ await send({
908
+ type: "render-component",
909
+ payload: { groupId: "peer:g2", envelope: {} },
910
+ });
552
911
  });
553
912
 
554
913
  it("handles render-component sendA2UI rejection", async () => {
555
- mockOrchestrator.router.findChannel.mockReturnValue({
556
- sendA2UI: (jest.fn() as any).mockRejectedValue(new Error("err")),
914
+ (mockOrchestrator.router?.findChannel as jest.Mock).mockReturnValue({
915
+ sendA2UI: jest
916
+ .fn<() => Promise<void>>()
917
+ .mockRejectedValue(new Error("err")),
557
918
  });
558
919
  const consoleError = jest
559
920
  .spyOn(console, "error")
@@ -564,7 +925,6 @@ describe("handleWorkerMessage", () => {
564
925
  payload: { groupId: "peer:g1", envelope: {} },
565
926
  });
566
927
 
567
- // Wait for the fire-and-forget catch block
568
928
  await new Promise(process.nextTick);
569
929
 
570
930
  expect(consoleError).toHaveBeenCalledWith(
@@ -618,7 +978,7 @@ describe("handleWorkerMessage", () => {
618
978
  );
619
979
  });
620
980
 
621
- it("handles mcp-reauth-required", async () => {
981
+ it("handles mcp-reauth-required success, retry action, and popup success/failure", async () => {
622
982
  mockGetRemoteMcpConnection.mockResolvedValue({
623
983
  autoReconnectOAuth: true,
624
984
  label: "conn",
@@ -631,10 +991,49 @@ describe("handleWorkerMessage", () => {
631
991
  });
632
992
 
633
993
  expect(mockReconnectMcpOAuth).toHaveBeenCalled();
634
- expect(mockOrchestrator.agentWorker.postMessage).toHaveBeenCalledWith({
994
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
635
995
  type: "mcp-reauth-result",
636
996
  payload: { connectionId: "c1", success: true },
637
997
  });
998
+
999
+ // Reauth silent attempt fails -> triggers popup action
1000
+ mockGetRemoteMcpConnection.mockResolvedValue({
1001
+ autoReconnectOAuth: true,
1002
+ });
1003
+ mockReconnectMcpOAuth.mockResolvedValueOnce({
1004
+ success: false,
1005
+ error: "Token expired",
1006
+ });
1007
+
1008
+ await send({
1009
+ type: "mcp-reauth-required",
1010
+ payload: { connectionId: "c2" },
1011
+ });
1012
+
1013
+ const failedToastCall = mockShowToast.mock.calls[
1014
+ mockShowToast.mock.calls.length - 1
1015
+ ] as [string, { action?: { onClick?: () => Promise<void> } }];
1016
+ const onClick = failedToastCall[1]?.action?.onClick;
1017
+ expect(typeof onClick).toBe("function");
1018
+
1019
+ // Click popup reconnect -> success
1020
+ mockReconnectMcpOAuth.mockResolvedValueOnce({ success: true });
1021
+ await onClick!();
1022
+ expect(mockShowToast).toHaveBeenCalledWith(
1023
+ '🔑 OAuth reconnected for "c2"',
1024
+ expect.objectContaining({ type: "success" }),
1025
+ );
1026
+
1027
+ // Click popup reconnect -> failure
1028
+ mockReconnectMcpOAuth.mockResolvedValueOnce({
1029
+ success: false,
1030
+ error: "User cancelled",
1031
+ });
1032
+ await onClick!();
1033
+ expect(mockShowToast).toHaveBeenCalledWith(
1034
+ '🔑 OAuth reconnect failed for "c2": User cancelled',
1035
+ expect.objectContaining({ type: "error" }),
1036
+ );
638
1037
  });
639
1038
 
640
1039
  it("handles mcp-reauth-required without auto-reconnect", async () => {
@@ -645,7 +1044,7 @@ describe("handleWorkerMessage", () => {
645
1044
  payload: { connectionId: "c1" },
646
1045
  });
647
1046
 
648
- expect(mockOrchestrator.agentWorker.postMessage).toHaveBeenCalledWith({
1047
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
649
1048
  type: "mcp-reauth-result",
650
1049
  payload: { connectionId: "c1", success: false },
651
1050
  });
@@ -658,7 +1057,7 @@ describe("handleWorkerMessage", () => {
658
1057
 
659
1058
  const localResolve = jest.fn();
660
1059
  const localReject = jest.fn();
661
- (globalThis as any).pendingNativeAiResolvers = {
1060
+ globalNativeAi.pendingNativeAiResolvers = {
662
1061
  task_123: { resolve: localResolve, reject: localReject },
663
1062
  };
664
1063
 
@@ -676,7 +1075,6 @@ describe("handleWorkerMessage", () => {
676
1075
  },
677
1076
  });
678
1077
 
679
- // Wait for the async IIFE inside handleWorkerMessage
680
1078
  await new Promise(process.nextTick);
681
1079
 
682
1080
  expect(mockTranslateText).toHaveBeenCalledWith(
@@ -688,30 +1086,69 @@ describe("handleWorkerMessage", () => {
688
1086
  );
689
1087
  expect(localResolve).toHaveBeenCalledWith("tres");
690
1088
  expect(
691
- (globalThis as any).pendingNativeAiResolvers["task_123"],
1089
+ globalNativeAi.pendingNativeAiResolvers?.["task_123"],
692
1090
  ).toBeUndefined();
693
- expect(mockOrchestrator.agentWorker.postMessage).toHaveBeenCalledWith({
1091
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
694
1092
  type: "native-ai-task-response",
695
1093
  payload: { id: "task_123", response: "tres" },
696
1094
  });
697
1095
  });
698
1096
 
1097
+ it("handles onProgress callback and task execution without groupId", async () => {
1098
+ mockGetConfig.mockResolvedValue("local");
1099
+ mockSummarizeText.mockImplementation(
1100
+ async (_text: unknown, options: unknown) => {
1101
+ const opts = options as { onProgress?: (p: unknown) => void };
1102
+ opts.onProgress?.({
1103
+ status: "downloading",
1104
+ progress: 0.5,
1105
+ message: "downloading model",
1106
+ });
1107
+ return "summary result";
1108
+ },
1109
+ );
1110
+ mockGetApiKeyForRequest.mockResolvedValueOnce(null);
1111
+
1112
+ await send({
1113
+ type: "request-native-ai-task",
1114
+ payload: {
1115
+ id: "task_no_group",
1116
+ taskType: "summarize",
1117
+ input: { text: "text without group" },
1118
+ },
1119
+ });
1120
+
1121
+ await new Promise(process.nextTick);
1122
+
1123
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
1124
+ type: "native-ai-task-response",
1125
+ payload: { id: "task_no_group", response: "summary result" },
1126
+ });
1127
+ });
1128
+
699
1129
  it("handles active_provider preference with Prompt API and resolves local resolvers", async () => {
700
1130
  mockGetConfig.mockResolvedValue("active_provider");
701
1131
  mockOrchestrator.provider = "prompt_api";
702
1132
 
703
- const mockPrompt = jest.fn(async (_prompt?: string) => "tres");
1133
+ const mockPrompt = jest.fn<(_p?: string) => Promise<string>>(
1134
+ async () => "tres",
1135
+ );
704
1136
  const mockDestroy = jest.fn();
705
- const mockCreate = jest.fn(async () => ({
1137
+ const mockCreate = jest.fn<
1138
+ () => Promise<{
1139
+ prompt: typeof mockPrompt;
1140
+ destroy: typeof mockDestroy;
1141
+ }>
1142
+ >(async () => ({
706
1143
  prompt: mockPrompt,
707
1144
  destroy: mockDestroy,
708
1145
  }));
709
1146
 
710
- (globalThis as any).LanguageModel = { create: mockCreate };
1147
+ globalNativeAi.LanguageModel = { create: mockCreate };
711
1148
 
712
1149
  const localResolve = jest.fn();
713
1150
  const localReject = jest.fn();
714
- (globalThis as any).pendingNativeAiResolvers = {
1151
+ globalNativeAi.pendingNativeAiResolvers = {
715
1152
  task_456: { resolve: localResolve, reject: localReject },
716
1153
  };
717
1154
 
@@ -737,15 +1174,15 @@ describe("handleWorkerMessage", () => {
737
1174
  );
738
1175
  expect(mockDestroy).toHaveBeenCalled();
739
1176
  expect(localResolve).toHaveBeenCalledWith("tres");
740
- expect(mockOrchestrator.agentWorker.postMessage).toHaveBeenCalledWith({
1177
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
741
1178
  type: "native-ai-task-response",
742
1179
  payload: { id: "task_456", response: "tres" },
743
1180
  });
744
1181
 
745
- delete (globalThis as any).LanguageModel;
1182
+ delete globalNativeAi.LanguageModel;
746
1183
  });
747
1184
 
748
- it("rejects local resolvers and sends error message on failure", async () => {
1185
+ it("rejects local resolvers and sends error message on failure with Error and non-Error", async () => {
749
1186
  mockGetConfig.mockResolvedValue("local");
750
1187
  mockSummarizeText.mockRejectedValue(
751
1188
  new Error("Summarizer out of memory"),
@@ -753,7 +1190,7 @@ describe("handleWorkerMessage", () => {
753
1190
 
754
1191
  const localResolve = jest.fn();
755
1192
  const localReject = jest.fn();
756
- (globalThis as any).pendingNativeAiResolvers = {
1193
+ globalNativeAi.pendingNativeAiResolvers = {
757
1194
  task_err: { resolve: localResolve, reject: localReject },
758
1195
  };
759
1196
 
@@ -773,64 +1210,113 @@ describe("handleWorkerMessage", () => {
773
1210
  expect.objectContaining({ message: "Summarizer out of memory" }),
774
1211
  );
775
1212
  expect(
776
- (globalThis as any).pendingNativeAiResolvers["task_err"],
1213
+ globalNativeAi.pendingNativeAiResolvers?.["task_err"],
777
1214
  ).toBeUndefined();
778
- expect(mockOrchestrator.agentWorker.postMessage).toHaveBeenCalledWith({
1215
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
779
1216
  type: "native-ai-task-response",
780
1217
  payload: { id: "task_err", error: "Summarizer out of memory" },
781
1218
  });
1219
+
1220
+ // Non-Error rejection without groupId
1221
+ mockSummarizeText.mockRejectedValueOnce("raw string failure");
1222
+ const stringResolve = jest.fn();
1223
+ const stringReject = jest.fn();
1224
+ globalNativeAi.pendingNativeAiResolvers = {
1225
+ task_string_err: { resolve: stringResolve, reject: stringReject },
1226
+ };
1227
+
1228
+ await send({
1229
+ type: "request-native-ai-task",
1230
+ payload: {
1231
+ id: "task_string_err",
1232
+ taskType: "summarize",
1233
+ input: { text: "Long text" },
1234
+ },
1235
+ });
1236
+
1237
+ await new Promise(process.nextTick);
1238
+
1239
+ expect(stringReject).toHaveBeenCalledWith(
1240
+ expect.objectContaining({ message: "raw string failure" }),
1241
+ );
1242
+ expect(mockOrchestrator.agentWorker?.postMessage).toHaveBeenCalledWith({
1243
+ type: "native-ai-task-response",
1244
+ payload: { id: "task_string_err", error: "raw string failure" },
1245
+ });
1246
+
1247
+ // Non-Error rejection WITH groupId
1248
+ mockSummarizeText.mockRejectedValueOnce("raw string failure with group");
1249
+ const stringResolveGroup = jest.fn();
1250
+ const stringRejectGroup = jest.fn();
1251
+ globalNativeAi.pendingNativeAiResolvers = {
1252
+ task_string_group: {
1253
+ resolve: stringResolveGroup,
1254
+ reject: stringRejectGroup,
1255
+ },
1256
+ };
1257
+
1258
+ await send({
1259
+ type: "request-native-ai-task",
1260
+ payload: {
1261
+ id: "task_string_group",
1262
+ groupId: "g1",
1263
+ taskType: "summarize",
1264
+ input: { text: "Long text" },
1265
+ },
1266
+ });
1267
+
1268
+ await new Promise(process.nextTick);
1269
+
1270
+ expect(mockOrchestrator.events.emit).toHaveBeenCalledWith(
1271
+ "model-download-progress",
1272
+ expect.objectContaining({
1273
+ groupId: "g1",
1274
+ status: "error",
1275
+ message: "raw string failure with group",
1276
+ }),
1277
+ );
782
1278
  });
783
1279
  });
784
1280
 
785
- /**
786
- * Regression test for the missing `model` field on inFlightEffectiveProviderByGroup.
787
- *
788
- * Before the fix, `executeActiveProviderTask` resolved the model via:
789
- * inFlightInfo?.providerConfig?.defaultModel || o.model
790
- *
791
- * This meant the conversation-pinned model was ignored and the provider's
792
- * config-level defaultModel was silently substituted. The fix adds a `model`
793
- * field directly to the in-flight entry so the resolved model is always used.
794
- */
795
1281
  describe("request-native-ai-task uses inFlightEffectiveProviderByGroup.model", () => {
796
1282
  it("passes the in-flight resolved model to formatRequest, not providerConfig.defaultModel", async () => {
797
1283
  const { getProvider } = await import("../../../config/config.js");
798
- (getProvider as jest.MockedFunction<any>).mockReturnValue({
1284
+ (getProvider as unknown as jest.Mock).mockReturnValue({
799
1285
  name: "Test Provider",
800
1286
  baseUrl: "https://api.test.example/v1",
801
1287
  format: "openai",
802
1288
  requiresApiKey: true,
803
1289
  supportsStreaming: false,
804
- defaultModel: "provider-default-model", // should NOT be used
1290
+ defaultModel: "provider-default-model",
805
1291
  });
806
1292
 
807
1293
  mockGetConfig.mockResolvedValue("active_provider");
808
1294
  mockOrchestrator.provider = "test-provider";
809
- mockOrchestrator.model = "orchestrator-fallback-model"; // should NOT be used
1295
+ mockOrchestrator.model = "orchestrator-fallback-model";
810
1296
 
811
- // The resolved (pinned) model recorded when the invocation started
812
1297
  const resolvedModel = "claude-sonnet-4-5-pinned";
813
1298
  mockOrchestrator.inFlightEffectiveProviderByGroup.set("g1", {
814
1299
  providerId: "test-provider",
815
1300
  providerConfig: {
1301
+ ...dummyProviderConfig,
816
1302
  name: "Test Provider",
817
1303
  baseUrl: "https://api.test.example/v1",
818
1304
  format: "openai",
819
1305
  requiresApiKey: true,
820
1306
  defaultModel: "provider-default-model",
821
1307
  },
822
- // ← This field was missing before the fix; without it, formatRequest
823
- // would have received 'provider-default-model' or 'orchestrator-fallback-model'.
824
1308
  model: resolvedModel,
825
1309
  });
826
1310
 
827
- global.fetch = (jest.fn() as any).mockResolvedValue({
828
- ok: true,
829
- json: () =>
830
- Promise.resolve({
831
- choices: [{ message: { content: "Summarized." } }],
832
- }),
833
- }) as any;
1311
+ global.fetch = jest
1312
+ .fn<(...args: unknown[]) => Promise<Response>>()
1313
+ .mockResolvedValue({
1314
+ ok: true,
1315
+ json: () =>
1316
+ Promise.resolve({
1317
+ choices: [{ message: { content: "Summarized." } }],
1318
+ }),
1319
+ } as unknown as Response);
834
1320
 
835
1321
  mockParseResponse.mockReturnValue({
836
1322
  content: [{ type: "text", text: "Summarized." }],
@@ -848,8 +1334,6 @@ describe("handleWorkerMessage", () => {
848
1334
 
849
1335
  await new Promise(process.nextTick);
850
1336
 
851
- // formatRequest must have been called with the resolved pinned model,
852
- // not providerConfig.defaultModel or o.model.
853
1337
  expect(mockFormatRequest).toHaveBeenCalledWith(
854
1338
  expect.anything(),
855
1339
  expect.anything(),
@@ -857,5 +1341,41 @@ describe("handleWorkerMessage", () => {
857
1341
  expect.objectContaining({ model: resolvedModel }),
858
1342
  );
859
1343
  });
1344
+
1345
+ it("falls back to providerConfig.defaultModel when inFlightInfo.model is not set", async () => {
1346
+ mockGetConfig.mockResolvedValue("active_provider");
1347
+ mockOrchestrator.inFlightEffectiveProviderByGroup.set("g1", {
1348
+ providerId: "test-provider",
1349
+ providerConfig: {
1350
+ ...dummyProviderConfig,
1351
+ defaultModel: "provider-config-default-model",
1352
+ },
1353
+ model: "",
1354
+ });
1355
+
1356
+ await send({
1357
+ type: "request-native-ai-task",
1358
+ payload: {
1359
+ id: "model-fallback-task",
1360
+ groupId: "g1",
1361
+ taskType: "summarize",
1362
+ input: { text: "A very long article…" },
1363
+ },
1364
+ });
1365
+
1366
+ await new Promise(process.nextTick);
1367
+
1368
+ expect(mockFormatRequest).toHaveBeenCalledWith(
1369
+ expect.anything(),
1370
+ expect.anything(),
1371
+ expect.anything(),
1372
+ expect.objectContaining({ model: "provider-config-default-model" }),
1373
+ );
1374
+ });
1375
+ });
1376
+
1377
+ it("safely ignores unrecognized worker message types", async () => {
1378
+ await send({ type: "unknown-type-xyz", payload: {} });
1379
+ expect(mockOrchestrator.events.emit).not.toHaveBeenCalled();
860
1380
  });
861
1381
  });