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,52 +1,128 @@
1
- import { jest } from "@jest/globals";
1
+ import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import { DEFAULT_GROUP_ID } from "../../../../config/config.js";
3
+ import type { MessageAttachment } from "../../../../content/types.js";
4
+ import type { ShadowClawDatabase } from "../../../../db/db.js";
5
+ import type {
6
+ Channel,
7
+ ChannelType,
8
+ } from "../../../../subsystems/channels/types.js";
9
+ import type { OrchestratorState } from "../../orchestrator-state.js";
2
10
 
3
- import {
4
- getChannelTypeForGroup,
11
+ const mockDefaultSetConfig =
12
+ jest.fn<
13
+ (_db: ShadowClawDatabase, _key: string, _val: string) => Promise<void>
14
+ >();
15
+ const mockDefaultGetConfig =
16
+ jest.fn<
17
+ (_db: ShadowClawDatabase, _key: string) => Promise<string | undefined>
18
+ >();
19
+ const mockEncryptValue = jest.fn<(val: string) => Promise<string | null>>();
20
+ const mockDecryptValue = jest.fn<(val: string) => Promise<string | null>>();
21
+ const mockSetRemoteAgentTyping =
22
+ jest.fn<(groupId: string, typing: boolean) => void>();
23
+
24
+ jest.unstable_mockModule("../../../../db/setConfig.js", () => ({
25
+ setConfig: mockDefaultSetConfig,
26
+ }));
27
+
28
+ jest.unstable_mockModule("../../../../db/getConfig.js", () => ({
29
+ getConfig: mockDefaultGetConfig,
30
+ }));
31
+
32
+ jest.unstable_mockModule("../../../../security/crypto.js", () => ({
33
+ encryptValue: mockEncryptValue,
34
+ decryptValue: mockDecryptValue,
35
+ }));
36
+
37
+ jest.unstable_mockModule("../../../../stores/orchestrator.js", () => ({
38
+ orchestratorStore: {
39
+ setRemoteAgentTyping: mockSetRemoteAgentTyping,
40
+ },
41
+ }));
42
+
43
+ const {
44
+ applyAllChannelRunningStates,
45
+ applyChannelRunningState,
46
+ clearPeerJsTypingState,
47
+ configureIMessage,
48
+ configureTelegram,
5
49
  getChannelByType,
6
50
  getChannelEnabled,
51
+ getChannelEnabledConfigKey,
52
+ getChannelTypeForGroup,
53
+ getIMessageConfig,
7
54
  getPeerJsConfig,
8
55
  getTelegramConfig,
9
- getIMessageConfig,
10
- getChannelEnabledConfigKey,
11
- shouldRunChannel,
12
- applyAllChannelRunningStates,
13
- clearPeerJsTypingState,
14
56
  loadChannelEnabled,
57
+ saveSecretConfig,
15
58
  setChannelEnabled,
59
+ shouldRunChannel,
16
60
  submitMessage,
17
- saveSecretConfig,
18
- configureIMessage,
19
- configureTelegram,
20
- } from "./channel.js";
61
+ } = await import("./channel.js");
21
62
 
22
- import type { ChannelType } from "../../../../subsystems/channels/types.js";
23
- import type { OrchestratorState } from "../../orchestrator-state.js";
63
+ interface MockChannel extends Channel {
64
+ ensureConnected?: (force: boolean) => void;
65
+ }
66
+
67
+ function makeChannel(
68
+ running = false,
69
+ ensureConnected?: (force: boolean) => void,
70
+ ): MockChannel {
71
+ const ch: MockChannel = {
72
+ running,
73
+ start: jest.fn(),
74
+ stop: jest.fn(),
75
+ submit: jest.fn(),
76
+ configure: jest.fn(),
77
+ } as unknown as MockChannel;
78
+ if (ensureConnected) {
79
+ ch.ensureConnected = ensureConnected;
80
+ }
81
+ return ch;
82
+ }
83
+
84
+ interface MockOrchestratorState {
85
+ browserChat: MockChannel;
86
+ telegram: MockChannel;
87
+ imessage: MockChannel;
88
+ peerjs: MockChannel;
89
+ channelEnabledByType: Partial<Record<ChannelType, boolean>>;
90
+ telegramBotToken: string;
91
+ telegramChatIds: string[];
92
+ telegramUseProxy: boolean;
93
+ imessageServerUrl: string;
94
+ imessageApiKey: string;
95
+ imessageChatIds: string[];
96
+ peerjsMyAlias: string;
97
+ peerjsMyPeerId: string;
98
+ peerjsPeerAliases: Record<string, string>;
99
+ peerjsServerHost: string;
100
+ peerjsServerPath: string;
101
+ peerjsServerPort: number;
102
+ peerjsServerSecure: boolean;
103
+ peerjsTrustedPeerIds: string[];
104
+ }
24
105
 
