@archipelagolab/lobi 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/ENDOFFILE +0 -0
  3. package/EOF +0 -0
  4. package/LICENSE +21 -0
  5. package/SPEC-SUPPORT.md +116 -0
  6. package/YAMLEND +0 -0
  7. package/api.ts +18 -0
  8. package/archipelagolab-lobi-1.0.0.tgz +0 -0
  9. package/auth-presence.ts +56 -0
  10. package/channel-plugin-api.ts +3 -0
  11. package/cli-metadata.ts +11 -0
  12. package/contract-api.ts +17 -0
  13. package/docs/CHECKLIST.md +83 -0
  14. package/docs/FORK_SDK_GUIDE.md +279 -0
  15. package/helper-api.ts +3 -0
  16. package/index.test.ts +61 -0
  17. package/index.ts +65 -0
  18. package/openclaw.plugin.json +23 -0
  19. package/package.json +52 -0
  20. package/plugin-entry.handlers.runtime.ts +1 -0
  21. package/runtime-api.ts +54 -0
  22. package/runtime-heavy-api.ts +1 -0
  23. package/scripts/migrate-to-lobi.sh +72 -0
  24. package/secret-contract-api.ts +5 -0
  25. package/setup-entry.ts +13 -0
  26. package/src/account-selection.test.ts +124 -0
  27. package/src/account-selection.ts +226 -0
  28. package/src/actions.account-propagation.test.ts +251 -0
  29. package/src/actions.test.ts +251 -0
  30. package/src/actions.ts +336 -0
  31. package/src/approval-auth.test.ts +23 -0
  32. package/src/approval-auth.ts +25 -0
  33. package/src/approval-handler.runtime.test.ts +46 -0
  34. package/src/approval-handler.runtime.ts +400 -0
  35. package/src/approval-ids.ts +6 -0
  36. package/src/approval-native.test.ts +329 -0
  37. package/src/approval-native.ts +336 -0
  38. package/src/approval-reactions.test.ts +107 -0
  39. package/src/approval-reactions.ts +158 -0
  40. package/src/auth-precedence.ts +61 -0
  41. package/src/channel-account-paths.ts +92 -0
  42. package/src/channel.account-paths.test.ts +102 -0
  43. package/src/channel.directory.test.ts +601 -0
  44. package/src/channel.resolve.test.ts +38 -0
  45. package/src/channel.runtime.ts +16 -0
  46. package/src/channel.setup.test.ts +269 -0
  47. package/src/channel.ts +570 -0
  48. package/src/cli-metadata.ts +19 -0
  49. package/src/cli.test.ts +1015 -0
  50. package/src/cli.ts +1198 -0
  51. package/src/config-adapter.ts +41 -0
  52. package/src/config-schema.test.ts +90 -0
  53. package/src/config-schema.ts +114 -0
  54. package/src/directory-live.test.ts +200 -0
  55. package/src/directory-live.ts +238 -0
  56. package/src/doctor-contract.ts +287 -0
  57. package/src/doctor.test.ts +440 -0
  58. package/src/doctor.ts +262 -0
  59. package/src/env-vars.ts +92 -0
  60. package/src/exec-approval-resolver.test.ts +68 -0
  61. package/src/exec-approval-resolver.ts +23 -0
  62. package/src/exec-approvals.test.ts +483 -0
  63. package/src/exec-approvals.ts +290 -0
  64. package/src/group-mentions.ts +41 -0
  65. package/src/legacy-crypto-inspector-availability.test.ts +81 -0
  66. package/src/legacy-crypto-inspector-availability.ts +60 -0
  67. package/src/legacy-crypto.test.ts +234 -0
  68. package/src/legacy-crypto.ts +549 -0
  69. package/src/legacy-state.test.ts +86 -0
  70. package/src/legacy-state.ts +156 -0
  71. package/src/matrix/account-config.ts +150 -0
  72. package/src/matrix/accounts.readiness.test.ts +27 -0
  73. package/src/matrix/accounts.test.ts +757 -0
  74. package/src/matrix/accounts.ts +194 -0
  75. package/src/matrix/actions/client.test.ts +215 -0
  76. package/src/matrix/actions/client.ts +31 -0
  77. package/src/matrix/actions/devices.test.ts +114 -0
  78. package/src/matrix/actions/devices.ts +34 -0
  79. package/src/matrix/actions/limits.test.ts +15 -0
  80. package/src/matrix/actions/limits.ts +6 -0
  81. package/src/matrix/actions/messages.test.ts +289 -0
  82. package/src/matrix/actions/messages.ts +123 -0
  83. package/src/matrix/actions/pins.test.ts +74 -0
  84. package/src/matrix/actions/pins.ts +64 -0
  85. package/src/matrix/actions/polls.test.ts +71 -0
  86. package/src/matrix/actions/polls.ts +109 -0
  87. package/src/matrix/actions/profile.test.ts +109 -0
  88. package/src/matrix/actions/profile.ts +37 -0
  89. package/src/matrix/actions/reactions.test.ts +135 -0
  90. package/src/matrix/actions/reactions.ts +59 -0
  91. package/src/matrix/actions/room.test.ts +79 -0
  92. package/src/matrix/actions/room.ts +71 -0
  93. package/src/matrix/actions/summary.test.ts +87 -0
  94. package/src/matrix/actions/summary.ts +88 -0
  95. package/src/matrix/actions/types.ts +82 -0
  96. package/src/matrix/actions/verification.test.ts +105 -0
  97. package/src/matrix/actions/verification.ts +237 -0
  98. package/src/matrix/actions.ts +37 -0
  99. package/src/matrix/active-client.ts +26 -0
  100. package/src/matrix/async-lock.ts +18 -0
  101. package/src/matrix/backup-health.ts +115 -0
  102. package/src/matrix/client/config-runtime-api.ts +14 -0
  103. package/src/matrix/client/config-secret-input.runtime.ts +1 -0
  104. package/src/matrix/client/config.ts +982 -0
  105. package/src/matrix/client/create-client.test.ts +115 -0
  106. package/src/matrix/client/create-client.ts +101 -0
  107. package/src/matrix/client/env-auth.ts +6 -0
  108. package/src/matrix/client/file-sync-store.test.ts +265 -0
  109. package/src/matrix/client/file-sync-store.ts +289 -0
  110. package/src/matrix/client/logging.ts +123 -0
  111. package/src/matrix/client/migration-snapshot.runtime.ts +1 -0
  112. package/src/matrix/client/private-network-host.ts +56 -0
  113. package/src/matrix/client/runtime.ts +4 -0
  114. package/src/matrix/client/shared.test.ts +344 -0
  115. package/src/matrix/client/shared.ts +306 -0
  116. package/src/matrix/client/storage.test.ts +634 -0
  117. package/src/matrix/client/storage.ts +544 -0
  118. package/src/matrix/client/types.ts +50 -0
  119. package/src/matrix/client-bootstrap.test.ts +84 -0
  120. package/src/matrix/client-bootstrap.ts +164 -0
  121. package/src/matrix/client-resolver.test-helpers.ts +147 -0
  122. package/src/matrix/client.test.ts +1521 -0
  123. package/src/matrix/client.ts +23 -0
  124. package/src/matrix/config-paths.ts +31 -0
  125. package/src/matrix/config-update.test.ts +237 -0
  126. package/src/matrix/config-update.ts +291 -0
  127. package/src/matrix/credentials-read.ts +206 -0
  128. package/src/matrix/credentials-write.runtime.ts +26 -0
  129. package/src/matrix/credentials.test.ts +501 -0
  130. package/src/matrix/credentials.ts +95 -0
  131. package/src/matrix/deps.test.ts +74 -0
  132. package/src/matrix/deps.ts +225 -0
  133. package/src/matrix/device-health.test.ts +45 -0
  134. package/src/matrix/device-health.ts +31 -0
  135. package/src/matrix/direct-management.test.ts +350 -0
  136. package/src/matrix/direct-management.ts +347 -0
  137. package/src/matrix/direct-room.test.ts +61 -0
  138. package/src/matrix/direct-room.ts +128 -0
  139. package/src/matrix/draft-stream.test.ts +406 -0
  140. package/src/matrix/draft-stream.ts +216 -0
  141. package/src/matrix/encryption-guidance.ts +27 -0
  142. package/src/matrix/errors.ts +21 -0
  143. package/src/matrix/format.test.ts +340 -0
  144. package/src/matrix/format.ts +428 -0
  145. package/src/matrix/legacy-crypto-inspector.ts +95 -0
  146. package/src/matrix/media-errors.ts +20 -0
  147. package/src/matrix/media-text.ts +169 -0
  148. package/src/matrix/monitor/access-state.test.ts +45 -0
  149. package/src/matrix/monitor/access-state.ts +77 -0
  150. package/src/matrix/monitor/ack-config.test.ts +57 -0
  151. package/src/matrix/monitor/ack-config.ts +26 -0
  152. package/src/matrix/monitor/allowlist.test.ts +45 -0
  153. package/src/matrix/monitor/allowlist.ts +94 -0
  154. package/src/matrix/monitor/auto-join.test.ts +203 -0
  155. package/src/matrix/monitor/auto-join.ts +86 -0
  156. package/src/matrix/monitor/config.test.ts +197 -0
  157. package/src/matrix/monitor/config.ts +303 -0
  158. package/src/matrix/monitor/context-summary.ts +43 -0
  159. package/src/matrix/monitor/direct.test.ts +529 -0
  160. package/src/matrix/monitor/direct.ts +270 -0
  161. package/src/matrix/monitor/events.test.ts +1524 -0
  162. package/src/matrix/monitor/events.ts +213 -0
  163. package/src/matrix/monitor/handler.body-for-agent.test.ts +396 -0
  164. package/src/matrix/monitor/handler.group-history.test.ts +648 -0
  165. package/src/matrix/monitor/handler.media-failure.test.ts +267 -0
  166. package/src/matrix/monitor/handler.test-helpers.ts +308 -0
  167. package/src/matrix/monitor/handler.test.ts +2952 -0
  168. package/src/matrix/monitor/handler.thread-root-media.test.ts +82 -0
  169. package/src/matrix/monitor/handler.ts +1679 -0
  170. package/src/matrix/monitor/inbound-dedupe.test.ts +146 -0
  171. package/src/matrix/monitor/inbound-dedupe.ts +267 -0
  172. package/src/matrix/monitor/index.test.ts +920 -0
  173. package/src/matrix/monitor/index.ts +434 -0
  174. package/src/matrix/monitor/legacy-crypto-restore.test.ts +206 -0
  175. package/src/matrix/monitor/legacy-crypto-restore.ts +139 -0
  176. package/src/matrix/monitor/location.ts +100 -0
  177. package/src/matrix/monitor/media.test.ts +159 -0
  178. package/src/matrix/monitor/media.ts +119 -0
  179. package/src/matrix/monitor/mentions.test.ts +289 -0
  180. package/src/matrix/monitor/mentions.ts +177 -0
  181. package/src/matrix/monitor/reaction-events.test.ts +326 -0
  182. package/src/matrix/monitor/reaction-events.ts +187 -0
  183. package/src/matrix/monitor/recent-invite.test.ts +92 -0
  184. package/src/matrix/monitor/recent-invite.ts +30 -0
  185. package/src/matrix/monitor/replies.test.ts +265 -0
  186. package/src/matrix/monitor/replies.ts +136 -0
  187. package/src/matrix/monitor/reply-context.test.ts +276 -0
  188. package/src/matrix/monitor/reply-context.ts +92 -0
  189. package/src/matrix/monitor/room-history.test.ts +258 -0
  190. package/src/matrix/monitor/room-history.ts +301 -0
  191. package/src/matrix/monitor/room-info.test.ts +201 -0
  192. package/src/matrix/monitor/room-info.ts +126 -0
  193. package/src/matrix/monitor/rooms.test.ts +121 -0
  194. package/src/matrix/monitor/rooms.ts +52 -0
  195. package/src/matrix/monitor/route.test.ts +255 -0
  196. package/src/matrix/monitor/route.ts +178 -0
  197. package/src/matrix/monitor/runtime-api.ts +31 -0
  198. package/src/matrix/monitor/startup-verification.test.ts +294 -0
  199. package/src/matrix/monitor/startup-verification.ts +237 -0
  200. package/src/matrix/monitor/startup.test.ts +257 -0
  201. package/src/matrix/monitor/startup.ts +218 -0
  202. package/src/matrix/monitor/status.ts +111 -0
  203. package/src/matrix/monitor/sync-lifecycle.test.ts +224 -0
  204. package/src/matrix/monitor/sync-lifecycle.ts +91 -0
  205. package/src/matrix/monitor/task-runner.ts +38 -0
  206. package/src/matrix/monitor/thread-context.test.ts +149 -0
  207. package/src/matrix/monitor/thread-context.ts +108 -0
  208. package/src/matrix/monitor/threads.test.ts +68 -0
  209. package/src/matrix/monitor/threads.ts +85 -0
  210. package/src/matrix/monitor/types.ts +30 -0
  211. package/src/matrix/monitor/verification-events.ts +627 -0
  212. package/src/matrix/monitor/verification-utils.test.ts +47 -0
  213. package/src/matrix/monitor/verification-utils.ts +46 -0
  214. package/src/matrix/outbound-media-runtime.ts +1 -0
  215. package/src/matrix/poll-summary.ts +110 -0
  216. package/src/matrix/poll-types.test.ts +205 -0
  217. package/src/matrix/poll-types.ts +433 -0
  218. package/src/matrix/probe.runtime.ts +4 -0
  219. package/src/matrix/probe.test.ts +154 -0
  220. package/src/matrix/probe.ts +96 -0
  221. package/src/matrix/profile.test.ts +154 -0
  222. package/src/matrix/profile.ts +184 -0
  223. package/src/matrix/reaction-common.test.ts +96 -0
  224. package/src/matrix/reaction-common.ts +147 -0
  225. package/src/matrix/sdk/crypto-bootstrap.test.ts +505 -0
  226. package/src/matrix/sdk/crypto-bootstrap.ts +341 -0
  227. package/src/matrix/sdk/crypto-facade.test.ts +197 -0
  228. package/src/matrix/sdk/crypto-facade.ts +207 -0
  229. package/src/matrix/sdk/crypto-node.runtime.test.ts +27 -0
  230. package/src/matrix/sdk/crypto-node.runtime.ts +9 -0
  231. package/src/matrix/sdk/crypto-runtime.ts +11 -0
  232. package/src/matrix/sdk/decrypt-bridge.ts +356 -0
  233. package/src/matrix/sdk/event-helpers.test.ts +60 -0
  234. package/src/matrix/sdk/event-helpers.ts +71 -0
  235. package/src/matrix/sdk/http-client.test.ts +134 -0
  236. package/src/matrix/sdk/http-client.ts +87 -0
  237. package/src/matrix/sdk/idb-persistence-lock.ts +51 -0
  238. package/src/matrix/sdk/idb-persistence.lock-order.test.ts +108 -0
  239. package/src/matrix/sdk/idb-persistence.test-helpers.ts +88 -0
  240. package/src/matrix/sdk/idb-persistence.test.ts +149 -0
  241. package/src/matrix/sdk/idb-persistence.ts +283 -0
  242. package/src/matrix/sdk/logger.test.ts +25 -0
  243. package/src/matrix/sdk/logger.ts +108 -0
  244. package/src/matrix/sdk/read-response-with-limit.ts +19 -0
  245. package/src/matrix/sdk/recovery-key-store.test.ts +385 -0
  246. package/src/matrix/sdk/recovery-key-store.ts +430 -0
  247. package/src/matrix/sdk/transport.test.ts +161 -0
  248. package/src/matrix/sdk/transport.ts +344 -0
  249. package/src/matrix/sdk/types.ts +236 -0
  250. package/src/matrix/sdk/verification-manager.test.ts +509 -0
  251. package/src/matrix/sdk/verification-manager.ts +694 -0
  252. package/src/matrix/sdk/verification-status.ts +23 -0
  253. package/src/matrix/sdk.test.ts +2568 -0
  254. package/src/matrix/sdk.ts +1789 -0
  255. package/src/matrix/send/client.test.ts +174 -0
  256. package/src/matrix/send/client.ts +90 -0
  257. package/src/matrix/send/formatting.ts +189 -0
  258. package/src/matrix/send/media.ts +244 -0
  259. package/src/matrix/send/targets.test.ts +254 -0
  260. package/src/matrix/send/targets.ts +104 -0
  261. package/src/matrix/send/types.ts +134 -0
  262. package/src/matrix/send.test.ts +958 -0
  263. package/src/matrix/send.ts +609 -0
  264. package/src/matrix/session-store-metadata.ts +108 -0
  265. package/src/matrix/startup-abort.ts +44 -0
  266. package/src/matrix/sync-state.ts +27 -0
  267. package/src/matrix/target-ids.ts +102 -0
  268. package/src/matrix/thread-bindings-shared.ts +201 -0
  269. package/src/matrix/thread-bindings.test.ts +673 -0
  270. package/src/matrix/thread-bindings.ts +577 -0
  271. package/src/matrix-migration.runtime.ts +9 -0
  272. package/src/migration-config.test.ts +228 -0
  273. package/src/migration-config.ts +243 -0
  274. package/src/migration-snapshot-backup.ts +117 -0
  275. package/src/migration-snapshot.test.ts +184 -0
  276. package/src/migration-snapshot.ts +55 -0
  277. package/src/onboarding.resolve.test.ts +55 -0
  278. package/src/onboarding.test-harness.ts +158 -0
  279. package/src/onboarding.test.ts +665 -0
  280. package/src/onboarding.ts +773 -0
  281. package/src/outbound.test.ts +173 -0
  282. package/src/outbound.ts +78 -0
  283. package/src/plugin-entry.runtime.js +159 -0
  284. package/src/plugin-entry.runtime.test.ts +108 -0
  285. package/src/plugin-entry.runtime.ts +68 -0
  286. package/src/profile-update.ts +68 -0
  287. package/src/record-shared.ts +3 -0
  288. package/src/resolve-targets.test.ts +178 -0
  289. package/src/resolve-targets.ts +175 -0
  290. package/src/resolver.ts +21 -0
  291. package/src/runtime-api.ts +144 -0
  292. package/src/runtime.ts +7 -0
  293. package/src/secret-contract.ts +174 -0
  294. package/src/session-route.test.ts +315 -0
  295. package/src/session-route.ts +113 -0
  296. package/src/setup-bootstrap.ts +94 -0
  297. package/src/setup-config.ts +222 -0
  298. package/src/setup-contract.ts +89 -0
  299. package/src/setup-core.test.ts +326 -0
  300. package/src/setup-core.ts +50 -0
  301. package/src/setup-surface.ts +4 -0
  302. package/src/startup-maintenance.test.ts +227 -0
  303. package/src/startup-maintenance.ts +114 -0
  304. package/src/storage-paths.ts +92 -0
  305. package/src/test-helpers.ts +42 -0
  306. package/src/test-mocks.ts +55 -0
  307. package/src/test-runtime.ts +72 -0
  308. package/src/test-support/monitor-route-test-support.ts +8 -0
  309. package/src/tool-actions.runtime.ts +1 -0
  310. package/src/tool-actions.test.ts +422 -0
  311. package/src/tool-actions.ts +498 -0
  312. package/src/types.ts +230 -0
  313. package/test-api.ts +2 -0
  314. package/thread-bindings-runtime.ts +4 -0
  315. package/tsconfig.json +16 -0
