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
@@ -78,6 +78,7 @@ import { ensureControlPlaneConnected } from "../utils/initControlPlane.js";
78
78
  import { applyAllChannelRunningStates } from "./utils/operations/channel.js";
79
79
 
80
80
  import type { ShadowClawDatabase } from "../../db/db.js";
81
+ import type { InboundMessage } from "../../subsystems/channels/types.js";
81
82
  import type { A2UIAction } from "../../ui/a2ui/types.js";
82
83
  import type { DirectToolCommandPolicy } from "./utils/types.js";
83
84
 
@@ -139,7 +140,7 @@ export class Orchestrator {
139
140
 
140
141
  meshLlmHost: string = "";
141
142
 
142
- messageQueue: any[] = [];
143
+ messageQueue: InboundMessage[] = [];
143
144
  model: string = getDefaultProvider().defaultModel;
144
145
 
145
146
  /** Peer groupIds where the A2A task has reached a terminal state */
@@ -503,15 +504,7 @@ export class Orchestrator {
503
504
  if (!storedKey) {
504
505
  this.#encryptedApiKey = "";
505
506
  } else {
506
- try {
507
- // We now store the encrypted key directly in the field.
508
- // decryptValue is only called on-demand.
509
- this.#encryptedApiKey = storedKey;
510
- } catch (e) {
511
- console.warn("[Orchestrator] Failed to load API key:", e);
512
-
513
- this.#encryptedApiKey = "";
514
- }
507
+ this.#encryptedApiKey = storedKey;
515
508
  }
516
509
 
517
510
  this.#apiKeyCache = null; // Invalidate cache
@@ -1,194 +1,496 @@
1
- import { jest } from "@jest/globals";
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
2
 
3
+ import { CONFIG_KEYS, DEFAULT_GROUP_ID } from "../../../config/config.js";
4
+ import type { ProviderConfig } from "../../../config/config.js";
5
+ import type { ConversationMessage } from "../../../content/types.js";
6
+ import type { ShadowClawDatabase } from "../../../db/db.js";
7
+ import type { GroupMeta } from "../../../db/types.js";
3
8
  import type { Orchestrator } from "../orchestrator.js";
4
9
 
10
+ const mockReadGroupFile =
11
+ jest.fn<
12
+ (
13
+ db: ShadowClawDatabase,
14
+ groupId: string,
15
+ filename: string,
16
+ ) => Promise<string>
17
+ >();
5
18
  jest.unstable_mockModule("../../../storage/readGroupFile.js", () => ({
6
- readGroupFile: jest.fn<any>().mockRejectedValue(new Error("no file")),
19
+ readGroupFile: mockReadGroupFile,
7
20
  }));
8
21
 
22
+ const mockBuildConversationMessages =
23
+ jest.fn<(groupId: string, limit: number) => Promise<ConversationMessage[]>>();
9
24
  jest.unstable_mockModule("../../../db/buildConversationMessages.js", () => ({
10
- buildConversationMessages: jest.fn<any>().mockResolvedValue([]),
25
+ buildConversationMessages: mockBuildConversationMessages,
11
26
  }));
12
27
 
28
+ const mockGetConfig =
29
+ jest.fn<(db: ShadowClawDatabase, key: string) => Promise<string | null>>();
13
30
  jest.unstable_mockModule("../../../db/getConfig.js", () => ({
14
- getConfig: jest.fn<any>().mockResolvedValue(null),
15
- }));
16
-
17
- jest.unstable_mockModule("../../../db/saveMessage.js", () => ({
18
- saveMessage: jest.fn<any>().mockResolvedValue(1),
31
+ getConfig: mockGetConfig,
19
32
  }));
20
33
 
34
+ let mockGroupsList: GroupMeta[] = [];
21
35
  jest.unstable_mockModule("../../../db/groups.js", () => ({
22
- getGroupMetadata: jest.fn<any>().mockResolvedValue([]),
23
- saveGroupMetadata: jest.fn<any>().mockResolvedValue(undefined),
24
- createGroup: jest.fn<any>().mockResolvedValue({} as any),
25
- renameGroup: jest.fn<any>().mockResolvedValue(undefined),
26
- updateGroupToolTags: jest.fn<any>().mockResolvedValue(undefined),
27
- deleteGroupMetadata: jest.fn<any>().mockResolvedValue(undefined),
28
- listGroups: jest.fn<any>().mockResolvedValue([
29
- {
30
- groupId: "group-pinned",
31
- pinnedProvider: "transformers_js_local",
32
- pinnedModel: "onnx-community/Llama-3.2-1B-Instruct",
33
- },
34
- ]),
35
- reorderGroups: jest.fn<any>().mockResolvedValue(undefined),
36
- cloneGroup: jest.fn<any>().mockResolvedValue({} as any),
37
- updateGroupPinnedProvider: jest.fn<any>().mockResolvedValue(undefined),
38
- updateGroupSubagentSettings: jest.fn<any>().mockResolvedValue(undefined),
36
+ getGroupMetadata: jest.fn<() => Promise<GroupMeta[]>>().mockResolvedValue([]),
37
+ saveGroupMetadata: jest
38
+ .fn<() => Promise<void>>()
39
+ .mockResolvedValue(undefined),
40
+ createGroup: jest
41
+ .fn<() => Promise<GroupMeta>>()
42
+ .mockResolvedValue({} as GroupMeta),
43
+ renameGroup: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
44
+ updateGroupToolTags: jest
45
+ .fn<() => Promise<void>>()
46
+ .mockResolvedValue(undefined),
47
+ deleteGroupMetadata: jest
48
+ .fn<() => Promise<void>>()
49
+ .mockResolvedValue(undefined),
50
+ listGroups: jest.fn<() => Promise<GroupMeta[]>>(async () => mockGroupsList),
51
+ reorderGroups: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
52
+ cloneGroup: jest
53
+ .fn<() => Promise<GroupMeta>>()
54
+ .mockResolvedValue({} as GroupMeta),
55
+ updateGroupPinnedProvider: jest
56
+ .fn<() => Promise<void>>()
57
+ .mockResolvedValue(undefined),
58
+ updateGroupSubagentSettings: jest
59
+ .fn<() => Promise<void>>()
60
+ .mockResolvedValue(undefined),
39
61
  updateGroupProviderRuntimeOverrides: jest
40
- .fn<any>()
62
+ .fn<() => Promise<void>>()
41
63
  .mockResolvedValue(undefined),
42
64
  }));
43
65
 
66
+ const mockEnsureBuiltinAiPolyfills = jest
67
+ .fn<() => Promise<void>>()
68
+ .mockResolvedValue(undefined);
69
+ const mockSummarizeText = jest
70
+ .fn<(text: string, options: unknown) => Promise<string>>()
71
+ .mockResolvedValue("Mock task summary");
44
72
  jest.unstable_mockModule(
45
73
  "../../../subsystems/providers/builtin-ai-tasks.js",
46
74
  () => ({
47
- ensureBuiltinAiPolyfills: jest.fn<any>().mockResolvedValue(undefined),
48
- summarizeText: jest
49
- .fn<any>()
50
- .mockResolvedValue("Mock summary from Task API"),
51
- writeText: jest.fn<any>().mockResolvedValue(""),
52
- rewriteText: jest.fn<any>().mockResolvedValue(""),
53
- detectLanguage: jest.fn<any>().mockResolvedValue([]),
54
- translateText: jest.fn<any>().mockResolvedValue(""),
55
- proofreadText: jest.fn<any>().mockResolvedValue(""),
56
- embedText: jest.fn<any>().mockResolvedValue([]),
57
- isBuiltinTaskSupported: jest.fn(() => true),
58
- getPromptApiFallbackModel: jest.fn<any>().mockResolvedValue(""),
59
- createTaskInstanceWithFallback: jest.fn<any>().mockResolvedValue({}),
60
- isWebGpuAdapterAvailable: jest.fn<any>().mockResolvedValue(false),
75
+ ensureBuiltinAiPolyfills: mockEnsureBuiltinAiPolyfills,
76
+ summarizeText: mockSummarizeText,
77
+ writeText: jest.fn<() => Promise<string>>().mockResolvedValue(""),
78
+ rewriteText: jest.fn<() => Promise<string>>().mockResolvedValue(""),
79
+ detectLanguage: jest.fn<() => Promise<unknown[]>>().mockResolvedValue([]),
80
+ translateText: jest.fn<() => Promise<string>>().mockResolvedValue(""),
81
+ proofreadText: jest.fn<() => Promise<string>>().mockResolvedValue(""),
82
+ embedText: jest.fn<() => Promise<unknown[]>>().mockResolvedValue([]),
83
+ isBuiltinTaskSupported: jest.fn<() => boolean>(() => true),
84
+ getPromptApiFallbackModel: jest
85
+ .fn<() => Promise<string>>()
86
+ .mockResolvedValue(""),
87
+ createTaskInstanceWithFallback: jest
88
+ .fn<() => Promise<unknown>>()
89
+ .mockResolvedValue({}),
90
+ isWebGpuAdapterAvailable: jest
91
+ .fn<() => Promise<boolean>>()
92
+ .mockResolvedValue(false),
61
93
  }),
62
94
  );
63
95
 
96
+ const mockIsPromptApiSupported = jest.fn<() => boolean>().mockReturnValue(true);
97
+ const mockCompactWithPromptApi = jest
98
+ .fn<
99
+ (
100
+ systemPrompt: string,
101
+ messages: unknown[],
102
+ signal: AbortSignal,
103
+ onMessage: (msg: unknown) => Promise<void>,
104
+ groupId: string,
105
+ ) => Promise<string>
106
+ >()
107
+ .mockResolvedValue("Mock prompt API summary");
108
+
64
109
  jest.unstable_mockModule(
65
110
  "../../../subsystems/providers/prompt-api-provider.js",
66
111
  () => ({
67
- isPromptApiSupported: jest.fn<any>().mockReturnValue(true),
68
- compactWithPromptApi: jest
69
- .fn<any>()
70
- .mockResolvedValue("Mock prompt API summary"),
71
- invokeWithPromptApi: jest.fn<any>().mockResolvedValue(undefined),
112
+ isPromptApiSupported: mockIsPromptApiSupported,
113
+ compactWithPromptApi: mockCompactWithPromptApi,
114
+ invokeWithPromptApi: jest
115
+ .fn<() => Promise<void>>()
116
+ .mockResolvedValue(undefined),
72
117
  }),
73
118
  );
74
119
 
120
+ const mockDeliverResponse = jest
121
+ .fn<
122
+ (
123
+ o: Orchestrator,
124
+ db: ShadowClawDatabase,
125
+ groupId: string,
126
+ text: string,
127
+ ) => Promise<void>
128
+ >()
129
+ .mockResolvedValue(undefined);
130
+
131
+ jest.unstable_mockModule("./deliverResponse.js", () => ({
132
+ deliverResponse: mockDeliverResponse,
133
+ }));
134
+
135
+ const mockHandleWorkerMessage = jest
136
+ .fn<
137
+ (o: Orchestrator, db: ShadowClawDatabase, msg: unknown) => Promise<void>
138
+ >()
139
+ .mockResolvedValue(undefined);
140
+
141
+ jest.unstable_mockModule("./handleWorkerMessage.js", () => ({
142
+ handleWorkerMessage: mockHandleWorkerMessage,
143
+ }));
144
+
145
+ let mockPeerState: unknown = null;
146
+ jest.unstable_mockModule("../../../stores/orchestrator.js", () => ({
147
+ orchestratorStore: {
148
+ getPeerState: jest.fn(() => mockPeerState),
149
+ },
150
+ }));
151
+
152
+ const { compactContext } = await import("./compactContext.js");
153
+ const { Orchestrator: Orch } = await import("../orchestrator.js");
154
+
75
155
  describe("compactContext", () => {
76
- let db: any;
156
+ let db: ShadowClawDatabase;
77
157
  let o: Orchestrator;
78
- let compactContext: any;
79
158
 
80
- beforeEach(async () => {
81
- db = {} as any;
82
- const { Orchestrator: Orch } = await import("../orchestrator.js");
159
+ beforeEach(() => {
160
+ jest.clearAllMocks();
161
+ db = {} as unknown as ShadowClawDatabase;
162
+ mockPeerState = null;
163
+ mockGroupsList = [
164
+ {
165
+ groupId: "group-pinned",
166
+ name: "Pinned Group",
167
+ createdAt: Date.now(),
168
+ pinnedProvider: "transformers_js_local",
169
+ pinnedModel: "onnx-community/Llama-3.2-1B-Instruct",
170
+ },
171
+ {
172
+ groupId: "group-pinned-no-model",
173
+ name: "Pinned Group No Model",
174
+ createdAt: Date.now(),
175
+ pinnedProvider: "openrouter",
176
+ },
177
+ {
178
+ groupId: "group-pinned-unknown-prov",
179
+ name: "Pinned Group Unknown Prov",
180
+ createdAt: Date.now(),
181
+ pinnedProvider: "unknown_prov_xyz",
182
+ },
183
+ ];
184
+
185
+ mockReadGroupFile.mockRejectedValue(new Error("File not found"));
186
+ mockBuildConversationMessages.mockResolvedValue([]);
187
+ mockGetConfig.mockResolvedValue(null);
188
+ mockIsPromptApiSupported.mockReturnValue(true);
189
+
83
190
  o = new Orch();
191
+ jest.spyOn(o, "getApiKey").mockResolvedValue("valid-api-key");
84
192
  o.provider = "openrouter";
85
- o.providerConfig = { requiresApiKey: false } as any;
86
-
87
- const mod = await import("./compactContext.js");
88
- compactContext = mod.compactContext;
193
+ o.model = "openai/gpt-4o";
194
+ o.providerConfig = {
195
+ id: "openrouter",
196
+ name: "OpenRouter",
197
+ defaultModel: "openai/gpt-4o",
198
+ requiresApiKey: false,
199
+ } as ProviderConfig;
200
+ o.agentWorker = { postMessage: jest.fn() } as unknown as Worker;
201
+ o.handleCompactDone = jest
202
+ .fn<() => Promise<void>>()
203
+ .mockResolvedValue(undefined);
89
204
  });
90
205
 
91
- it("emits error if requires API key and none is set", async () => {
206
+ it("should emit error and provider-help if provider requires API key and none is set", async () => {
92
207
  o.provider = "openrouter";
93
- o.providerConfig = { requiresApiKey: true } as any;
208
+ o.providerConfig = {
209
+ id: "openrouter",
210
+ name: "OpenRouter",
211
+ defaultModel: "openai/gpt-4o",
212
+ requiresApiKey: true,
213
+ } as ProviderConfig;
94
214
  jest.spyOn(o, "getApiKey").mockResolvedValue("");
95
215
 
96
- const events: any[] = [];
97
- o.events.on("error", (e: any) => events.push(e));
216
+ const helpEvents: unknown[] = [];
217
+ const errorEvents: unknown[] = [];
218
+ o.events.on("provider-help", (e) => helpEvents.push(e));
219
+ o.events.on("error", (e) => errorEvents.push(e));
98
220
 
99
221
  await compactContext(o, db, "group-1");
100
222
 
101
- expect(events).toHaveLength(1);
102
- expect(events[0].error).toMatch(/API key not configured/);
223
+ expect(helpEvents).toHaveLength(1);
224
+ expect(errorEvents).toHaveLength(1);
225
+ expect(errorEvents[0]).toEqual({
226
+ groupId: "group-1",
227
+ error: "API key not configured. Cannot compact context.",
228
+ });
103
229
  });
104
230
 
105
- it("emits error if state is not idle", async () => {
106
- jest.spyOn(o, "getApiKey").mockResolvedValue("key");
231
+ it("should fetch specific API key when effective provider differs from orchestrator provider", async () => {
232
+ const specificApiKeySpy = jest
233
+ .spyOn(o, "getApiKeyForSpecificProvider")
234
+ .mockResolvedValueOnce("specific-key");
235
+ mockGroupsList.push({
236
+ groupId: "group-different-prov",
237
+ name: "Diff Prov",
238
+ createdAt: Date.now(),
239
+ pinnedProvider: "anthropic",
240
+ pinnedModel: "claude-3-5-sonnet",
241
+ });
242
+
243
+ await compactContext(o, db, "group-different-prov");
244
+
245
+ expect(specificApiKeySpy).toHaveBeenCalledWith(db, "anthropic");
246
+ expect(o.agentWorker?.postMessage).toHaveBeenCalled();
247
+ });
248
+
249
+ it("should emit error and abort if orchestrator state is not idle", async () => {
107
250
  o.setState("thinking");
108
251
 
109
- const events: any[] = [];
110
- o.events.on("error", (e: any) => events.push(e));
252
+ const errorEvents: unknown[] = [];
253
+ o.events.on("error", (e) => errorEvents.push(e));
111
254
 
112
255
  await compactContext(o, db, "group-1");
113
256
 
114
- expect(events).toHaveLength(1);
115
- expect(events[0].error).toMatch(/Cannot compact while processing/);
257
+ expect(errorEvents).toHaveLength(1);
258
+ expect(errorEvents[0]).toEqual({
259
+ groupId: "group-1",
260
+ error:
261
+ "Cannot compact while processing. Wait for the current response to finish.",
262
+ });
116
263
  });
117
264
 
118
- it("sends message to agentWorker", async () => {
119
- jest.spyOn(o, "getApiKey").mockResolvedValue("key");
120
- const postMessage = jest.fn();
121
- o.agentWorker = { postMessage } as any;
265
+ it("should read memory from MEMORY.md if available", async () => {
266
+ mockReadGroupFile.mockResolvedValueOnce("# Context Memory");
122
267
 
123
268
  await compactContext(o, db, "group-1");
124
269
 
270
+ const postMessage = o.agentWorker?.postMessage as jest.Mock;
125
271
  expect(postMessage).toHaveBeenCalled();
126
- const payload: any = postMessage.mock.calls[0][0];
127
- expect(payload.type).toBe("compact");
128
- expect(payload.payload.groupId).toBe("group-1");
272
+ const payload = postMessage.mock.calls[0][0] as {
273
+ payload: { memory: string };
274
+ };
275
+ expect(payload.payload.memory).toBe("# Context Memory");
129
276
  });
130
277
 
131
- it("uses effective provider and model when group has pinned provider", async () => {
132
- jest.spyOn(o, "getApiKey").mockResolvedValue("key");
133
- const postMessage = jest.fn();
134
- o.agentWorker = { postMessage } as any;
135
-
136
- o.provider = "openrouter";
137
- o.model = "gpt-4o";
278
+ it("should use peerState from orchestratorStore when available", async () => {
279
+ mockPeerState = { peerId: "peer-abc", typing: true };
138
280
 
139
- await compactContext(o, db, "group-pinned");
281
+ await compactContext(o, db, "group-1");
140
282
 
141
- expect(postMessage).toHaveBeenCalled();
142
- const payload: any = postMessage.mock.calls[0][0];
143
- expect(payload.type).toBe("compact");
144
- expect(payload.payload.provider).toBe("transformers_js_local");
145
- expect(payload.payload.model).toBe("onnx-community/Llama-3.2-1B-Instruct");
283
+ expect(o.agentWorker?.postMessage).toHaveBeenCalled();
146
284
  });
147
285
 
148
- it("handles compactionPref === 'builtin_task_api'", async () => {
149
- jest.spyOn(o, "getApiKey").mockResolvedValue("key");
150
- const { getConfig } = await import("../../../db/getConfig.js");
151
- (getConfig as jest.Mock<any>).mockImplementation(
152
- (_db: any, key: string) => {
153
- if (key === "compaction_engine_preference") {
154
- return Promise.resolve("builtin_task_api");
155
- }
156
- return Promise.resolve(null);
157
- },
158
- );
286
+ it("should resolve fallback model for prompt_api when browser-built-in and not supported", async () => {
287
+ o.provider = "prompt_api";
288
+ o.model = "browser-built-in";
289
+ mockIsPromptApiSupported.mockReturnValue(false);
290
+ mockGetConfig.mockImplementation(async (_db, key) => {
291
+ if (key === CONFIG_KEYS.PROMPT_API_FALLBACK_MODEL) {
292
+ return "custom-fallback-model";
293
+ }
294
+ return null;
295
+ });
159
296
 
160
- const { buildConversationMessages } =
161
- await import("../../../db/buildConversationMessages.js");
162
- (buildConversationMessages as jest.Mock<any>).mockResolvedValue([
163
- { role: "user", content: "Hi" },
164
- { role: "assistant", content: "Hello" },
165
- ]);
297
+ await compactContext(o, db, "group-1");
298
+
299
+ expect(mockEnsureBuiltinAiPolyfills).toHaveBeenCalled();
300
+ });
166
301
 
167
- o.handleCompactDone = jest.fn<any>().mockResolvedValue(undefined);
302
+ it("should resolve default fallback model when PROMPT_API_FALLBACK_MODEL is null", async () => {
303
+ o.provider = "prompt_api";
304
+ o.model = "";
305
+ mockIsPromptApiSupported.mockReturnValue(false);
306
+ mockGetConfig.mockResolvedValue(null);
168
307
 
169
308
  await compactContext(o, db, "group-1");
170
309
 
171
- expect(o.handleCompactDone).toHaveBeenCalledWith(
172
- db,
173
- "group-1",
174
- "Mock summary from Task API",
175
- );
310
+ expect(mockEnsureBuiltinAiPolyfills).toHaveBeenCalled();
176
311
  });
177
312
 
178
- it("handles effectiveProviderId === 'prompt_api'", async () => {
179
- const { getConfig } = await import("../../../db/getConfig.js");
180
- (getConfig as jest.Mock<any>).mockResolvedValue(null);
313
+ it("should handle pinned provider without pinned model using defaultModel or fallback", async () => {
314
+ await compactContext(o, db, "group-pinned-no-model");
315
+ const postMessage = o.agentWorker?.postMessage as jest.Mock;
316
+ expect(postMessage).toHaveBeenCalled();
317
+ const payload = postMessage.mock.calls[0][0] as {
318
+ payload: { provider: string };
319
+ };
320
+ expect(payload.payload.provider).toBe("openrouter");
321
+
322
+ // Also test group with unknown pinned provider
323
+ o.setState("idle");
324
+ jest
325
+ .spyOn(o, "getApiKeyForSpecificProvider")
326
+ .mockResolvedValue("specific-key");
327
+ await compactContext(o, db, "group-pinned-unknown-prov");
328
+ expect(postMessage).toHaveBeenCalledTimes(2);
329
+ });
181
330
 
182
- o.provider = "prompt_api";
183
- o.providerConfig = { requiresApiKey: false } as any;
184
- o.handleCompactDone = jest.fn<any>().mockResolvedValue(undefined);
331
+ it("should default groupId to DEFAULT_GROUP_ID when omitted", async () => {
332
+ await compactContext(o, db);
333
+ const postMessage = o.agentWorker?.postMessage as jest.Mock;
334
+ expect(postMessage).toHaveBeenCalled();
335
+ const payload = postMessage.mock.calls[0][0] as {
336
+ payload: { groupId: string };
337
+ };
338
+ expect(payload.payload.groupId).toBe(DEFAULT_GROUP_ID);
339
+ });
185
340
 
186
- await compactContext(o, db, "group-1");
341
+ describe("compactionPref === builtin_task_api", () => {
342
+ it("should summarize messages and call handleCompactDone on success", async () => {
343
+ mockGetConfig.mockImplementation(async (_db, key) => {
344
+ if (key === CONFIG_KEYS.COMPACTION_ENGINE_PREFERENCE) {
345
+ return "builtin_task_api";
346
+ }
347
+ return null;
348
+ });
349
+ mockBuildConversationMessages.mockResolvedValueOnce([
350
+ { role: "user", content: "Hello" },
351
+ { role: "assistant", content: { text: "World" } as unknown as string },
352
+ ]);
353
+
354
+ await compactContext(o, db, "group-1");
355
+
356
+ expect(mockSummarizeText).toHaveBeenCalled();
357
+ expect(o.handleCompactDone).toHaveBeenCalledWith(
358
+ db,
359
+ "group-1",
360
+ "Mock task summary",
361
+ );
362
+ });
363
+
364
+ it("should catch error in summarizeText and deliver fallback response", async () => {
365
+ mockGetConfig.mockImplementation(async (_db, key) => {
366
+ if (key === CONFIG_KEYS.COMPACTION_ENGINE_PREFERENCE) {
367
+ return "builtin_task_api";
368
+ }
369
+ return null;
370
+ });
371
+ mockSummarizeText.mockRejectedValueOnce(new Error("Summarizer failed"));
372
+
373
+ await compactContext(o, db, "group-1");
374
+
375
+ expect(mockDeliverResponse).toHaveBeenCalledWith(
376
+ o,
377
+ db,
378
+ "group-1",
379
+ "⚠️ Built-in Task API compaction failed, falling back to provider: Summarizer failed",
380
+ );
381
+ expect(o.agentWorker?.postMessage).toHaveBeenCalled();
382
+ });
383
+
384
+ it("should catch non-Error thrown in summarizeText and deliver fallback response", async () => {
385
+ mockGetConfig.mockImplementation(async (_db, key) => {
386
+ if (key === CONFIG_KEYS.COMPACTION_ENGINE_PREFERENCE) {
387
+ return "builtin_task_api";
388
+ }
389
+ return null;
390
+ });
391
+ mockSummarizeText.mockRejectedValueOnce("Non-error string failure");
392
+
393
+ await compactContext(o, db, "group-1");
394
+
395
+ expect(mockDeliverResponse).toHaveBeenCalledWith(
396
+ o,
397
+ db,
398
+ "group-1",
399
+ "⚠️ Built-in Task API compaction failed, falling back to provider: Non-error string failure",
400
+ );
401
+ });
402
+ });
187
403
 
188
- expect(o.handleCompactDone).toHaveBeenCalledWith(
189
- db,
190
- "group-1",
191
- "Mock prompt API summary",
192
- );
404
+ describe("effectiveProviderId === prompt_api", () => {
405
+ it("should abort if prompt_api remains unsupported after polyfill attempt", async () => {
406
+ o.provider = "prompt_api";
407
+ mockIsPromptApiSupported.mockReturnValue(false);
408
+
409
+ const errorEvents: unknown[] = [];
410
+ const typingEvents: unknown[] = [];
411
+ o.events.on("error", (e) => errorEvents.push(e));
412
+ o.events.on("typing", (e) => typingEvents.push(e));
413
+
414
+ await compactContext(o, db, "group-1");
415
+
416
+ expect(mockEnsureBuiltinAiPolyfills).toHaveBeenCalled();
417
+ expect(errorEvents).toHaveLength(1);
418
+ expect(typingEvents).toContainEqual({
419
+ groupId: "group-1",
420
+ typing: false,
421
+ });
422
+ expect(o.state).toBe("idle");
423
+ });
424
+
425
+ it("should execute compactWithPromptApi, trigger handleWorkerMessage callback, and call handleCompactDone", async () => {
426
+ o.provider = "prompt_api";
427
+ mockIsPromptApiSupported.mockReturnValue(true);
428
+
429
+ await compactContext(o, db, "group-1");
430
+
431
+ expect(mockCompactWithPromptApi).toHaveBeenCalled();
432
+ const onMsgCb = mockCompactWithPromptApi.mock.calls[0][3];
433
+ await onMsgCb({ type: "stream-chunk" });
434
+ expect(mockHandleWorkerMessage).toHaveBeenCalledWith(o, db, {
435
+ type: "stream-chunk",
436
+ });
437
+
438
+ expect(o.handleCompactDone).toHaveBeenCalledWith(
439
+ db,
440
+ "group-1",
441
+ "Mock prompt API summary",
442
+ );
443
+ expect(o.promptControllers.has("group-1")).toBe(false);
444
+ });
445
+
446
+ it("should return early when compactWithPromptApi throws AbortError", async () => {
447
+ o.provider = "prompt_api";
448
+ mockIsPromptApiSupported.mockReturnValue(true);
449
+
450
+ const abortError = new Error("Aborted");
451
+ abortError.name = "AbortError";
452
+ mockCompactWithPromptApi.mockRejectedValueOnce(abortError);
453
+
454
+ await compactContext(o, db, "group-1");
455
+
456
+ expect(mockDeliverResponse).not.toHaveBeenCalled();
457
+ expect(o.promptControllers.has("group-1")).toBe(false);
458
+ });
459
+
460
+ it("should deliver error response when compactWithPromptApi throws regular Error", async () => {
461
+ o.provider = "prompt_api";
462
+ mockIsPromptApiSupported.mockReturnValue(true);
463
+
464
+ mockCompactWithPromptApi.mockRejectedValueOnce(
465
+ new Error("Prompt API crash"),
466
+ );
467
+
468
+ await compactContext(o, db, "group-1");
469
+
470
+ expect(mockDeliverResponse).toHaveBeenCalledWith(
471
+ o,
472
+ db,
473
+ "group-1",
474
+ "⚠️ Error: Compaction failed: Prompt API crash",
475
+ );
476
+ expect(o.promptControllers.has("group-1")).toBe(false);
477
+ });
478
+
479
+ it("should deliver error response when compactWithPromptApi throws non-Error", async () => {
480
+ o.provider = "prompt_api";
481
+ mockIsPromptApiSupported.mockReturnValue(true);
482
+
483
+ mockCompactWithPromptApi.mockRejectedValueOnce("String error thrown");
484
+
485
+ await compactContext(o, db, "group-1");
486
+
487
+ expect(mockDeliverResponse).toHaveBeenCalledWith(
488
+ o,
489
+ db,
490
+ "group-1",
491
+ "⚠️ Error: Compaction failed: String error thrown",
492
+ );
493
+ expect(o.promptControllers.has("group-1")).toBe(false);
494
+ });
193
495
  });
194
496
  });