25
- function makeState(overrides: Partial<OrchestratorState> = {}) {
26
- return {
27
- browserChat: {
28
- start: jest.fn(),
29
- stop: jest.fn(),
30
- submit: jest.fn(),
31
- } as any,
32
- telegram: {
33
- start: jest.fn(),
34
- stop: jest.fn(),
35
- configure: jest.fn(),
36
- } as any,
37
- imessage: {
38
- start: jest.fn(),
39
- stop: jest.fn(),
40
- configure: jest.fn(),
41
- } as any,
42
- peerjs: { start: jest.fn(), stop: jest.fn(), configure: jest.fn() } as any,
43
- channelEnabledByType: { telegram: false, imessage: false, peerjs: false },
106
+ function makeState(overrides: Partial<MockOrchestratorState> = {}) {
107
+ const defaultChannelEnabled: Record<ChannelType, boolean> = {
108
+ browser: true,
109
+ telegram: false,
110
+ imessage: false,
111
+ peerjs: false,
112
+ room: false,
113
+ };
114
+ const base: MockOrchestratorState = {
115
+ browserChat: makeChannel(),
116
+ telegram: makeChannel(),
117
+ imessage: makeChannel(),
118
+ peerjs: makeChannel(),
119
+ channelEnabledByType: { ...defaultChannelEnabled },
44
120
  telegramBotToken: "",
45
- telegramChatIds: [],
121
+ telegramChatIds: [] as string[],
46
122
  telegramUseProxy: false,
47
123
  imessageServerUrl: "",
48
124
  imessageApiKey: "",
49
- imessageChatIds: [],
125
+ imessageChatIds: [] as string[],
50
126
  peerjsMyAlias: "",
51
127
  peerjsMyPeerId: "",
52
128
  peerjsPeerAliases: {},
@@ -54,30 +130,45 @@ function makeState(overrides: Partial<OrchestratorState> = {}) {
54
130
  peerjsServerPath: "/",
55
131
  peerjsServerPort: 443,
56
132
  peerjsServerSecure: true,
57
- peerjsTrustedPeerIds: [],
58
- ...overrides,
59
- } as unknown as OrchestratorState;
133
+ peerjsTrustedPeerIds: [] as string[],
134
+ };
135
+ const merged = Object.assign(base, overrides);
136
+ if (overrides.channelEnabledByType) {
137
+ merged.channelEnabledByType = {
138
+ ...defaultChannelEnabled,
139
+ ...overrides.channelEnabledByType,
140
+ };
141
+ }
142
+ return merged as unknown as OrchestratorState;
60
143
  }
61
144
 
62
145
  describe("channel operations", () => {
146
+ const mockDb = {} as ShadowClawDatabase;
147
+
148
+ beforeEach(() => {
149
+ jest.clearAllMocks();
150
+ mockDefaultSetConfig.mockResolvedValue(undefined);
151
+ mockDefaultGetConfig.mockResolvedValue(undefined);
152
+ mockEncryptValue.mockImplementation(async (val) => `enc:${val}`);
153
+ });
154
+
63
155
  it("getChannelTypeForGroup returns registered type or default browser", () => {
64
156
  const stateWithRegistry = {
65
157
  channelRegistry: {
66
- getChannelType: jest.fn((gId: string) =>
67
- gId.startsWith("tg:") ? "telegram" : null,
68
- ),
158
+ getChannelType: (gId: string) =>
159
+ gId.startsWith("tg:") ? ("telegram" as ChannelType) : null,
69
160
  },
70
161
  };
71
- expect(getChannelTypeForGroup(stateWithRegistry as any, "tg:123")).toBe(
162
+ expect(getChannelTypeForGroup(stateWithRegistry, "tg:123")).toBe(
72
163
  "telegram",
73
164
  );
74
- expect(getChannelTypeForGroup(stateWithRegistry as any, "br:main")).toBe(
165
+ expect(getChannelTypeForGroup(stateWithRegistry, "br:main")).toBe(
75
166
  "browser",
76
167
  );
77
168
  expect(getChannelTypeForGroup({}, "br:main")).toBe("browser");
78
169
  });
79
170
 
80
- it("getChannelByType returns the correct channel", () => {
171
+ it("getChannelByType returns the correct channel or null for unknown", () => {
81
172
  const state = makeState();
82
173
  expect(getChannelByType(state, "browser")).toBe(state.browserChat);
83
174
  expect(getChannelByType(state, "telegram")).toBe(state.telegram);
@@ -86,214 +177,306 @@ describe("channel operations", () => {
86
177
  expect(getChannelByType(state, "unknown" as ChannelType)).toBeNull();
87
178
  });
88
179
 
89
- it("getChannelEnabled works", () => {
180
+ it("getChannelEnabled returns true for browser and checks map for others", () => {
90
181
  const state = makeState({
91
182
  channelEnabledByType: {
92
183
  telegram: true,
93
184
  imessage: false,
94
185
  peerjs: false,
95
- } as any,
186
+ },
96
187
  });
97
188
  expect(getChannelEnabled(state, "browser")).toBe(true);
98
189
  expect(getChannelEnabled(state, "telegram")).toBe(true);
99
190
  expect(getChannelEnabled(state, "imessage")).toBe(false);
100
191
  });
101
192
 
102
- it("getPeerJsConfig, getTelegramConfig, getIMessageConfig return snapshot configurations", () => {
193
+ it("getChannelEnabledConfigKey formats key properly", () => {
194
+ expect(getChannelEnabledConfigKey("telegram")).toBe(
195
+ "channel_enabled:telegram",
196
+ );
197
+ });
198
+
199
+ it("getTelegramConfig, getIMessageConfig, and getPeerJsConfig return current config", () => {
103
200
  const state = makeState({
104
- channelEnabledByType: {
105
- telegram: true,
106
- imessage: true,
107
- peerjs: true,
108
- } as any,
109
- telegramBotToken: "tok-1",
110
- telegramChatIds: ["chat-1"],
201
+ telegramBotToken: "tok",
202
+ telegramChatIds: ["tg1"],
111
203
  telegramUseProxy: true,
112
- imessageServerUrl: "http://imessage.local",
113
- imessageApiKey: "key-1",
114
- imessageChatIds: ["i-chat-1"],
115
- peerjsMyAlias: "my-alias",
116
- peerjsMyPeerId: "peer-123",
117
- peerjsPeerAliases: { p1: "Peer 1" },
118
- peerjsServerHost: "peerjs.custom.io",
119
- peerjsServerPath: "/custom",
204
+ imessageServerUrl: "http://imsg",
205
+ imessageApiKey: "key",
206
+ imessageChatIds: ["im1"],
207
+ peerjsMyAlias: "alias",
208
+ peerjsMyPeerId: "peer1",
209
+ peerjsPeerAliases: { p2: "a2" },
210
+ peerjsServerHost: "host",
211
+ peerjsServerPath: "/p",
120
212
  peerjsServerPort: 9000,
121
213
  peerjsServerSecure: false,
122
- peerjsTrustedPeerIds: ["trusted-1"],
214
+ peerjsTrustedPeerIds: ["trust1"],
123
215
  });
124
216
 
125
217
  expect(getTelegramConfig(state)).toEqual({
126
- botToken: "tok-1",
127
- chatIds: ["chat-1"],
128
- enabled: true,
218
+ botToken: "tok",
219
+ chatIds: ["tg1"],
220
+ enabled: false,
129
221
  useProxy: true,
130
222
  });
131
223
 
132
224
  expect(getIMessageConfig(state)).toEqual({
133
- serverUrl: "http://imessage.local",
134
- apiKey: "key-1",
135
- chatIds: ["i-chat-1"],
136
- enabled: true,
225
+ apiKey: "key",
226
+ chatIds: ["im1"],
227
+ enabled: false,
228
+ serverUrl: "http://imsg",
137
229
  });
138
230
 
139
231
  expect(getPeerJsConfig(state)).toEqual({
140
- enabled: true,
141
- myAlias: "my-alias",
142
- myPeerId: "peer-123",
143
- peerAliases: { p1: "Peer 1" },
144
- serverHost: "peerjs.custom.io",
145
- serverPath: "/custom",
232
+ enabled: false,
233
+ myAlias: "alias",
234
+ myPeerId: "peer1",
235
+ peerAliases: { p2: "a2" },
236
+ serverHost: "host",
237
+ serverPath: "/p",
146
238
  serverPort: 9000,
147
239
  serverSecure: false,
148
- trustedPeerIds: ["trusted-1"],
240
+ trustedPeerIds: ["trust1"],
149
241
  });
150
242
  });
151
243
 
152
- it("getChannelEnabledConfigKey formats config key", () => {
153
- expect(getChannelEnabledConfigKey("telegram")).toBe(
154
- "channel_enabled:telegram",
155
- );
156
- expect(getChannelEnabledConfigKey("imessage")).toBe(
157
- "channel_enabled:imessage",
158
- );
159
- });
160
-
161
- it("shouldRunChannel works", () => {
244
+ it("shouldRunChannel evaluates readiness for each channel type", () => {
162
245
  const state = makeState({
163
246
  channelEnabledByType: {
164
247
  telegram: true,
165
- peerjs: false,
166
248
  imessage: true,
167
- } as any,
168
- telegramBotToken: "token",
169
- imessageServerUrl: "http://imessage",
249
+ peerjs: true,
250
+ custom: true,
251
+ } as unknown as Record<ChannelType, boolean>,
252
+ telegramBotToken: "tok",
253
+ imessageServerUrl: "http://imsg",
170
254
  peerjsMyPeerId: "peer1",
171
255
  });
256
+
172
257
  expect(shouldRunChannel(state, "browser")).toBe(true);
173
258
  expect(shouldRunChannel(state, "telegram")).toBe(true);
174
259
  expect(shouldRunChannel(state, "imessage")).toBe(true);
175
- expect(shouldRunChannel(state, "peerjs")).toBe(false);
176
- });
260
+ expect(shouldRunChannel(state, "peerjs")).toBe(true);
261
+ expect(shouldRunChannel(state, "custom" as ChannelType)).toBe(true);
177
262
 
178
- it("applyChannelRunningState and applyAllChannelRunningStates start or stop channels", () => {
179
- const state = makeState({
263
+ // When disabled
264
+ const disabledState = makeState();
265
+ expect(shouldRunChannel(disabledState, "telegram")).toBe(false);
266
+
267
+ // When enabled but missing token/url/id
268
+ const incompleteState = makeState({
180
269
  channelEnabledByType: {
181
270
  telegram: true,
182
- imessage: false,
183
- peerjs: false,
184
- } as any,
185
- telegramBotToken: "token",
271
+ imessage: true,
272
+ peerjs: true,
273
+ },
186
274
  });
275
+ expect(shouldRunChannel(incompleteState, "telegram")).toBe(false);
276
+ expect(shouldRunChannel(incompleteState, "imessage")).toBe(false);
277
+ expect(shouldRunChannel(incompleteState, "peerjs")).toBe(false);
278
+ });
187
279
 
188
- applyAllChannelRunningStates(state);
189
- expect(state.browserChat.start).toHaveBeenCalled();
280
+ it("applyChannelRunningState handles null channel, starting, stopping, and ensureConnected", () => {
281
+ const state = makeState();
282
+
283
+ // Unknown channel does nothing
284
+ expect(() =>
285
+ applyChannelRunningState(state, "unknown" as ChannelType),
286
+ ).not.toThrow();
287
+
288
+ // Not running and should run -> starts channel
289
+ state.channelEnabledByType.telegram = true;
290
+ state.telegramBotToken = "token";
291
+ applyChannelRunningState(state, "telegram");
190
292
  expect(state.telegram.start).toHaveBeenCalled();
191
- expect(state.imessage.stop).toHaveBeenCalled();
192
- expect(state.peerjs.stop).toHaveBeenCalled();
293
+
294
+ // Already running and should run -> calls ensureConnected if present
295
+ const ensureMock = jest.fn();
296
+ const runningTelegram = makeChannel(true, ensureMock);
297
+ const runningState = makeState({
298
+ channelEnabledByType: { telegram: true, imessage: false, peerjs: false },
299
+ telegramBotToken: "token",
300
+ telegram: runningTelegram,
301
+ });
302
+
303
+ applyChannelRunningState(runningState, "telegram", true);
304
+ expect(ensureMock).toHaveBeenCalledWith(true);
305
+
306
+ // Running but should not run -> stops channel
307
+ const shouldStopTelegram = makeChannel(true);
308
+ const stopState = makeState({
309
+ telegram: shouldStopTelegram,
310
+ });
311
+ applyChannelRunningState(stopState, "telegram");
312
+ expect(shouldStopTelegram.stop).toHaveBeenCalled();
193
313
  });
194
314
 
195
- it("applyChannelRunningState invokes ensureConnected if channel is already running", () => {
196
- const ensureConnectedMock = jest.fn();
315
+ it("applyAllChannelRunningStates applies state across all remote channels", () => {
197
316
  const state = makeState({
198
- channelEnabledByType: { telegram: true } as any,
317
+ channelEnabledByType: { telegram: true, imessage: false, peerjs: false },
199
318
  telegramBotToken: "token",
200
- telegram: {
201
- running: true,
202
- start: jest.fn(),
203
- stop: jest.fn(),
204
- ensureConnected: ensureConnectedMock,
205
- } as any,
206
319
  });
207
320
 
208
- applyAllChannelRunningStates(state, true);
209
- expect(ensureConnectedMock).toHaveBeenCalledWith(true);
321
+ applyAllChannelRunningStates(state);
322
+ expect(state.telegram.start).toHaveBeenCalled();
210
323
  });
211
324
 
212
- it("clearPeerJsTypingState invokes store update", () => {
213
- clearPeerJsTypingState("peer:group1");
325
+ it("clearPeerJsTypingState clears typing indicators on orchestratorStore", () => {
326
+ mockSetRemoteAgentTyping.mockClear();
327
+ clearPeerJsTypingState("group-123");
328
+ expect(mockSetRemoteAgentTyping).toHaveBeenCalledWith("group-123", false);
214
329
  });
215
330
 
216
- it("loadChannelEnabled reads from config", async () => {
217
- const mockGetConfig = jest
218
- .fn()
219
- .mockResolvedValueOnce("true" as never)
220
- .mockResolvedValueOnce("false" as never)
221
- .mockResolvedValueOnce(undefined as never);
222
-
223
- expect(
224
- await loadChannelEnabled("telegram", {} as any, mockGetConfig as any),
225
- ).toBe(true);
226
- expect(
227
- await loadChannelEnabled("telegram", {} as any, mockGetConfig as any),
228
- ).toBe(false);
229
- expect(
230
- await loadChannelEnabled("telegram", {} as any, mockGetConfig as any),
231
- ).toBe(false);
232
- });
331
+ it("loadChannelEnabled uses default getConfig or passed getConfig", async () => {
332
+ mockDefaultGetConfig.mockResolvedValueOnce("true");
333
+ const loadedDefault = await loadChannelEnabled("telegram", mockDb);
334
+ expect(loadedDefault).toBe(true);
233
335
 
234
- it("setChannelEnabled updates state, writes to db and updates running state", async () => {
235
- const state = makeState({
236
- telegramBotToken: "bot-token",
237
- });
238
- const mockSetConfig = jest.fn().mockResolvedValue(undefined as never);
336
+ const customGetConfig = jest
337
+ .fn<
338
+ (_db: ShadowClawDatabase, _key: string) => Promise<string | undefined>
339
+ >()
340
+ .mockResolvedValue("false");
341
+ const loadedCustom = await loadChannelEnabled(
342
+ "imessage",
343
+ mockDb,
344
+ customGetConfig,
345
+ );
346
+ expect(loadedCustom).toBe(false);
239
347
 
240
- await setChannelEnabled(
241
- state,
242
- {} as any,
243
- "browser",
244
- true,
245
- mockSetConfig as any,
348
+ const loadedMissing = await loadChannelEnabled(
349
+ "telegram",
350
+ mockDb,
351
+ async () => undefined,
246
352
  );
247
- expect(mockSetConfig).not.toHaveBeenCalled();
353
+ expect(loadedMissing).toBe(false);
248
354
 
249
- await setChannelEnabled(
250
- state,
251
- {} as any,
355
+ const loadedEmpty = await loadChannelEnabled(
252
356
  "telegram",
253
- true,
254
- mockSetConfig as any,
357
+ mockDb,
358
+ async () => "",
255
359
  );
256
- expect(state.channelEnabledByType["telegram"]).toBe(true);
257
- expect(mockSetConfig).toHaveBeenCalledWith(
258
- {},
360
+ expect(loadedEmpty).toBe(false);
361
+ });
362
+
363
+ it("setChannelEnabled uses default setConfig or passed setConfig", async () => {
364
+ const state = makeState();
365
+ await setChannelEnabled(state, mockDb, "telegram", true);
366
+ expect(state.channelEnabledByType.telegram).toBe(true);
367
+ expect(mockDefaultSetConfig).toHaveBeenCalledWith(
368
+ mockDb,
259
369
  "channel_enabled:telegram",
260
370
  "true",
261
371
  );
262
- expect(state.telegram.start).toHaveBeenCalled();
372
+
373
+ const customSetConfig = jest
374
+ .fn<
375
+ (_db: ShadowClawDatabase, _key: string, _val: string) => Promise<void>
376
+ >()
377
+ .mockResolvedValue(undefined);
378
+ await setChannelEnabled(state, mockDb, "imessage", false, customSetConfig);
379
+ expect(state.channelEnabledByType.imessage).toBe(false);
380
+ expect(customSetConfig).toHaveBeenCalledWith(
381
+ mockDb,
382
+ "channel_enabled:imessage",
383
+ "false",
384
+ );
385
+
386
+ // channelType === "browser" returns early without setting config
387
+ mockDefaultSetConfig.mockClear();
388
+ await setChannelEnabled(state, mockDb, "browser", true);
389
+ expect(mockDefaultSetConfig).not.toHaveBeenCalled();
263
390
  });
264
391
 
265
- it("submitMessage proxies call to browserChat", () => {
392
+ it("submitMessage proxies call to browserChat with explicit and default arguments", () => {
266
393
  const state = makeState();
267
- submitMessage(state, "Hello agent", "group-1");
394
+ const attachments: MessageAttachment[] = [];
395
+ submitMessage(state, "Hello agent", "group-1", attachments);
268
396
  expect(state.browserChat.submit).toHaveBeenCalledWith(
269
397
  "Hello agent",
270
398
  "group-1",
399
+ attachments,
400
+ undefined,
401
+ );
402
+
403
+ submitMessage(state, "Default params test");
404
+ expect(state.browserChat.submit).toHaveBeenCalledWith(
405
+ "Default params test",
406
+ DEFAULT_GROUP_ID,
271
407
  [],
272
408
  undefined,
273
409
  );
274
410
  });
275
411
 
276
- it("saveSecretConfig handles empty and encrypted values", async () => {
277
- const mockSetConfig = jest.fn().mockResolvedValue(undefined as never);
278
- await saveSecretConfig({} as any, "key1", "", mockSetConfig as any);
279
- expect(mockSetConfig).toHaveBeenCalledWith({}, "key1", "");
412
+ it("applyChannelRunningState handles running channel without ensureConnected", () => {
413
+ const state = makeState({
414
+ telegram: makeChannel(true),
415
+ channelEnabledByType: { telegram: true, imessage: false, peerjs: false },
416
+ telegramBotToken: "tok",
417
+ });
418
+ applyChannelRunningState(state, "telegram");
419
+ expect(state.telegram.start).not.toHaveBeenCalled();
280
420
  });
281
421
 
282
- it("configureIMessage and configureTelegram stop, configure, save and start channels", async () => {
422
+ it("saveSecretConfig handles empty, encrypted values, and encryption errors", async () => {
423
+ const mockSetConfig = jest
424
+ .fn<
425
+ (_db: ShadowClawDatabase, _key: string, _val: string) => Promise<void>
426
+ >()
427
+ .mockResolvedValue(undefined);
428
+
429
+ // Empty value clears config
430
+ await saveSecretConfig(mockDb, "key1", "", mockSetConfig);
431
+ expect(mockSetConfig).toHaveBeenCalledWith(mockDb, "key1", "");
432
+
433
+ // Non-empty value encrypts and sets config
434
+ await saveSecretConfig(mockDb, "key2", "super-secret", mockSetConfig);
435
+ expect(mockEncryptValue).toHaveBeenCalledWith("super-secret");
436
+ expect(mockSetConfig).toHaveBeenCalledWith(
437
+ mockDb,
438
+ "key2",
439
+ "enc:super-secret",
440
+ );
441
+
442
+ // Uses default setConfig when omitted
443
+ await saveSecretConfig(mockDb, "key3", "val");
444
+ expect(mockDefaultSetConfig).toHaveBeenCalledWith(
445
+ mockDb,
446
+ "key3",
447
+ "enc:val",
448
+ );
449
+
450
+ // Throws if encryptValue returns null/empty
451
+ mockEncryptValue.mockResolvedValueOnce(null);
452
+ await expect(
453
+ saveSecretConfig(mockDb, "key4", "failed-val"),
454
+ ).rejects.toThrow("Failed to encrypt secret config for key4");
455
+ });
456
+
457
+ it("configureIMessage and configureTelegram use custom and default configs", async () => {
283
458
  const state = makeState({
284
- channelEnabledByType: { telegram: true, imessage: true } as any,
459
+ channelEnabledByType: { telegram: true, imessage: true, peerjs: false },
285
460
  });
286
- const mockSetConfig = jest.fn().mockResolvedValue(undefined as never);
287
- const mockSaveSecret = jest.fn().mockResolvedValue(undefined as never);
461
+ const mockSetConfig = jest
462
+ .fn<
463
+ (_db: ShadowClawDatabase, _key: string, _val: string) => Promise<void>
464
+ >()
465
+ .mockResolvedValue(undefined);
466
+ const mockSaveSecret = jest
467
+ .fn<
468
+ (_db: ShadowClawDatabase, _key: string, _val: string) => Promise<void>
469
+ >()
470
+ .mockResolvedValue(undefined);
288
471
 
289
472
  await configureIMessage(
290
473
  state,
291
- {} as any,
474
+ mockDb,
292
475
  "http://imessage.local/",
293
476
  "secret-key",
294
477
  ["chat1", "chat2"],
295
- mockSetConfig as any,
296
- mockSaveSecret as any,
478
+ mockSetConfig,
479
+ mockSaveSecret,
297
480
  );
298
481
 
299
482
  expect(state.imessage.stop).toHaveBeenCalled();
@@ -304,14 +487,27 @@ describe("channel operations", () => {
304
487
  );
305
488
  expect(state.imessage.start).toHaveBeenCalled();
306
489
 
490
+ // configureIMessage with defaults and empty url
491
+ const stateImDisabled = makeState({
492
+ channelEnabledByType: { telegram: false, imessage: false, peerjs: false },
493
+ });
494
+ await configureIMessage(stateImDisabled, mockDb, "", "", []);
495
+ expect(stateImDisabled.imessage.start).not.toHaveBeenCalled();
496
+
497
+ // configureIMessage with url but channel disabled
498
+ await configureIMessage(stateImDisabled, mockDb, "http://imsg.local", "k", [
499
+ "c",
500
+ ]);
501
+ expect(stateImDisabled.imessage.start).not.toHaveBeenCalled();
502
+
307
503
  await configureTelegram(
308
504
  state,
309
- {} as any,
505
+ mockDb,
310
506
  "telegram-token",
311
507
  ["tg-chat-1"],
312
508
  true,
313
- mockSetConfig as any,
314
- mockSaveSecret as any,
509
+ mockSetConfig,
510
+ mockSaveSecret,
315
511
  );
316
512
 
317
513
  expect(state.telegram.stop).toHaveBeenCalled();
@@ -321,5 +517,16 @@ describe("channel operations", () => {
321
517
  true,
322
518
  );
323
519
  expect(state.telegram.start).toHaveBeenCalled();
520
+
521
+ // Call configureTelegram with defaults and empty token
522
+ const stateTgDisabled = makeState({
523
+ channelEnabledByType: { telegram: false, imessage: false, peerjs: false },
524
+ });
525
+ await configureTelegram(stateTgDisabled, mockDb, "", []);
526
+ expect(stateTgDisabled.telegram.start).not.toHaveBeenCalled();
527
+
528
+ // Call configureTelegram with token but channel disabled
529
+ await configureTelegram(stateTgDisabled, mockDb, "valid-token", ["chat"]);
530
+ expect(stateTgDisabled.telegram.start).not.toHaveBeenCalled();
324
531
  });
325
532
  });