@@ -0,0 +1,920 @@
1
+ import path from "node:path";
2
+ import { z } from "openclaw/plugin-sdk/zod";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { loadRuntimeApiExportTypesViaJiti } from "../../../../../test/helpers/plugins/jiti-runtime-api.ts";
5
+ import type { MatrixRoomInfo } from "./room-info.js";
6
+
7
+ type DirectRoomTrackerOptions = {
8
+ canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
9
+ shouldKeepLocallyPromotedDirectRoom?:
10
+ | ((roomId: string) => boolean | undefined | Promise<boolean | undefined>)
11
+ | undefined;
12
+ };
13
+
14
+ const hoisted = vi.hoisted(() => {
15
+ const createEmitter = () => {
16
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
17
+ return {
18
+ on(event: string, listener: (...args: unknown[]) => void) {
19
+ let bucket = listeners.get(event);
20
+ if (!bucket) {
21
+ bucket = new Set();
22
+ listeners.set(event, bucket);
23
+ }
24
+ bucket.add(listener);
25
+ return this;
26
+ },
27
+ off(event: string, listener: (...args: unknown[]) => void) {
28
+ listeners.get(event)?.delete(listener);
29
+ return this;
30
+ },
31
+ emit(event: string, ...args: unknown[]) {
32
+ for (const listener of listeners.get(event) ?? []) {
33
+ listener(...args);
34
+ }
35
+ return true;
36
+ },
37
+ removeAllListeners() {
38
+ listeners.clear();
39
+ return this;
40
+ },
41
+ };
42
+ };
43
+ const callOrder: string[] = [];
44
+ const state = {
45
+ startClientError: null as Error | null,
46
+ };
47
+ const accountConfig = {
48
+ dm: {},
49
+ };
50
+ const inboundDeduper = {
51
+ claimEvent: vi.fn(() => true),
52
+ commitEvent: vi.fn(async () => undefined),
53
+ releaseEvent: vi.fn(),
54
+ flush: vi.fn(async () => undefined),
55
+ stop: vi.fn(async () => undefined),
56
+ };
57
+ const createMatrixInboundEventDeduper = vi.fn(async () => inboundDeduper);
58
+ const client = Object.assign(createEmitter(), {
59
+ id: "matrix-client",
60
+ hasPersistedSyncState: vi.fn(() => false),
61
+ stopSyncWithoutPersist: vi.fn(),
62
+ drainPendingDecryptions: vi.fn(async () => undefined),
63
+ });
64
+ const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
65
+ const createDirectRoomTracker = vi.fn((_client: unknown, _opts?: DirectRoomTrackerOptions) => ({
66
+ isDirectMessage: vi.fn(async () => false),
67
+ }));
68
+ const getRoomInfo = vi.fn<
69
+ (roomId: string, opts?: { includeAliases?: boolean }) => Promise<MatrixRoomInfo>
70
+ >(async () => ({
71
+ altAliases: [],
72
+ nameResolved: true,
73
+ aliasesResolved: true,
74
+ }));
75
+ const getMemberDisplayName = vi.fn(async () => "Bot");
76
+ const resolveTextChunkLimit = vi.fn<
77
+ (cfg: unknown, channel: unknown, accountId?: unknown) => number
78
+ >(() => 4000);
79
+ const logger = {
80
+ info: vi.fn(),
81
+ warn: vi.fn(),
82
+ error: vi.fn(),
83
+ debug: vi.fn(),
84
+ };
85
+ const stopThreadBindingManager = vi.fn();
86
+ const releaseSharedClientInstance = vi.fn(async () => true);
87
+ const resolveSharedMatrixClient = vi.fn(async (params: { startClient?: boolean }) => {
88
+ if (params.startClient === false) {
89
+ callOrder.push("prepare-client");
90
+ return client;
91
+ }
92
+ if (!callOrder.includes("create-manager")) {
93
+ throw new Error("Matrix client started before thread bindings were registered");
94
+ }
95
+ if (state.startClientError) {
96
+ throw state.startClientError;
97
+ }
98
+ callOrder.push("start-client");
99
+ return client;
100
+ });
101
+ const setActiveMatrixClient = vi.fn();
102
+ const setMatrixRuntime = vi.fn();
103
+ const backfillMatrixAuthDeviceIdAfterStartup = vi.fn(async () => undefined);
104
+ const runMatrixStartupMaintenance = vi.fn<
105
+ (params: { abortSignal?: AbortSignal }) => Promise<void>
106
+ >(async () => undefined);
107
+ const setStatus = vi.fn();
108
+ return {
109
+ backfillMatrixAuthDeviceIdAfterStartup,
110
+ callOrder,
111
+ accountConfig,
112
+ client,
113
+ createDirectRoomTracker,
114
+ createMatrixInboundEventDeduper,
115
+ createMatrixRoomMessageHandler,
116
+ getMemberDisplayName,
117
+ getRoomInfo,
118
+ inboundDeduper,
119
+ logger,
120
+ registeredOnRoomMessage: null as null | ((roomId: string, event: unknown) => Promise<void>),
121
+ releaseSharedClientInstance,
122
+ resolveSharedMatrixClient,
123
+ resolveTextChunkLimit,
124
+ runMatrixStartupMaintenance,
125
+ setActiveMatrixClient,
126
+ setMatrixRuntime,
127
+ setStatus,
128
+ state,
129
+ stopThreadBindingManager,
130
+ };
131
+ });
132
+
133
+ vi.mock("../../runtime-api.js", () => {
134
+ const normalizeAccountId = (value: string | null | undefined) => value?.trim() || "default";
135
+ return {
136
+ DEFAULT_ACCOUNT_ID: "default",
137
+ GROUP_POLICY_BLOCKED_LABEL: {
138
+ room: "room",
139
+ },
140
+ MarkdownConfigSchema: z.any().optional(),
141
+ PAIRING_APPROVED_MESSAGE: "paired",
142
+ ToolPolicySchema: z.any().optional(),
143
+ addAllowlistUserEntriesFromConfigEntry: vi.fn(),
144
+ buildChannelConfigSchema: (schema: unknown) => schema,
145
+ buildChannelKeyCandidates: () => [],
146
+ buildProbeChannelStatusSummary: (
147
+ snapshot: Record<string, unknown>,
148
+ extra?: Record<string, unknown>,
149
+ ) => ({
150
+ ...snapshot,
151
+ ...extra,
152
+ }),
153
+ buildSecretInputSchema: () => z.string(),
154
+ chunkTextForOutbound: vi.fn((text: string) => [text]),
155
+ collectStatusIssuesFromLastError: () => [],
156
+ createActionGate: () => () => true,
157
+ createReplyPrefixOptions: () => ({}),
158
+ createTypingCallbacks: () => ({}),
159
+ formatDocsLink: (input: string) => input,
160
+ formatZonedTimestamp: () => "2026-03-27T00:00:00.000Z",
161
+ getAgentScopedMediaLocalRoots: () => [],
162
+ getSessionBindingService: () => ({}),
163
+ hasConfiguredSecretInput: (value: unknown) => Boolean(value),
164
+ mergeAllowlist: ({ existing, additions }: { existing: string[]; additions: string[] }) => [
165
+ ...existing,
166
+ ...additions,
167
+ ],
168
+ normalizeAccountId,
169
+ normalizeOptionalAccountId: normalizeAccountId,
170
+ resolveThreadBindingIdleTimeoutMsForChannel: () => 24 * 60 * 60 * 1000,
171
+ resolveThreadBindingMaxAgeMsForChannel: () => 0,
172
+ resolveAllowlistProviderRuntimeGroupPolicy: () => ({
173
+ groupPolicy: "allowlist",
174
+ providerMissingFallbackApplied: false,
175
+ }),
176
+ resolveChannelEntryMatch: ({
177
+ entries,
178
+ keys,
179
+ wildcardKey,
180
+ }: {
181
+ entries: Record<string, unknown>;
182
+ keys: string[];
183
+ wildcardKey: string;
184
+ }) => {
185
+ for (const key of keys) {
186
+ if (Object.prototype.hasOwnProperty.call(entries, key)) {
187
+ return {
188
+ entry: entries[key],
189
+ key,
190
+ wildcardEntry: Object.prototype.hasOwnProperty.call(entries, wildcardKey)
191
+ ? entries[wildcardKey]
192
+ : undefined,
193
+ wildcardKey: Object.prototype.hasOwnProperty.call(entries, wildcardKey)
194
+ ? wildcardKey
195
+ : undefined,
196
+ };
197
+ }
198
+ }
199
+ return {
200
+ entry: undefined,
201
+ key: undefined,
202
+ wildcardEntry: Object.prototype.hasOwnProperty.call(entries, wildcardKey)
203
+ ? entries[wildcardKey]
204
+ : undefined,
205
+ wildcardKey: Object.prototype.hasOwnProperty.call(entries, wildcardKey)
206
+ ? wildcardKey
207
+ : undefined,
208
+ };
209
+ },
210
+ resolveDefaultGroupPolicy: () => "allowlist",
211
+ resolveOutboundSendDep: () => null,
212
+ resolveThreadBindingFarewellText: () => null,
213
+ resolveAckReaction: () => null,
214
+ readJsonFileWithFallback: vi.fn(),
215
+ readNumberParam: vi.fn(),
216
+ readReactionParams: vi.fn(),
217
+ readStringArrayParam: vi.fn(),
218
+ readStringParam: vi.fn(),
219
+ summarizeMapping: vi.fn(),
220
+ warnMissingProviderGroupPolicyFallbackOnce: vi.fn(),
221
+ };
222
+ });
223
+
224
+ vi.mock("../../resolve-targets.js", () => ({
225
+ resolveMatrixTargets: vi.fn(async () => []),
226
+ }));
227
+
228
+ vi.mock("../../runtime.js", () => ({
229
+ getMatrixRuntime: () => ({
230
+ config: {
231
+ loadConfig: () => ({
232
+ channels: {
233
+ matrix: hoisted.accountConfig,
234
+ },
235
+ }),
236
+ writeConfigFile: vi.fn(),
237
+ },
238
+ logging: {
239
+ getChildLogger: () => hoisted.logger,
240
+ shouldLogVerbose: () => false,
241
+ },
242
+ channel: {
243
+ mentions: {
244
+ buildMentionRegexes: () => [],
245
+ },
246
+ text: {
247
+ resolveTextChunkLimit: (cfg: unknown, channel: unknown, accountId?: unknown) =>
248
+ hoisted.resolveTextChunkLimit(cfg, channel, accountId),
249
+ },
250
+ },
251
+ system: {
252
+ formatNativeDependencyHint: () => "",
253
+ },
254
+ media: {
255
+ loadWebMedia: vi.fn(),
256
+ },
257
+ }),
258
+ setMatrixRuntime: hoisted.setMatrixRuntime,
259
+ }));
260
+
261
+ vi.mock("../accounts.js", async () => {
262
+ const actual = await vi.importActual<typeof import("../accounts.js")>("../accounts.js");
263
+ return {
264
+ ...actual,
265
+ resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set<string>()),
266
+ resolveMatrixAccount: () => ({
267
+ accountId: "default",
268
+ config: hoisted.accountConfig,
269
+ }),
270
+ };
271
+ });
272
+
273
+ vi.mock("../active-client.js", () => ({
274
+ setActiveMatrixClient: hoisted.setActiveMatrixClient,
275
+ }));
276
+
277
+ vi.mock("../client.js", () => ({
278
+ backfillMatrixAuthDeviceIdAfterStartup: hoisted.backfillMatrixAuthDeviceIdAfterStartup,
279
+ isBunRuntime: () => false,
280
+ resolveMatrixAuth: vi.fn(async () => ({
281
+ accountId: "default",
282
+ homeserver: "https://matrix.example.org",
283
+ userId: "@bot:example.org",
284
+ accessToken: "token",
285
+ initialSyncLimit: 20,
286
+ encryption: false,
287
+ })),
288
+ resolveMatrixAuthContext: vi.fn(() => ({
289
+ accountId: "default",
290
+ })),
291
+ resolveSharedMatrixClient: hoisted.resolveSharedMatrixClient,
292
+ }));
293
+
294
+ vi.mock("../client/shared.js", () => ({
295
+ releaseSharedClientInstance: hoisted.releaseSharedClientInstance,
296
+ }));
297
+
298
+ vi.mock("../config-update.js", () => ({
299
+ updateMatrixAccountConfig: vi.fn((cfg: unknown) => cfg),
300
+ }));
301
+
302
+ vi.mock("../device-health.js", () => ({
303
+ summarizeMatrixDeviceHealth: vi.fn(() => ({
304
+ staleOpenClawDevices: [],
305
+ })),
306
+ }));
307
+
308
+ vi.mock("../profile.js", () => ({
309
+ syncMatrixOwnProfile: vi.fn(async () => ({
310
+ displayNameUpdated: false,
311
+ avatarUpdated: false,
312
+ convertedAvatarFromHttp: false,
313
+ resolvedAvatarUrl: undefined,
314
+ })),
315
+ }));
316
+
317
+ vi.mock("../thread-bindings.js", () => ({
318
+ createMatrixThreadBindingManager: vi.fn(async () => {
319
+ hoisted.callOrder.push("create-manager");
320
+ return {
321
+ accountId: "default",
322
+ stop: hoisted.stopThreadBindingManager,
323
+ };
324
+ }),
325
+ }));
326
+
327
+ vi.mock("./allowlist.js", () => ({
328
+ normalizeMatrixUserId: (value: string) => value,
329
+ }));
330
+
331
+ vi.mock("./auto-join.js", () => ({
332
+ registerMatrixAutoJoin: vi.fn(),
333
+ }));
334
+
335
+ vi.mock("./direct.js", () => ({
336
+ createDirectRoomTracker: hoisted.createDirectRoomTracker,
337
+ }));
338
+
339
+ vi.mock("./events.js", () => ({
340
+ registerMatrixMonitorEvents: vi.fn(
341
+ (params: {
342
+ onRoomMessage: (roomId: string, event: unknown) => Promise<void>;
343
+ runDetachedTask?: (label: string, task: () => Promise<void>) => Promise<void>;
344
+ }) => {
345
+ hoisted.callOrder.push("register-events");
346
+ hoisted.registeredOnRoomMessage = (roomId: string, event: unknown) =>
347
+ params.runDetachedTask
348
+ ? params.runDetachedTask("test room message", async () => {
349
+ await params.onRoomMessage(roomId, event);
350
+ })
351
+ : params.onRoomMessage(roomId, event);
352
+ },
353
+ ),
354
+ }));
355
+
356
+ vi.mock("./handler.js", () => ({
357
+ createMatrixRoomMessageHandler: hoisted.createMatrixRoomMessageHandler,
358
+ }));
359
+
360
+ vi.mock("./inbound-dedupe.js", () => ({
361
+ createMatrixInboundEventDeduper: hoisted.createMatrixInboundEventDeduper,
362
+ }));
363
+
364
+ vi.mock("./legacy-crypto-restore.js", () => ({
365
+ maybeRestoreLegacyMatrixBackup: vi.fn(),
366
+ }));
367
+
368
+ vi.mock("./room-info.js", () => ({
369
+ createMatrixRoomInfoResolver: vi.fn(() => ({
370
+ getRoomInfo: hoisted.getRoomInfo,
371
+ getMemberDisplayName: hoisted.getMemberDisplayName,
372
+ })),
373
+ }));
374
+
375
+ vi.mock("./startup-verification.js", () => ({
376
+ ensureMatrixStartupVerification: vi.fn(),
377
+ }));
378
+
379
+ vi.mock("./startup.js", () => ({
380
+ runMatrixStartupMaintenance: hoisted.runMatrixStartupMaintenance,
381
+ }));
382
+
383
+ let monitorMatrixProvider: typeof import("./index.js").monitorMatrixProvider;
384
+
385
+ describe("monitorMatrixProvider", () => {
386
+ beforeAll(async () => {
387
+ ({ monitorMatrixProvider } = await import("./index.js"));
388
+ });
389
+
390
+ async function startMonitorAndAbortAfterStartup(): Promise<void> {
391
+ const abortController = new AbortController();
392
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
393
+ await vi.waitFor(() => {
394
+ expect(hoisted.callOrder).toContain("start-client");
395
+ });
396
+ abortController.abort();
397
+ await monitorPromise;
398
+ }
399
+ beforeEach(() => {
400
+ hoisted.callOrder.length = 0;
401
+ hoisted.state.startClientError = null;
402
+ hoisted.accountConfig.dm = {};
403
+ delete (hoisted.accountConfig as { rooms?: Record<string, unknown> }).rooms;
404
+ hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
405
+ hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true);
406
+ hoisted.resolveSharedMatrixClient
407
+ .mockReset()
408
+ .mockImplementation(async (params: { startClient?: boolean }) => {
409
+ if (params.startClient === false) {
410
+ hoisted.callOrder.push("prepare-client");
411
+ return hoisted.client;
412
+ }
413
+ if (!hoisted.callOrder.includes("create-manager")) {
414
+ throw new Error("Matrix client started before thread bindings were registered");
415
+ }
416
+ if (hoisted.state.startClientError) {
417
+ throw hoisted.state.startClientError;
418
+ }
419
+ hoisted.callOrder.push("start-client");
420
+ return hoisted.client;
421
+ });
422
+ hoisted.createDirectRoomTracker.mockReset().mockReturnValue({
423
+ isDirectMessage: vi.fn(async () => false),
424
+ });
425
+ hoisted.getRoomInfo.mockReset().mockResolvedValue({
426
+ altAliases: [],
427
+ nameResolved: true,
428
+ aliasesResolved: true,
429
+ });
430
+ hoisted.getMemberDisplayName.mockReset().mockResolvedValue("Bot");
431
+ hoisted.registeredOnRoomMessage = null;
432
+ hoisted.setActiveMatrixClient.mockReset();
433
+ hoisted.stopThreadBindingManager.mockReset();
434
+ hoisted.client.removeAllListeners();
435
+ hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false);
436
+ hoisted.client.stopSyncWithoutPersist.mockReset();
437
+ hoisted.client.drainPendingDecryptions.mockReset().mockResolvedValue(undefined);
438
+ hoisted.inboundDeduper.claimEvent.mockReset().mockReturnValue(true);
439
+ hoisted.inboundDeduper.commitEvent.mockReset().mockResolvedValue(undefined);
440
+ hoisted.inboundDeduper.releaseEvent.mockReset();
441
+ hoisted.inboundDeduper.flush.mockReset().mockResolvedValue(undefined);
442
+ hoisted.inboundDeduper.stop.mockReset().mockResolvedValue(undefined);
443
+ hoisted.createMatrixInboundEventDeduper.mockReset().mockResolvedValue(hoisted.inboundDeduper);
444
+ hoisted.backfillMatrixAuthDeviceIdAfterStartup.mockReset().mockResolvedValue(undefined);
445
+ hoisted.runMatrixStartupMaintenance.mockReset().mockResolvedValue(undefined);
446
+ hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn());
447
+ hoisted.setStatus.mockReset();
448
+ Object.values(hoisted.logger).forEach((mock) => mock.mockReset());
449
+ });
450
+
451
+ it("returns immediately when the abort signal is already canceled", async () => {
452
+ const abortController = new AbortController();
453
+ abortController.abort();
454
+
455
+ await monitorMatrixProvider({ abortSignal: abortController.signal });
456
+
457
+ expect(hoisted.callOrder).toEqual([]);
458
+ expect(hoisted.resolveTextChunkLimit).not.toHaveBeenCalled();
459
+ expect(hoisted.createMatrixRoomMessageHandler).not.toHaveBeenCalled();
460
+ expect(hoisted.setActiveMatrixClient).not.toHaveBeenCalled();
461
+ });
462
+
463
+ it("publishes disconnected startup status and connected sync status without failing the monitor", async () => {
464
+ const abortController = new AbortController();
465
+ const monitorPromise = monitorMatrixProvider({
466
+ abortSignal: abortController.signal,
467
+ setStatus: hoisted.setStatus,
468
+ });
469
+
470
+ await vi.waitFor(() => {
471
+ expect(hoisted.callOrder).toContain("start-client");
472
+ });
473
+
474
+ expect(hoisted.setStatus).toHaveBeenCalledWith(
475
+ expect.objectContaining({
476
+ accountId: "default",
477
+ baseUrl: "https://matrix.example.org",
478
+ connected: false,
479
+ healthState: "starting",
480
+ }),
481
+ );
482
+
483
+ hoisted.client.emit("sync.state", "SYNCING", "RECONNECTING", undefined);
484
+
485
+ await vi.waitFor(() => {
486
+ expect(hoisted.setStatus).toHaveBeenCalledWith(
487
+ expect.objectContaining({
488
+ accountId: "default",
489
+ connected: true,
490
+ healthState: "healthy",
491
+ lastError: null,
492
+ }),
493
+ );
494
+ });
495
+
496
+ abortController.abort();
497
+ await expect(monitorPromise).resolves.toBeUndefined();
498
+ });
499
+
500
+ it("contains room-message handler rejections inside monitor task tracking", async () => {
501
+ const abortController = new AbortController();
502
+ const unhandled: unknown[] = [];
503
+ const onUnhandled = (reason: unknown) => {
504
+ unhandled.push(reason);
505
+ };
506
+
507
+ hoisted.createMatrixRoomMessageHandler.mockReturnValue(
508
+ vi.fn(async () => {
509
+ throw new Error("room handler exploded");
510
+ }),
511
+ );
512
+
513
+ process.on("unhandledRejection", onUnhandled);
514
+ try {
515
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
516
+ await vi.waitFor(() => {
517
+ expect(hoisted.callOrder).toContain("start-client");
518
+ });
519
+
520
+ const onRoomMessage = hoisted.registeredOnRoomMessage;
521
+ if (!onRoomMessage) {
522
+ throw new Error("expected room message handler to be registered");
523
+ }
524
+
525
+ await onRoomMessage("!room:example.org", { event_id: "$event" });
526
+ await Promise.resolve();
527
+
528
+ expect(unhandled).toHaveLength(0);
529
+ expect(hoisted.logger.warn).toHaveBeenCalledWith(
530
+ "matrix background task failed",
531
+ expect.objectContaining({
532
+ task: "test room message",
533
+ error: "Error: room handler exploded",
534
+ }),
535
+ );
536
+
537
+ abortController.abort();
538
+ await monitorPromise;
539
+ } finally {
540
+ process.off("unhandledRejection", onUnhandled);
541
+ }
542
+ });
543
+
544
+ it("fails the channel task when Matrix sync emits an unexpected fatal error", async () => {
545
+ const abortController = new AbortController();
546
+ const monitorPromise = monitorMatrixProvider({
547
+ abortSignal: abortController.signal,
548
+ setStatus: hoisted.setStatus,
549
+ });
550
+
551
+ await vi.waitFor(() => {
552
+ expect(hoisted.callOrder).toContain("start-client");
553
+ });
554
+
555
+ hoisted.client.emit("sync.unexpected_error", new Error("sync exploded"));
556
+
557
+ await expect(monitorPromise).rejects.toThrow("sync exploded");
558
+ expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
559
+ expect(hoisted.setStatus).toHaveBeenCalledWith(
560
+ expect.objectContaining({
561
+ accountId: "default",
562
+ connected: false,
563
+ healthState: "error",
564
+ lastError: "sync exploded",
565
+ }),
566
+ );
567
+ });
568
+
569
+ it("marks early startup failures as error before the monitor loop starts", async () => {
570
+ hoisted.resolveSharedMatrixClient.mockImplementation(
571
+ async (params: { startClient?: boolean }) => {
572
+ if (params.startClient === false) {
573
+ throw new Error("prepare failed");
574
+ }
575
+ hoisted.callOrder.push("start-client");
576
+ return hoisted.client;
577
+ },
578
+ );
579
+
580
+ await expect(
581
+ monitorMatrixProvider({
582
+ setStatus: hoisted.setStatus,
583
+ }),
584
+ ).rejects.toThrow("prepare failed");
585
+
586
+ expect(hoisted.releaseSharedClientInstance).not.toHaveBeenCalled();
587
+ expect(hoisted.setStatus).toHaveBeenLastCalledWith(
588
+ expect.objectContaining({
589
+ accountId: "default",
590
+ connected: false,
591
+ healthState: "error",
592
+ lastError: "prepare failed",
593
+ }),
594
+ );
595
+ });
596
+
597
+ it("releases the prepared client when startup fails before later resources exist", async () => {
598
+ hoisted.createMatrixInboundEventDeduper.mockRejectedValue(new Error("deduper failed"));
599
+
600
+ await expect(
601
+ monitorMatrixProvider({
602
+ setStatus: hoisted.setStatus,
603
+ }),
604
+ ).rejects.toThrow("deduper failed");
605
+
606
+ expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
607
+ expect(hoisted.inboundDeduper.stop).not.toHaveBeenCalled();
608
+ expect(hoisted.setStatus).toHaveBeenLastCalledWith(
609
+ expect.objectContaining({
610
+ accountId: "default",
611
+ connected: false,
612
+ healthState: "error",
613
+ lastError: "deduper failed",
614
+ }),
615
+ );
616
+ });
617
+
618
+ it("aborts stalled startup promptly and releases the shared client without persist", async () => {
619
+ const abortController = new AbortController();
620
+ hoisted.resolveSharedMatrixClient.mockImplementation(
621
+ async (params: { startClient?: boolean; abortSignal?: AbortSignal }) => {
622
+ if (params.startClient === false) {
623
+ hoisted.callOrder.push("prepare-client");
624
+ return hoisted.client;
625
+ }
626
+ hoisted.callOrder.push("start-client");
627
+ return await new Promise<typeof hoisted.client>((_resolve, reject) => {
628
+ params.abortSignal?.addEventListener(
629
+ "abort",
630
+ () => {
631
+ const error = new Error("Matrix startup aborted");
632
+ error.name = "AbortError";
633
+ reject(error);
634
+ },
635
+ { once: true },
636
+ );
637
+ });
638
+ },
639
+ );
640
+
641
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
642
+
643
+ await vi.waitFor(() => {
644
+ expect(hoisted.callOrder).toContain("start-client");
645
+ });
646
+
647
+ abortController.abort();
648
+
649
+ await expect(monitorPromise).resolves.toBeUndefined();
650
+ expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "stop");
651
+ expect(hoisted.client.drainPendingDecryptions).not.toHaveBeenCalled();
652
+ });
653
+
654
+ it("aborts during startup maintenance and releases the shared client without persist", async () => {
655
+ const abortController = new AbortController();
656
+ hoisted.runMatrixStartupMaintenance.mockImplementation(
657
+ async (params: { abortSignal?: AbortSignal }) =>
658
+ await new Promise<void>((_resolve, reject) => {
659
+ params.abortSignal?.addEventListener(
660
+ "abort",
661
+ () => {
662
+ const error = new Error("Matrix startup aborted");
663
+ error.name = "AbortError";
664
+ reject(error);
665
+ },
666
+ { once: true },
667
+ );
668
+ }),
669
+ );
670
+
671
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
672
+
673
+ await vi.waitFor(() => {
674
+ expect(hoisted.runMatrixStartupMaintenance).toHaveBeenCalledTimes(1);
675
+ });
676
+
677
+ abortController.abort();
678
+
679
+ await expect(monitorPromise).resolves.toBeUndefined();
680
+ expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "stop");
681
+ expect(hoisted.client.drainPendingDecryptions).not.toHaveBeenCalled();
682
+ });
683
+
684
+ it("registers Matrix thread bindings before starting the client", async () => {
685
+ await startMonitorAndAbortAfterStartup();
686
+
687
+ expect(hoisted.callOrder).toEqual([
688
+ "prepare-client",
689
+ "create-manager",
690
+ "register-events",
691
+ "start-client",
692
+ ]);
693
+ expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
694
+ });
695
+
696
+ it("resolves text chunk limit for the effective Matrix account", async () => {
697
+ await startMonitorAndAbortAfterStartup();
698
+
699
+ expect(hoisted.resolveTextChunkLimit).toHaveBeenCalledWith(
700
+ expect.anything(),
701
+ "matrix",
702
+ "default",
703
+ );
704
+ });
705
+
706
+ it("starts monitoring without waiting for best-effort deviceId backfill", async () => {
707
+ hoisted.backfillMatrixAuthDeviceIdAfterStartup.mockImplementation(
708
+ () => new Promise<undefined>(() => {}),
709
+ );
710
+
711
+ const abortController = new AbortController();
712
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
713
+
714
+ await vi.waitFor(() => {
715
+ expect(hoisted.callOrder).toContain("start-client");
716
+ expect(hoisted.backfillMatrixAuthDeviceIdAfterStartup).toHaveBeenCalledTimes(1);
717
+ });
718
+ expect(hoisted.backfillMatrixAuthDeviceIdAfterStartup).toHaveBeenCalledWith(
719
+ expect.objectContaining({
720
+ abortSignal: abortController.signal,
721
+ }),
722
+ );
723
+
724
+ abortController.abort();
725
+ await expect(monitorPromise).resolves.toBeUndefined();
726
+ });
727
+
728
+ it("cleans up thread bindings and shared clients when startup fails", async () => {
729
+ hoisted.state.startClientError = new Error("start failed");
730
+
731
+ await expect(monitorMatrixProvider()).rejects.toThrow("start failed");
732
+
733
+ expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
734
+ expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledTimes(1);
735
+ expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
736
+ expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default");
737
+ expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default");
738
+ });
739
+
740
+ it("disables cold-start backlog dropping only when sync state is cleanly persisted", async () => {
741
+ hoisted.client.hasPersistedSyncState.mockReturnValue(true);
742
+ await startMonitorAndAbortAfterStartup();
743
+
744
+ expect(hoisted.createMatrixRoomMessageHandler).toHaveBeenCalledWith(
745
+ expect.objectContaining({
746
+ dropPreStartupMessages: false,
747
+ }),
748
+ );
749
+ });
750
+
751
+ it("stops sync, drains decryptions, then waits for in-flight handlers before persisting", async () => {
752
+ const abortController = new AbortController();
753
+ let resolveHandler: (() => void) | null = null;
754
+
755
+ hoisted.createMatrixRoomMessageHandler.mockReturnValue(
756
+ vi.fn(() => {
757
+ hoisted.callOrder.push("handler-start");
758
+ return new Promise<void>((resolve) => {
759
+ resolveHandler = () => {
760
+ hoisted.callOrder.push("handler-done");
761
+ resolve();
762
+ };
763
+ });
764
+ }),
765
+ );
766
+ hoisted.client.stopSyncWithoutPersist.mockImplementation(() => {
767
+ hoisted.callOrder.push("pause-client");
768
+ });
769
+ hoisted.client.drainPendingDecryptions.mockImplementation(async () => {
770
+ hoisted.callOrder.push("drain-decrypts");
771
+ });
772
+ hoisted.stopThreadBindingManager.mockImplementation(() => {
773
+ hoisted.callOrder.push("stop-manager");
774
+ });
775
+ hoisted.releaseSharedClientInstance.mockImplementation(async () => {
776
+ hoisted.callOrder.push("release-client");
777
+ return true;
778
+ });
779
+ hoisted.inboundDeduper.stop.mockImplementation(async () => {
780
+ hoisted.callOrder.push("stop-deduper");
781
+ });
782
+
783
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
784
+ await vi.waitFor(() => {
785
+ expect(hoisted.callOrder).toContain("start-client");
786
+ });
787
+ const onRoomMessage = hoisted.registeredOnRoomMessage;
788
+ if (!onRoomMessage) {
789
+ throw new Error("expected room message handler to be registered");
790
+ }
791
+
792
+ const roomMessagePromise = onRoomMessage("!room:example.org", { event_id: "$event" });
793
+ abortController.abort();
794
+ await vi.waitFor(() => {
795
+ expect(hoisted.callOrder).toContain("pause-client");
796
+ });
797
+ expect(hoisted.callOrder).not.toContain("stop-deduper");
798
+
799
+ if (resolveHandler === null) {
800
+ throw new Error("expected in-flight handler to be pending");
801
+ }
802
+ (resolveHandler as () => void)();
803
+ await roomMessagePromise;
804
+ await monitorPromise;
805
+
806
+ expect(hoisted.callOrder.indexOf("pause-client")).toBeLessThan(
807
+ hoisted.callOrder.indexOf("drain-decrypts"),
808
+ );
809
+ expect(hoisted.callOrder.indexOf("drain-decrypts")).toBeLessThan(
810
+ hoisted.callOrder.indexOf("handler-done"),
811
+ );
812
+ expect(hoisted.callOrder.indexOf("handler-done")).toBeLessThan(
813
+ hoisted.callOrder.indexOf("stop-manager"),
814
+ );
815
+ expect(hoisted.callOrder.indexOf("stop-manager")).toBeLessThan(
816
+ hoisted.callOrder.indexOf("stop-deduper"),
817
+ );
818
+ expect(hoisted.callOrder.indexOf("stop-deduper")).toBeLessThan(
819
+ hoisted.callOrder.indexOf("release-client"),
820
+ );
821
+ });
822
+
823
+ it("wires recent-invite promotion to fail closed when room metadata is unresolved", async () => {
824
+ await startMonitorAndAbortAfterStartup();
825
+
826
+ const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1];
827
+ if (!trackerOpts?.canPromoteRecentInvite) {
828
+ throw new Error("recent invite promotion callback was not wired");
829
+ }
830
+
831
+ hoisted.getRoomInfo.mockResolvedValueOnce({
832
+ altAliases: [],
833
+ nameResolved: false,
834
+ aliasesResolved: false,
835
+ });
836
+
837
+ await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false);
838
+ });
839
+
840
+ it("wires recent-invite promotion to reject named rooms", async () => {
841
+ await startMonitorAndAbortAfterStartup();
842
+
843
+ const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1];
844
+ if (!trackerOpts?.canPromoteRecentInvite) {
845
+ throw new Error("recent invite promotion callback was not wired");
846
+ }
847
+
848
+ hoisted.getRoomInfo.mockResolvedValueOnce({
849
+ name: "Ops Room",
850
+ altAliases: [],
851
+ nameResolved: true,
852
+ aliasesResolved: true,
853
+ });
854
+
855
+ await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false);
856
+ });
857
+
858
+ it("wires recent-invite promotion to reject wildcard-configured rooms", async () => {
859
+ (hoisted.accountConfig as { rooms?: Record<string, unknown> }).rooms = {
860
+ "*": { enabled: false },
861
+ };
862
+
863
+ await startMonitorAndAbortAfterStartup();
864
+
865
+ const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1];
866
+ if (!trackerOpts?.canPromoteRecentInvite) {
867
+ throw new Error("recent invite promotion callback was not wired");
868
+ }
869
+
870
+ hoisted.getRoomInfo.mockResolvedValueOnce({
871
+ altAliases: [],
872
+ nameResolved: true,
873
+ aliasesResolved: true,
874
+ });
875
+
876
+ await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false);
877
+ });
878
+
879
+ it("treats unresolved room metadata as indeterminate for local promotion revalidation", async () => {
880
+ await startMonitorAndAbortAfterStartup();
881
+
882
+ const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1];
883
+ if (!trackerOpts?.shouldKeepLocallyPromotedDirectRoom) {
884
+ throw new Error("local promotion revalidation callback was not wired");
885
+ }
886
+
887
+ hoisted.getRoomInfo.mockResolvedValueOnce({
888
+ altAliases: [],
889
+ nameResolved: false,
890
+ aliasesResolved: false,
891
+ });
892
+
893
+ await expect(
894
+ trackerOpts.shouldKeepLocallyPromotedDirectRoom("!room:example.org"),
895
+ ).resolves.toBeUndefined();
896
+ });
897
+ });
898
+
899
+ describe("matrix plugin registration", () => {
900
+ beforeEach(() => {
901
+ vi.clearAllMocks();
902
+ });
903
+
904
+ it("loads the matrix runtime api through Jiti", () => {
905
+ const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
906
+ expect(
907
+ loadRuntimeApiExportTypesViaJiti({
908
+ modulePath: runtimeApiPath,
909
+ exportNames: [
910
+ "requiresExplicitMatrixDefaultAccount",
911
+ "resolveMatrixDefaultOrOnlyAccountId",
912
+ ],
913
+ realPluginSdkSpecifiers: [],
914
+ }),
915
+ ).toEqual({
916
+ requiresExplicitMatrixDefaultAccount: "function",
917
+ resolveMatrixDefaultOrOnlyAccountId: "function",
918
+ });
919
+ }, 240_000);
920
+ });