@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,2952 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
5
+ import {
6
+ __testing as sessionBindingTesting,
7
+ registerSessionBindingAdapter,
8
+ } from "openclaw/plugin-sdk/conversation-runtime";
9
+ import { beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
11
+ import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
12
+ import { createMatrixRoomMessageHandler } from "./handler.js";
13
+ import {
14
+ createMatrixHandlerTestHarness,
15
+ createMatrixReactionEvent,
16
+ createMatrixRoomMessageEvent,
17
+ createMatrixTextMessageEvent,
18
+ } from "./handler.test-helpers.js";
19
+ import type { MatrixRawEvent } from "./types.js";
20
+ import { EventType } from "./types.js";
21
+
22
+ const sendMessageMatrixMock = vi.hoisted(() =>
23
+ vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })),
24
+ );
25
+ const sendSingleTextMessageMatrixMock = vi.hoisted(() =>
26
+ vi.fn(async (..._args: unknown[]) => ({ messageId: "$draft1", roomId: "!room" })),
27
+ );
28
+ const editMessageMatrixMock = vi.hoisted(() => vi.fn(async () => "$edited"));
29
+ const prepareMatrixSingleTextMock = vi.hoisted(() =>
30
+ vi.fn((text: string) => {
31
+ const trimmedText = text.trim();
32
+ return {
33
+ trimmedText,
34
+ convertedText: trimmedText,
35
+ singleEventLimit: 4000,
36
+ fitsInSingleEvent: true,
37
+ };
38
+ }),
39
+ );
40
+
41
+ vi.mock("../send.js", () => ({
42
+ editMessageMatrix: editMessageMatrixMock,
43
+ prepareMatrixSingleText: prepareMatrixSingleTextMock,
44
+ reactMatrixMessage: vi.fn(async () => {}),
45
+ sendMessageMatrix: sendMessageMatrixMock,
46
+ sendSingleTextMessageMatrix: sendSingleTextMessageMatrixMock,
47
+ sendReadReceiptMatrix: vi.fn(async () => {}),
48
+ sendTypingMatrix: vi.fn(async () => {}),
49
+ }));
50
+
51
+ const deliverMatrixRepliesMock = vi.hoisted(() => vi.fn(async () => true));
52
+
53
+ vi.mock("./replies.js", () => ({
54
+ deliverMatrixReplies: deliverMatrixRepliesMock,
55
+ }));
56
+
57
+ beforeEach(() => {
58
+ sessionBindingTesting.resetSessionBindingAdaptersForTests();
59
+ installMatrixMonitorTestRuntime();
60
+ prepareMatrixSingleTextMock.mockReset().mockImplementation((text: string) => {
61
+ const trimmedText = text.trim();
62
+ return {
63
+ trimmedText,
64
+ convertedText: trimmedText,
65
+ singleEventLimit: 4000,
66
+ fitsInSingleEvent: true,
67
+ };
68
+ });
69
+ });
70
+
71
+ function createReactionHarness(params?: {
72
+ cfg?: unknown;
73
+ dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
74
+ allowFrom?: string[];
75
+ storeAllowFrom?: string[];
76
+ targetSender?: string;
77
+ isDirectMessage?: boolean;
78
+ senderName?: string;
79
+ client?: NonNullable<Parameters<typeof createMatrixHandlerTestHarness>[0]>["client"];
80
+ }) {
81
+ return createMatrixHandlerTestHarness({
82
+ cfg: params?.cfg,
83
+ dmPolicy: params?.dmPolicy,
84
+ allowFrom: params?.allowFrom,
85
+ readAllowFromStore: vi.fn(async () => params?.storeAllowFrom ?? []),
86
+ client: {
87
+ getEvent: async () => ({ sender: params?.targetSender ?? "@bot:example.org" }),
88
+ ...params?.client,
89
+ },
90
+ isDirectMessage: params?.isDirectMessage,
91
+ getMemberDisplayName: async () => params?.senderName ?? "sender",
92
+ });
93
+ }
94
+
95
+ describe("matrix monitor handler pairing account scope", () => {
96
+ it("caches account-scoped allowFrom store reads on hot path", async () => {
97
+ const readAllowFromStore = vi.fn(async () => [] as string[]);
98
+ sendMessageMatrixMock.mockClear();
99
+
100
+ const { handler } = createMatrixHandlerTestHarness({
101
+ readAllowFromStore,
102
+ dmPolicy: "pairing",
103
+ buildPairingReply: () => "pairing",
104
+ });
105
+
106
+ await handler(
107
+ "!room:example.org",
108
+ createMatrixTextMessageEvent({
109
+ eventId: "$event1",
110
+ body: "@room hello",
111
+ mentions: { room: true },
112
+ }),
113
+ );
114
+
115
+ await handler(
116
+ "!room:example.org",
117
+ createMatrixTextMessageEvent({
118
+ eventId: "$event2",
119
+ body: "@room hello again",
120
+ mentions: { room: true },
121
+ }),
122
+ );
123
+
124
+ expect(readAllowFromStore).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("refreshes the account-scoped allowFrom cache after its ttl expires", async () => {
128
+ vi.useFakeTimers();
129
+ vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
130
+ try {
131
+ const readAllowFromStore = vi.fn(async () => [] as string[]);
132
+ const { handler } = createMatrixHandlerTestHarness({
133
+ readAllowFromStore,
134
+ dmPolicy: "pairing",
135
+ buildPairingReply: () => "pairing",
136
+ });
137
+
138
+ const makeEvent = (id: string): MatrixRawEvent =>
139
+ createMatrixTextMessageEvent({
140
+ eventId: id,
141
+ body: "@room hello",
142
+ mentions: { room: true },
143
+ });
144
+
145
+ await handler("!room:example.org", makeEvent("$event1"));
146
+ await handler("!room:example.org", makeEvent("$event2"));
147
+ expect(readAllowFromStore).toHaveBeenCalledTimes(1);
148
+
149
+ await vi.advanceTimersByTimeAsync(30_001);
150
+ await handler("!room:example.org", makeEvent("$event3"));
151
+
152
+ expect(readAllowFromStore).toHaveBeenCalledTimes(2);
153
+ } finally {
154
+ vi.useRealTimers();
155
+ }
156
+ });
157
+
158
+ it("sends pairing reminders for pending requests with cooldown", async () => {
159
+ vi.useFakeTimers();
160
+ vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
161
+ try {
162
+ const readAllowFromStore = vi.fn(async () => [] as string[]);
163
+ sendMessageMatrixMock.mockClear();
164
+
165
+ const { handler } = createMatrixHandlerTestHarness({
166
+ readAllowFromStore,
167
+ dmPolicy: "pairing",
168
+ buildPairingReply: () => "Pairing code: ABCDEFGH",
169
+ isDirectMessage: true,
170
+ getMemberDisplayName: async () => "sender",
171
+ });
172
+
173
+ const makeEvent = (id: string): MatrixRawEvent =>
174
+ createMatrixTextMessageEvent({
175
+ eventId: id,
176
+ body: "hello",
177
+ mentions: { room: true },
178
+ });
179
+
180
+ await handler("!room:example.org", makeEvent("$event1"));
181
+ await handler("!room:example.org", makeEvent("$event2"));
182
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
183
+ expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toContain(
184
+ "Pairing request is still pending approval.",
185
+ );
186
+
187
+ await vi.advanceTimersByTimeAsync(5 * 60_000 + 1);
188
+ await handler("!room:example.org", makeEvent("$event3"));
189
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
190
+ } finally {
191
+ vi.useRealTimers();
192
+ }
193
+ });
194
+
195
+ it("uses account-scoped pairing store reads and upserts for dm pairing", async () => {
196
+ const readAllowFromStore = vi.fn(async () => [] as string[]);
197
+ const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
198
+
199
+ const { handler } = createMatrixHandlerTestHarness({
200
+ readAllowFromStore,
201
+ upsertPairingRequest,
202
+ dmPolicy: "pairing",
203
+ isDirectMessage: true,
204
+ getMemberDisplayName: async () => "sender",
205
+ dropPreStartupMessages: true,
206
+ needsRoomAliasesForConfig: false,
207
+ });
208
+
209
+ await handler(
210
+ "!room:example.org",
211
+ createMatrixTextMessageEvent({
212
+ eventId: "$event1",
213
+ body: "hello",
214
+ mentions: { room: true },
215
+ }),
216
+ );
217
+
218
+ expect(readAllowFromStore).toHaveBeenCalledWith({
219
+ channel: "matrix",
220
+ env: process.env,
221
+ accountId: "ops",
222
+ });
223
+ expect(upsertPairingRequest).toHaveBeenCalledWith({
224
+ channel: "matrix",
225
+ id: "@user:example.org",
226
+ accountId: "ops",
227
+ meta: { name: "sender" },
228
+ });
229
+ });
230
+
231
+ it("passes accountId into route resolution for inbound dm messages", async () => {
232
+ const resolveAgentRoute = vi.fn(() => ({
233
+ agentId: "ops",
234
+ channel: "matrix",
235
+ accountId: "ops",
236
+ sessionKey: "agent:ops:main",
237
+ mainSessionKey: "agent:ops:main",
238
+ matchedBy: "binding.account" as const,
239
+ }));
240
+
241
+ const { handler } = createMatrixHandlerTestHarness({
242
+ resolveAgentRoute,
243
+ isDirectMessage: true,
244
+ getMemberDisplayName: async () => "sender",
245
+ });
246
+
247
+ await handler(
248
+ "!room:example.org",
249
+ createMatrixTextMessageEvent({
250
+ eventId: "$event2",
251
+ body: "hello",
252
+ mentions: { room: true },
253
+ }),
254
+ );
255
+
256
+ expect(resolveAgentRoute).toHaveBeenCalledWith(
257
+ expect.objectContaining({
258
+ channel: "matrix",
259
+ accountId: "ops",
260
+ }),
261
+ );
262
+ });
263
+
264
+ it("does not enqueue delivered text messages into system events", async () => {
265
+ const dispatchReplyFromConfig = vi.fn(async () => ({
266
+ queuedFinal: true,
267
+ counts: { final: 1, block: 0, tool: 0 },
268
+ }));
269
+ const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
270
+ dispatchReplyFromConfig,
271
+ isDirectMessage: true,
272
+ getMemberDisplayName: async () => "sender",
273
+ });
274
+
275
+ await handler(
276
+ "!room:example.org",
277
+ createMatrixTextMessageEvent({
278
+ eventId: "$event-system-preview",
279
+ body: "hello from matrix",
280
+ mentions: { room: true },
281
+ }),
282
+ );
283
+
284
+ expect(dispatchReplyFromConfig).toHaveBeenCalled();
285
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it("drops room messages from configured Matrix bot accounts when allowBots is off", async () => {
289
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
290
+ isDirectMessage: false,
291
+ configuredBotUserIds: new Set(["@ops:example.org"]),
292
+ roomsConfig: {
293
+ "!room:example.org": { requireMention: false },
294
+ },
295
+ getMemberDisplayName: async () => "ops-bot",
296
+ });
297
+
298
+ await handler(
299
+ "!room:example.org",
300
+ createMatrixTextMessageEvent({
301
+ eventId: "$bot-off",
302
+ sender: "@ops:example.org",
303
+ body: "hello from bot",
304
+ }),
305
+ );
306
+
307
+ expect(recordInboundSession).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it("accepts room messages from configured Matrix bot accounts when allowBots is true", async () => {
311
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
312
+ isDirectMessage: false,
313
+ accountAllowBots: true,
314
+ configuredBotUserIds: new Set(["@ops:example.org"]),
315
+ roomsConfig: {
316
+ "!room:example.org": { requireMention: false },
317
+ },
318
+ getMemberDisplayName: async () => "ops-bot",
319
+ });
320
+
321
+ await handler(
322
+ "!room:example.org",
323
+ createMatrixTextMessageEvent({
324
+ eventId: "$bot-on",
325
+ sender: "@ops:example.org",
326
+ body: "hello from bot",
327
+ }),
328
+ );
329
+
330
+ expect(recordInboundSession).toHaveBeenCalled();
331
+ });
332
+
333
+ it("does not treat unconfigured Matrix users as bots when allowBots is off", async () => {
334
+ const { handler, resolveAgentRoute, recordInboundSession } = createMatrixHandlerTestHarness({
335
+ isDirectMessage: false,
336
+ configuredBotUserIds: new Set(["@ops:example.org"]),
337
+ roomsConfig: {
338
+ "!room:example.org": { requireMention: false },
339
+ },
340
+ getMemberDisplayName: async () => "human",
341
+ });
342
+
343
+ await handler(
344
+ "!room:example.org",
345
+ createMatrixTextMessageEvent({
346
+ eventId: "$non-bot",
347
+ sender: "@alice:example.org",
348
+ body: "hello from human",
349
+ }),
350
+ );
351
+
352
+ expect(resolveAgentRoute).toHaveBeenCalled();
353
+ expect(recordInboundSession).toHaveBeenCalled();
354
+ });
355
+
356
+ it('drops configured Matrix bot room messages without a mention when allowBots="mentions"', async () => {
357
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
358
+ isDirectMessage: false,
359
+ accountAllowBots: "mentions",
360
+ configuredBotUserIds: new Set(["@ops:example.org"]),
361
+ roomsConfig: {
362
+ "!room:example.org": { requireMention: false },
363
+ },
364
+ mentionRegexes: [/@bot/i],
365
+ getMemberDisplayName: async () => "ops-bot",
366
+ });
367
+
368
+ await handler(
369
+ "!room:example.org",
370
+ createMatrixTextMessageEvent({
371
+ eventId: "$bot-mentions-off",
372
+ sender: "@ops:example.org",
373
+ body: "hello from bot",
374
+ }),
375
+ );
376
+
377
+ expect(recordInboundSession).not.toHaveBeenCalled();
378
+ });
379
+
380
+ it('accepts configured Matrix bot room messages with a mention when allowBots="mentions"', async () => {
381
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
382
+ isDirectMessage: false,
383
+ accountAllowBots: "mentions",
384
+ configuredBotUserIds: new Set(["@ops:example.org"]),
385
+ roomsConfig: {
386
+ "!room:example.org": { requireMention: false },
387
+ },
388
+ mentionRegexes: [/@bot/i],
389
+ getMemberDisplayName: async () => "ops-bot",
390
+ });
391
+
392
+ await handler(
393
+ "!room:example.org",
394
+ createMatrixTextMessageEvent({
395
+ eventId: "$bot-mentions-on",
396
+ sender: "@ops:example.org",
397
+ body: "hello @bot",
398
+ mentions: { user_ids: ["@bot:example.org"] },
399
+ }),
400
+ );
401
+
402
+ expect(recordInboundSession).toHaveBeenCalled();
403
+ });
404
+
405
+ it('accepts configured Matrix bot DMs without a mention when allowBots="mentions"', async () => {
406
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
407
+ isDirectMessage: true,
408
+ accountAllowBots: "mentions",
409
+ configuredBotUserIds: new Set(["@ops:example.org"]),
410
+ getMemberDisplayName: async () => "ops-bot",
411
+ });
412
+
413
+ await handler(
414
+ "!dm:example.org",
415
+ createMatrixTextMessageEvent({
416
+ eventId: "$bot-dm-mentions",
417
+ sender: "@ops:example.org",
418
+ body: "hello from dm bot",
419
+ }),
420
+ );
421
+
422
+ expect(recordInboundSession).toHaveBeenCalled();
423
+ });
424
+
425
+ it("lets room-level allowBots override a permissive account default", async () => {
426
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
427
+ isDirectMessage: false,
428
+ accountAllowBots: true,
429
+ configuredBotUserIds: new Set(["@ops:example.org"]),
430
+ roomsConfig: {
431
+ "!room:example.org": { requireMention: false, allowBots: false },
432
+ },
433
+ getMemberDisplayName: async () => "ops-bot",
434
+ });
435
+
436
+ await handler(
437
+ "!room:example.org",
438
+ createMatrixTextMessageEvent({
439
+ eventId: "$bot-room-override",
440
+ sender: "@ops:example.org",
441
+ body: "hello from bot",
442
+ }),
443
+ );
444
+
445
+ expect(recordInboundSession).not.toHaveBeenCalled();
446
+ });
447
+
448
+ it("processes room messages mentioned via displayName in formatted_body", async () => {
449
+ const recordInboundSession = vi.fn(async () => {});
450
+ const { handler } = createMatrixHandlerTestHarness({
451
+ isDirectMessage: false,
452
+ getMemberDisplayName: async () => "Tom Servo",
453
+ recordInboundSession,
454
+ });
455
+
456
+ await handler(
457
+ "!room:example.org",
458
+ createMatrixRoomMessageEvent({
459
+ eventId: "$display-name-mention",
460
+ content: {
461
+ msgtype: "m.text",
462
+ body: "Tom Servo: hello",
463
+ formatted_body: '<a href="https://matrix.to/#/@bot:example.org">Tom Servo</a>: hello',
464
+ },
465
+ }),
466
+ );
467
+
468
+ expect(recordInboundSession).toHaveBeenCalled();
469
+ });
470
+
471
+ it("does not fetch self displayName for plain-text room mentions", async () => {
472
+ const getMemberDisplayName = vi.fn(async () => "Tom Servo");
473
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
474
+ isDirectMessage: false,
475
+ mentionRegexes: [/\btom servo\b/i],
476
+ getMemberDisplayName,
477
+ });
478
+
479
+ await handler(
480
+ "!room:example.org",
481
+ createMatrixTextMessageEvent({
482
+ eventId: "$plain-text-mention",
483
+ body: "Tom Servo: hello",
484
+ }),
485
+ );
486
+
487
+ expect(recordInboundSession).toHaveBeenCalled();
488
+ expect(getMemberDisplayName).not.toHaveBeenCalledWith("!room:example.org", "@bot:example.org");
489
+ });
490
+
491
+ it("drops forged metadata-only mentions before session recording", async () => {
492
+ const { handler, recordInboundSession, resolveAgentRoute } = createMatrixHandlerTestHarness({
493
+ isDirectMessage: false,
494
+ mentionRegexes: [/@bot/i],
495
+ getMemberDisplayName: async () => "sender",
496
+ });
497
+
498
+ await handler(
499
+ "!room:example.org",
500
+ createMatrixTextMessageEvent({
501
+ eventId: "$spoofed-mention",
502
+ body: "hello there",
503
+ mentions: { user_ids: ["@bot:example.org"] },
504
+ }),
505
+ );
506
+
507
+ expect(recordInboundSession).not.toHaveBeenCalled();
508
+ expect(resolveAgentRoute).toHaveBeenCalledTimes(1);
509
+ });
510
+
511
+ it("skips media downloads for unmentioned group media messages", async () => {
512
+ const downloadContent = vi.fn(async () => Buffer.from("image"));
513
+ const getMemberDisplayName = vi.fn(async () => "sender");
514
+ const getRoomInfo = vi.fn(async () => ({ altAliases: [] }));
515
+ const { handler } = createMatrixHandlerTestHarness({
516
+ client: {
517
+ downloadContent,
518
+ },
519
+ isDirectMessage: false,
520
+ mentionRegexes: [/@bot/i],
521
+ getMemberDisplayName,
522
+ getRoomInfo,
523
+ });
524
+
525
+ await handler("!room:example.org", {
526
+ type: EventType.RoomMessage,
527
+ sender: "@user:example.org",
528
+ event_id: "$media1",
529
+ origin_server_ts: Date.now(),
530
+ content: {
531
+ msgtype: "m.image",
532
+ body: "",
533
+ url: "mxc://example.org/media",
534
+ info: {
535
+ mimetype: "image/png",
536
+ size: 5,
537
+ },
538
+ },
539
+ } as MatrixRawEvent);
540
+
541
+ expect(downloadContent).not.toHaveBeenCalled();
542
+ expect(getMemberDisplayName).not.toHaveBeenCalled();
543
+ expect(getRoomInfo).not.toHaveBeenCalled();
544
+ });
545
+
546
+ it("skips poll snapshot fetches for unmentioned group poll responses", async () => {
547
+ const getEvent = vi.fn(async () => ({
548
+ event_id: "$poll",
549
+ sender: "@user:example.org",
550
+ type: "m.poll.start",
551
+ origin_server_ts: Date.now(),
552
+ content: {
553
+ "m.poll.start": {
554
+ question: { "m.text": "Lunch?" },
555
+ kind: "m.poll.disclosed",
556
+ max_selections: 1,
557
+ answers: [{ id: "a1", "m.text": "Pizza" }],
558
+ },
559
+ },
560
+ }));
561
+ const getRelations = vi.fn(async () => ({
562
+ events: [],
563
+ nextBatch: null,
564
+ prevBatch: null,
565
+ }));
566
+ const getMemberDisplayName = vi.fn(async () => "sender");
567
+ const getRoomInfo = vi.fn(async () => ({ altAliases: [] }));
568
+ const { handler } = createMatrixHandlerTestHarness({
569
+ client: {
570
+ getEvent,
571
+ getRelations,
572
+ },
573
+ isDirectMessage: false,
574
+ mentionRegexes: [/@bot/i],
575
+ getMemberDisplayName,
576
+ getRoomInfo,
577
+ });
578
+
579
+ await handler("!room:example.org", {
580
+ type: "m.poll.response",
581
+ sender: "@user:example.org",
582
+ event_id: "$poll-response-1",
583
+ origin_server_ts: Date.now(),
584
+ content: {
585
+ "m.poll.response": {
586
+ answers: ["a1"],
587
+ },
588
+ "m.relates_to": {
589
+ rel_type: "m.reference",
590
+ event_id: "$poll",
591
+ },
592
+ },
593
+ } as MatrixRawEvent);
594
+
595
+ expect(getEvent).not.toHaveBeenCalled();
596
+ expect(getRelations).not.toHaveBeenCalled();
597
+ expect(getMemberDisplayName).not.toHaveBeenCalled();
598
+ expect(getRoomInfo).not.toHaveBeenCalled();
599
+ });
600
+
601
+ it("records thread starter context for inbound thread replies", async () => {
602
+ const { handler, finalizeInboundContext, recordInboundSession } =
603
+ createMatrixHandlerTestHarness({
604
+ client: {
605
+ getEvent: async () =>
606
+ createMatrixTextMessageEvent({
607
+ eventId: "$root",
608
+ sender: "@alice:example.org",
609
+ body: "Root topic",
610
+ }),
611
+ },
612
+ isDirectMessage: false,
613
+ getMemberDisplayName: async (_roomId, userId) =>
614
+ userId === "@alice:example.org" ? "Alice" : "sender",
615
+ });
616
+
617
+ await handler(
618
+ "!room:example.org",
619
+ createMatrixTextMessageEvent({
620
+ eventId: "$reply1",
621
+ body: "@room follow up",
622
+ relatesTo: {
623
+ rel_type: "m.thread",
624
+ event_id: "$root",
625
+ "m.in_reply_to": { event_id: "$root" },
626
+ },
627
+ mentions: { room: true },
628
+ }),
629
+ );
630
+
631
+ expect(finalizeInboundContext).toHaveBeenCalledWith(
632
+ expect.objectContaining({
633
+ MessageThreadId: "$root",
634
+ ThreadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
635
+ }),
636
+ );
637
+ expect(recordInboundSession).toHaveBeenCalledWith(
638
+ expect.objectContaining({
639
+ sessionKey: "agent:ops:main:thread:$root",
640
+ }),
641
+ );
642
+ });
643
+
644
+ it("keeps threaded DMs flat when dm threadReplies is off", async () => {
645
+ const { handler, finalizeInboundContext, recordInboundSession } =
646
+ createMatrixHandlerTestHarness({
647
+ threadReplies: "always",
648
+ dmThreadReplies: "off",
649
+ isDirectMessage: true,
650
+ client: {
651
+ getEvent: async (_roomId, eventId) =>
652
+ eventId === "$root"
653
+ ? createMatrixTextMessageEvent({
654
+ eventId: "$root",
655
+ sender: "@alice:example.org",
656
+ body: "Root topic",
657
+ })
658
+ : ({ sender: "@bot:example.org" } as never),
659
+ },
660
+ getMemberDisplayName: async (_roomId, userId) =>
661
+ userId === "@alice:example.org" ? "Alice" : "sender",
662
+ });
663
+
664
+ await handler(
665
+ "!dm:example.org",
666
+ createMatrixTextMessageEvent({
667
+ eventId: "$reply1",
668
+ body: "follow up",
669
+ relatesTo: {
670
+ rel_type: "m.thread",
671
+ event_id: "$root",
672
+ "m.in_reply_to": { event_id: "$root" },
673
+ },
674
+ }),
675
+ );
676
+
677
+ expect(finalizeInboundContext).toHaveBeenCalledWith(
678
+ expect.objectContaining({
679
+ MessageThreadId: undefined,
680
+ ReplyToId: "$root",
681
+ ThreadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
682
+ }),
683
+ );
684
+ expect(recordInboundSession).toHaveBeenCalledWith(
685
+ expect.objectContaining({
686
+ sessionKey: "agent:ops:main",
687
+ }),
688
+ );
689
+ });
690
+
691
+ it("posts a one-time notice when another Matrix DM room already owns the shared DM session", async () => {
692
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-shared-notice-"));
693
+ const storePath = path.join(tempDir, "sessions.json");
694
+ const sendNotice = vi.fn(async () => "$notice");
695
+
696
+ try {
697
+ await recordSessionMetaFromInbound({
698
+ storePath,
699
+ sessionKey: "agent:ops:main",
700
+ ctx: {
701
+ SessionKey: "agent:ops:main",
702
+ AccountId: "ops",
703
+ ChatType: "direct",
704
+ Provider: "matrix",
705
+ Surface: "matrix",
706
+ From: "matrix:@user:example.org",
707
+ To: "room:!other:example.org",
708
+ NativeChannelId: "!other:example.org",
709
+ OriginatingChannel: "matrix",
710
+ OriginatingTo: "room:!other:example.org",
711
+ },
712
+ });
713
+
714
+ const { handler } = createMatrixHandlerTestHarness({
715
+ isDirectMessage: true,
716
+ resolveStorePath: () => storePath,
717
+ client: {
718
+ sendMessage: sendNotice,
719
+ },
720
+ });
721
+
722
+ await handler(
723
+ "!dm:example.org",
724
+ createMatrixTextMessageEvent({
725
+ eventId: "$dm1",
726
+ body: "follow up",
727
+ }),
728
+ );
729
+
730
+ expect(sendNotice).toHaveBeenCalledWith(
731
+ "!dm:example.org",
732
+ expect.objectContaining({
733
+ msgtype: "m.notice",
734
+ body: expect.stringContaining("channels.lobi.dm.sessionScope"),
735
+ }),
736
+ );
737
+
738
+ await handler(
739
+ "!dm:example.org",
740
+ createMatrixTextMessageEvent({
741
+ eventId: "$dm2",
742
+ body: "again",
743
+ }),
744
+ );
745
+
746
+ expect(sendNotice).toHaveBeenCalledTimes(1);
747
+ } finally {
748
+ fs.rmSync(tempDir, { recursive: true, force: true });
749
+ }
750
+ });
751
+
752
+ it("checks flat DM collision notices against the current DM session key", async () => {
753
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-flat-notice-"));
754
+ const storePath = path.join(tempDir, "sessions.json");
755
+ const sendNotice = vi.fn(async () => "$notice");
756
+
757
+ try {
758
+ await recordSessionMetaFromInbound({
759
+ storePath,
760
+ sessionKey: "agent:ops:matrix:direct:@user:example.org",
761
+ ctx: {
762
+ SessionKey: "agent:ops:matrix:direct:@user:example.org",
763
+ AccountId: "ops",
764
+ ChatType: "direct",
765
+ Provider: "matrix",
766
+ Surface: "matrix",
767
+ From: "matrix:@user:example.org",
768
+ To: "room:!other:example.org",
769
+ NativeChannelId: "!other:example.org",
770
+ OriginatingChannel: "matrix",
771
+ OriginatingTo: "room:!other:example.org",
772
+ },
773
+ });
774
+
775
+ const { handler } = createMatrixHandlerTestHarness({
776
+ isDirectMessage: true,
777
+ resolveStorePath: () => storePath,
778
+ resolveAgentRoute: () => ({
779
+ agentId: "ops",
780
+ channel: "matrix",
781
+ accountId: "ops",
782
+ sessionKey: "agent:ops:matrix:direct:@user:example.org",
783
+ mainSessionKey: "agent:ops:main",
784
+ matchedBy: "binding.account" as const,
785
+ }),
786
+ client: {
787
+ sendMessage: sendNotice,
788
+ },
789
+ });
790
+
791
+ await handler(
792
+ "!dm:example.org",
793
+ createMatrixTextMessageEvent({
794
+ eventId: "$dm-flat-1",
795
+ body: "follow up",
796
+ }),
797
+ );
798
+
799
+ expect(sendNotice).toHaveBeenCalledWith(
800
+ "!dm:example.org",
801
+ expect.objectContaining({
802
+ msgtype: "m.notice",
803
+ body: expect.stringContaining("channels.lobi.dm.sessionScope"),
804
+ }),
805
+ );
806
+ } finally {
807
+ fs.rmSync(tempDir, { recursive: true, force: true });
808
+ }
809
+ });
810
+
811
+ it("checks threaded DM collision notices against the parent DM session", async () => {
812
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-thread-notice-"));
813
+ const storePath = path.join(tempDir, "sessions.json");
814
+ const sendNotice = vi.fn(async () => "$notice");
815
+
816
+ try {
817
+ await recordSessionMetaFromInbound({
818
+ storePath,
819
+ sessionKey: "agent:ops:main",
820
+ ctx: {
821
+ SessionKey: "agent:ops:main",
822
+ AccountId: "ops",
823
+ ChatType: "direct",
824
+ Provider: "matrix",
825
+ Surface: "matrix",
826
+ From: "matrix:@user:example.org",
827
+ To: "room:!other:example.org",
828
+ NativeChannelId: "!other:example.org",
829
+ OriginatingChannel: "matrix",
830
+ OriginatingTo: "room:!other:example.org",
831
+ },
832
+ });
833
+
834
+ const { handler } = createMatrixHandlerTestHarness({
835
+ isDirectMessage: true,
836
+ threadReplies: "always",
837
+ resolveStorePath: () => storePath,
838
+ client: {
839
+ sendMessage: sendNotice,
840
+ getEvent: async (_roomId, eventId) =>
841
+ eventId === "$root"
842
+ ? createMatrixTextMessageEvent({
843
+ eventId: "$root",
844
+ sender: "@alice:example.org",
845
+ body: "Root topic",
846
+ })
847
+ : ({ sender: "@bot:example.org" } as never),
848
+ },
849
+ getMemberDisplayName: async (_roomId, userId) =>
850
+ userId === "@alice:example.org" ? "Alice" : "sender",
851
+ });
852
+
853
+ await handler(
854
+ "!dm:example.org",
855
+ createMatrixTextMessageEvent({
856
+ eventId: "$reply1",
857
+ body: "follow up",
858
+ relatesTo: {
859
+ rel_type: "m.thread",
860
+ event_id: "$root",
861
+ "m.in_reply_to": { event_id: "$root" },
862
+ },
863
+ }),
864
+ );
865
+
866
+ expect(sendNotice).toHaveBeenCalledWith(
867
+ "!dm:example.org",
868
+ expect.objectContaining({
869
+ msgtype: "m.notice",
870
+ body: expect.stringContaining("channels.lobi.dm.sessionScope"),
871
+ }),
872
+ );
873
+ } finally {
874
+ fs.rmSync(tempDir, { recursive: true, force: true });
875
+ }
876
+ });
877
+
878
+ it("keeps the shared-session notice after user-target outbound metadata overwrites latest room fields", async () => {
879
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-shared-notice-stable-"));
880
+ const storePath = path.join(tempDir, "sessions.json");
881
+ const sendNotice = vi.fn(async () => "$notice");
882
+
883
+ try {
884
+ await recordSessionMetaFromInbound({
885
+ storePath,
886
+ sessionKey: "agent:ops:main",
887
+ ctx: {
888
+ SessionKey: "agent:ops:main",
889
+ AccountId: "ops",
890
+ ChatType: "direct",
891
+ Provider: "matrix",
892
+ Surface: "matrix",
893
+ From: "matrix:@user:example.org",
894
+ To: "room:!other:example.org",
895
+ NativeChannelId: "!other:example.org",
896
+ OriginatingChannel: "matrix",
897
+ OriginatingTo: "room:!other:example.org",
898
+ },
899
+ });
900
+ await recordSessionMetaFromInbound({
901
+ storePath,
902
+ sessionKey: "agent:ops:main",
903
+ ctx: {
904
+ SessionKey: "agent:ops:main",
905
+ AccountId: "ops",
906
+ ChatType: "direct",
907
+ Provider: "matrix",
908
+ Surface: "matrix",
909
+ From: "matrix:@other:example.org",
910
+ To: "room:@other:example.org",
911
+ NativeDirectUserId: "@user:example.org",
912
+ OriginatingChannel: "matrix",
913
+ OriginatingTo: "room:@other:example.org",
914
+ },
915
+ });
916
+
917
+ const { handler } = createMatrixHandlerTestHarness({
918
+ isDirectMessage: true,
919
+ resolveStorePath: () => storePath,
920
+ client: {
921
+ sendMessage: sendNotice,
922
+ },
923
+ });
924
+
925
+ await handler(
926
+ "!dm:example.org",
927
+ createMatrixTextMessageEvent({
928
+ eventId: "$dm1",
929
+ body: "follow up",
930
+ }),
931
+ );
932
+
933
+ expect(sendNotice).toHaveBeenCalledWith(
934
+ "!dm:example.org",
935
+ expect.objectContaining({
936
+ msgtype: "m.notice",
937
+ body: expect.stringContaining("channels.lobi.dm.sessionScope"),
938
+ }),
939
+ );
940
+ } finally {
941
+ fs.rmSync(tempDir, { recursive: true, force: true });
942
+ }
943
+ });
944
+
945
+ it("skips the shared-session notice when the prior Matrix session metadata is not a DM", async () => {
946
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-shared-notice-room-"));
947
+ const storePath = path.join(tempDir, "sessions.json");
948
+ const sendNotice = vi.fn(async () => "$notice");
949
+
950
+ try {
951
+ await recordSessionMetaFromInbound({
952
+ storePath,
953
+ sessionKey: "agent:ops:main",
954
+ ctx: {
955
+ SessionKey: "agent:ops:main",
956
+ AccountId: "ops",
957
+ ChatType: "group",
958
+ Provider: "matrix",
959
+ Surface: "matrix",
960
+ From: "matrix:channel:!group:example.org",
961
+ To: "room:!group:example.org",
962
+ NativeChannelId: "!group:example.org",
963
+ OriginatingChannel: "matrix",
964
+ OriginatingTo: "room:!group:example.org",
965
+ },
966
+ });
967
+
968
+ const { handler } = createMatrixHandlerTestHarness({
969
+ isDirectMessage: true,
970
+ resolveStorePath: () => storePath,
971
+ client: {
972
+ sendMessage: sendNotice,
973
+ },
974
+ });
975
+
976
+ await handler(
977
+ "!dm:example.org",
978
+ createMatrixTextMessageEvent({
979
+ eventId: "$dm1",
980
+ body: "follow up",
981
+ }),
982
+ );
983
+
984
+ expect(sendNotice).not.toHaveBeenCalled();
985
+ } finally {
986
+ fs.rmSync(tempDir, { recursive: true, force: true });
987
+ }
988
+ });
989
+
990
+ it("skips the shared-session notice when Matrix DMs are isolated per room", async () => {
991
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-room-scope-"));
992
+ const storePath = path.join(tempDir, "sessions.json");
993
+ fs.writeFileSync(
994
+ storePath,
995
+ JSON.stringify({
996
+ "agent:ops:main": {
997
+ sessionId: "sess-main",
998
+ updatedAt: Date.now(),
999
+ deliveryContext: {
1000
+ channel: "matrix",
1001
+ to: "room:!other:example.org",
1002
+ accountId: "ops",
1003
+ },
1004
+ },
1005
+ }),
1006
+ "utf8",
1007
+ );
1008
+ const sendNotice = vi.fn(async () => "$notice");
1009
+
1010
+ try {
1011
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
1012
+ isDirectMessage: true,
1013
+ dmSessionScope: "per-room",
1014
+ resolveStorePath: () => storePath,
1015
+ client: {
1016
+ sendMessage: sendNotice,
1017
+ },
1018
+ });
1019
+
1020
+ await handler(
1021
+ "!dm:example.org",
1022
+ createMatrixTextMessageEvent({
1023
+ eventId: "$dm1",
1024
+ body: "follow up",
1025
+ }),
1026
+ );
1027
+
1028
+ expect(sendNotice).not.toHaveBeenCalled();
1029
+ expect(recordInboundSession).toHaveBeenCalledWith(
1030
+ expect.objectContaining({
1031
+ sessionKey: "agent:ops:matrix:channel:!dm:example.org",
1032
+ }),
1033
+ );
1034
+ } finally {
1035
+ fs.rmSync(tempDir, { recursive: true, force: true });
1036
+ }
1037
+ });
1038
+
1039
+ it("skips the shared-session notice when a Matrix DM is explicitly bound", async () => {
1040
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-dm-bound-notice-"));
1041
+ const storePath = path.join(tempDir, "sessions.json");
1042
+ fs.writeFileSync(
1043
+ storePath,
1044
+ JSON.stringify({
1045
+ "agent:bound:session-1": {
1046
+ sessionId: "sess-bound",
1047
+ updatedAt: Date.now(),
1048
+ deliveryContext: {
1049
+ channel: "matrix",
1050
+ to: "room:!other:example.org",
1051
+ accountId: "ops",
1052
+ },
1053
+ },
1054
+ }),
1055
+ "utf8",
1056
+ );
1057
+ const sendNotice = vi.fn(async () => "$notice");
1058
+ const touch = vi.fn();
1059
+ registerSessionBindingAdapter({
1060
+ channel: "matrix",
1061
+ accountId: "ops",
1062
+ listBySession: () => [],
1063
+ resolveByConversation: (ref) =>
1064
+ ref.conversationId === "!dm:example.org"
1065
+ ? {
1066
+ bindingId: "ops:!dm:example.org",
1067
+ targetSessionKey: "agent:bound:session-1",
1068
+ targetKind: "session",
1069
+ conversation: {
1070
+ channel: "matrix",
1071
+ accountId: "ops",
1072
+ conversationId: "!dm:example.org",
1073
+ },
1074
+ status: "active",
1075
+ boundAt: Date.now(),
1076
+ metadata: {
1077
+ boundBy: "user-1",
1078
+ },
1079
+ }
1080
+ : null,
1081
+ touch,
1082
+ });
1083
+
1084
+ try {
1085
+ const { handler } = createMatrixHandlerTestHarness({
1086
+ isDirectMessage: true,
1087
+ resolveStorePath: () => storePath,
1088
+ client: {
1089
+ sendMessage: sendNotice,
1090
+ },
1091
+ });
1092
+
1093
+ await handler(
1094
+ "!dm:example.org",
1095
+ createMatrixTextMessageEvent({
1096
+ eventId: "$dm-bound-1",
1097
+ body: "follow up",
1098
+ }),
1099
+ );
1100
+
1101
+ expect(sendNotice).not.toHaveBeenCalled();
1102
+ expect(touch).toHaveBeenCalledOnce();
1103
+ } finally {
1104
+ fs.rmSync(tempDir, { recursive: true, force: true });
1105
+ }
1106
+ });
1107
+
1108
+ it("uses stable room ids instead of room-declared aliases in group context", async () => {
1109
+ const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
1110
+ isDirectMessage: false,
1111
+ getRoomInfo: async () => ({
1112
+ name: "Ops Room",
1113
+ canonicalAlias: "#spoofed:example.org",
1114
+ altAliases: ["#alt:example.org"],
1115
+ }),
1116
+ getMemberDisplayName: async () => "sender",
1117
+ dispatchReplyFromConfig: async () => ({
1118
+ queuedFinal: false,
1119
+ counts: { final: 0, block: 0, tool: 0 },
1120
+ }),
1121
+ });
1122
+
1123
+ await handler(
1124
+ "!room:example.org",
1125
+ createMatrixTextMessageEvent({
1126
+ eventId: "$group1",
1127
+ body: "@room hello",
1128
+ mentions: { room: true },
1129
+ }),
1130
+ );
1131
+
1132
+ const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0];
1133
+ expect(finalized).toEqual(
1134
+ expect.objectContaining({
1135
+ GroupSubject: "Ops Room",
1136
+ GroupId: "!room:example.org",
1137
+ }),
1138
+ );
1139
+ expect(finalized).not.toHaveProperty("GroupChannel");
1140
+ });
1141
+
1142
+ it("routes bound Matrix threads to the target session key", async () => {
1143
+ const touch = vi.fn();
1144
+ registerSessionBindingAdapter({
1145
+ channel: "matrix",
1146
+ accountId: "ops",
1147
+ listBySession: () => [],
1148
+ resolveByConversation: (ref) =>
1149
+ ref.conversationId === "$root"
1150
+ ? {
1151
+ bindingId: "ops:!room:example:$root",
1152
+ targetSessionKey: "agent:bound:session-1",
1153
+ targetKind: "session",
1154
+ conversation: {
1155
+ channel: "matrix",
1156
+ accountId: "ops",
1157
+ conversationId: "$root",
1158
+ parentConversationId: "!room:example",
1159
+ },
1160
+ status: "active",
1161
+ boundAt: Date.now(),
1162
+ metadata: {
1163
+ boundBy: "user-1",
1164
+ },
1165
+ }
1166
+ : null,
1167
+ touch,
1168
+ });
1169
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
1170
+ client: {
1171
+ getEvent: async () =>
1172
+ createMatrixTextMessageEvent({
1173
+ eventId: "$root",
1174
+ sender: "@alice:example.org",
1175
+ body: "Root topic",
1176
+ }),
1177
+ },
1178
+ isDirectMessage: false,
1179
+ finalizeInboundContext: (ctx: unknown) => ctx,
1180
+ getMemberDisplayName: async () => "sender",
1181
+ });
1182
+
1183
+ await handler(
1184
+ "!room:example",
1185
+ createMatrixTextMessageEvent({
1186
+ eventId: "$reply1",
1187
+ body: "@room follow up",
1188
+ relatesTo: {
1189
+ rel_type: "m.thread",
1190
+ event_id: "$root",
1191
+ "m.in_reply_to": { event_id: "$root" },
1192
+ },
1193
+ mentions: { room: true },
1194
+ }),
1195
+ );
1196
+
1197
+ expect(recordInboundSession).toHaveBeenCalledWith(
1198
+ expect.objectContaining({
1199
+ sessionKey: "agent:bound:session-1",
1200
+ }),
1201
+ );
1202
+ expect(touch).toHaveBeenCalledTimes(1);
1203
+ });
1204
+
1205
+ it("does not refresh bound Matrix thread bindings for room messages dropped before routing", async () => {
1206
+ const touch = vi.fn();
1207
+ registerSessionBindingAdapter({
1208
+ channel: "matrix",
1209
+ accountId: "ops",
1210
+ listBySession: () => [],
1211
+ resolveByConversation: (ref) =>
1212
+ ref.conversationId === "$root"
1213
+ ? {
1214
+ bindingId: "ops:!room:example:$root",
1215
+ targetSessionKey: "agent:bound:session-1",
1216
+ targetKind: "session",
1217
+ conversation: {
1218
+ channel: "matrix",
1219
+ accountId: "ops",
1220
+ conversationId: "$root",
1221
+ parentConversationId: "!room:example",
1222
+ },
1223
+ status: "active",
1224
+ boundAt: Date.now(),
1225
+ metadata: {
1226
+ boundBy: "user-1",
1227
+ },
1228
+ }
1229
+ : null,
1230
+ touch,
1231
+ });
1232
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
1233
+ client: {
1234
+ getEvent: async () =>
1235
+ createMatrixTextMessageEvent({
1236
+ eventId: "$root",
1237
+ sender: "@alice:example.org",
1238
+ body: "Root topic",
1239
+ }),
1240
+ },
1241
+ isDirectMessage: false,
1242
+ getMemberDisplayName: async () => "sender",
1243
+ });
1244
+
1245
+ await handler(
1246
+ "!room:example",
1247
+ createMatrixTextMessageEvent({
1248
+ eventId: "$reply-no-mention",
1249
+ body: "follow up without mention",
1250
+ relatesTo: {
1251
+ rel_type: "m.thread",
1252
+ event_id: "$root",
1253
+ "m.in_reply_to": { event_id: "$root" },
1254
+ },
1255
+ }),
1256
+ );
1257
+
1258
+ expect(recordInboundSession).not.toHaveBeenCalled();
1259
+ expect(touch).not.toHaveBeenCalled();
1260
+ });
1261
+
1262
+ it("does not enqueue system events for delivered text replies", async () => {
1263
+ const enqueueSystemEvent = vi.fn();
1264
+
1265
+ const handler = createMatrixRoomMessageHandler({
1266
+ client: {
1267
+ getUserId: async () => "@bot:example.org",
1268
+ } as never,
1269
+ core: {
1270
+ channel: {
1271
+ pairing: {
1272
+ readAllowFromStore: async () => [] as string[],
1273
+ upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
1274
+ buildPairingReply: () => "pairing",
1275
+ },
1276
+ commands: {
1277
+ shouldHandleTextCommands: () => false,
1278
+ },
1279
+ text: {
1280
+ hasControlCommand: () => false,
1281
+ resolveMarkdownTableMode: () => "preserve",
1282
+ },
1283
+ routing: {
1284
+ resolveAgentRoute: () => ({
1285
+ agentId: "ops",
1286
+ channel: "matrix",
1287
+ accountId: "ops",
1288
+ sessionKey: "agent:ops:main",
1289
+ mainSessionKey: "agent:ops:main",
1290
+ matchedBy: "binding.account",
1291
+ }),
1292
+ },
1293
+ mentions: {
1294
+ buildMentionRegexes: () => [],
1295
+ },
1296
+ session: {
1297
+ resolveStorePath: () => "/tmp/session-store",
1298
+ readSessionUpdatedAt: () => undefined,
1299
+ recordInboundSession: vi.fn(async () => {}),
1300
+ },
1301
+ reply: {
1302
+ resolveEnvelopeFormatOptions: () => ({}),
1303
+ formatAgentEnvelope: ({ body }: { body: string }) => body,
1304
+ finalizeInboundContext: (ctx: unknown) => ctx,
1305
+ createReplyDispatcherWithTyping: () => ({
1306
+ dispatcher: {},
1307
+ replyOptions: {},
1308
+ markDispatchIdle: () => {},
1309
+ markRunComplete: () => {},
1310
+ }),
1311
+ resolveHumanDelayConfig: () => undefined,
1312
+ dispatchReplyFromConfig: async () => ({
1313
+ queuedFinal: true,
1314
+ counts: { final: 1, block: 0, tool: 0 },
1315
+ }),
1316
+ withReplyDispatcher: async <T>({
1317
+ dispatcher,
1318
+ run,
1319
+ onSettled,
1320
+ }: {
1321
+ dispatcher: {
1322
+ markComplete?: () => void;
1323
+ waitForIdle?: () => Promise<void>;
1324
+ };
1325
+ run: () => Promise<T>;
1326
+ onSettled?: () => void | Promise<void>;
1327
+ }) => {
1328
+ try {
1329
+ return await run();
1330
+ } finally {
1331
+ dispatcher.markComplete?.();
1332
+ try {
1333
+ await dispatcher.waitForIdle?.();
1334
+ } finally {
1335
+ await onSettled?.();
1336
+ }
1337
+ }
1338
+ },
1339
+ },
1340
+ reactions: {
1341
+ shouldAckReaction: () => false,
1342
+ },
1343
+ },
1344
+ system: {
1345
+ enqueueSystemEvent,
1346
+ },
1347
+ } as never,
1348
+ cfg: {} as never,
1349
+ accountId: "ops",
1350
+ runtime: {
1351
+ error: () => {},
1352
+ } as never,
1353
+ logger: {
1354
+ info: () => {},
1355
+ warn: () => {},
1356
+ } as never,
1357
+ logVerboseMessage: () => {},
1358
+ allowFrom: [],
1359
+ groupPolicy: "open",
1360
+ replyToMode: "off",
1361
+ threadReplies: "inbound",
1362
+ streaming: "off",
1363
+ blockStreamingEnabled: false,
1364
+ dmEnabled: true,
1365
+ dmPolicy: "open",
1366
+ textLimit: 8_000,
1367
+ mediaMaxBytes: 10_000_000,
1368
+ historyLimit: 0,
1369
+ startupMs: 0,
1370
+ startupGraceMs: 0,
1371
+ directTracker: {
1372
+ isDirectMessage: async () => false,
1373
+ },
1374
+ dropPreStartupMessages: true,
1375
+ getRoomInfo: async () => ({ altAliases: [] }),
1376
+ getMemberDisplayName: async () => "sender",
1377
+ needsRoomAliasesForConfig: false,
1378
+ });
1379
+
1380
+ await handler(
1381
+ "!room:example.org",
1382
+ createMatrixTextMessageEvent({
1383
+ eventId: "$message1",
1384
+ sender: "@user:example.org",
1385
+ body: "hello there",
1386
+ mentions: { room: true },
1387
+ }),
1388
+ );
1389
+
1390
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
1391
+ });
1392
+
1393
+ it("enqueues system events for reactions on bot-authored messages", async () => {
1394
+ const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness();
1395
+
1396
+ await handler(
1397
+ "!room:example.org",
1398
+ createMatrixReactionEvent({
1399
+ eventId: "$reaction1",
1400
+ targetEventId: "$msg1",
1401
+ key: "👍",
1402
+ }),
1403
+ );
1404
+
1405
+ expect(resolveAgentRoute).toHaveBeenCalledWith(
1406
+ expect.objectContaining({
1407
+ channel: "matrix",
1408
+ accountId: "ops",
1409
+ }),
1410
+ );
1411
+ expect(enqueueSystemEvent).toHaveBeenCalledWith(
1412
+ "Matrix reaction added: 👍 by sender on msg $msg1",
1413
+ {
1414
+ sessionKey: "agent:ops:main",
1415
+ contextKey: "matrix:reaction:add:!room:example.org:$msg1:@user:example.org:👍",
1416
+ },
1417
+ );
1418
+ });
1419
+
1420
+ it("routes reaction notifications for bound thread messages to the bound session", async () => {
1421
+ registerSessionBindingAdapter({
1422
+ channel: "matrix",
1423
+ accountId: "ops",
1424
+ listBySession: () => [],
1425
+ resolveByConversation: (ref) =>
1426
+ ref.conversationId === "$root"
1427
+ ? {
1428
+ bindingId: "ops:!room:example.org:$root",
1429
+ targetSessionKey: "agent:bound:session-1",
1430
+ targetKind: "session",
1431
+ conversation: {
1432
+ channel: "matrix",
1433
+ accountId: "ops",
1434
+ conversationId: "$root",
1435
+ parentConversationId: "!room:example.org",
1436
+ },
1437
+ status: "active",
1438
+ boundAt: Date.now(),
1439
+ metadata: {
1440
+ boundBy: "user-1",
1441
+ },
1442
+ }
1443
+ : null,
1444
+ touch: vi.fn(),
1445
+ });
1446
+
1447
+ const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({
1448
+ client: {
1449
+ getEvent: async () =>
1450
+ createMatrixTextMessageEvent({
1451
+ eventId: "$reply1",
1452
+ sender: "@bot:example.org",
1453
+ body: "follow up",
1454
+ relatesTo: {
1455
+ rel_type: "m.thread",
1456
+ event_id: "$root",
1457
+ "m.in_reply_to": { event_id: "$root" },
1458
+ },
1459
+ }),
1460
+ },
1461
+ isDirectMessage: false,
1462
+ });
1463
+
1464
+ await handler(
1465
+ "!room:example.org",
1466
+ createMatrixReactionEvent({
1467
+ eventId: "$reaction-thread",
1468
+ targetEventId: "$reply1",
1469
+ key: "🎯",
1470
+ }),
1471
+ );
1472
+
1473
+ expect(enqueueSystemEvent).toHaveBeenCalledWith(
1474
+ "Matrix reaction added: 🎯 by sender on msg $reply1",
1475
+ {
1476
+ sessionKey: "agent:bound:session-1",
1477
+ contextKey: "matrix:reaction:add:!room:example.org:$reply1:@user:example.org:🎯",
1478
+ },
1479
+ );
1480
+ });
1481
+
1482
+ it("keeps threaded DM reaction notifications on the flat session when dm threadReplies is off", async () => {
1483
+ const { handler, enqueueSystemEvent } = createReactionHarness({
1484
+ cfg: {
1485
+ channels: {
1486
+ matrix: {
1487
+ threadReplies: "always",
1488
+ dm: { threadReplies: "off" },
1489
+ },
1490
+ },
1491
+ },
1492
+ isDirectMessage: true,
1493
+ client: {
1494
+ getEvent: async () =>
1495
+ createMatrixTextMessageEvent({
1496
+ eventId: "$reply1",
1497
+ sender: "@bot:example.org",
1498
+ body: "follow up",
1499
+ relatesTo: {
1500
+ rel_type: "m.thread",
1501
+ event_id: "$root",
1502
+ "m.in_reply_to": { event_id: "$root" },
1503
+ },
1504
+ }),
1505
+ },
1506
+ });
1507
+
1508
+ await handler(
1509
+ "!dm:example.org",
1510
+ createMatrixReactionEvent({
1511
+ eventId: "$reaction-thread",
1512
+ targetEventId: "$reply1",
1513
+ key: "🎯",
1514
+ }),
1515
+ );
1516
+
1517
+ expect(enqueueSystemEvent).toHaveBeenCalledWith(
1518
+ "Matrix reaction added: 🎯 by sender on msg $reply1",
1519
+ {
1520
+ sessionKey: "agent:ops:main",
1521
+ contextKey: "matrix:reaction:add:!dm:example.org:$reply1:@user:example.org:🎯",
1522
+ },
1523
+ );
1524
+ });
1525
+
1526
+ it("routes thread-root reaction notifications to the thread session when threadReplies is always", async () => {
1527
+ const { handler, enqueueSystemEvent } = createReactionHarness({
1528
+ cfg: {
1529
+ channels: {
1530
+ matrix: {
1531
+ threadReplies: "always",
1532
+ },
1533
+ },
1534
+ },
1535
+ isDirectMessage: false,
1536
+ client: {
1537
+ getEvent: async () =>
1538
+ createMatrixTextMessageEvent({
1539
+ eventId: "$root",
1540
+ sender: "@bot:example.org",
1541
+ body: "start thread",
1542
+ }),
1543
+ },
1544
+ });
1545
+
1546
+ await handler(
1547
+ "!room:example.org",
1548
+ createMatrixReactionEvent({
1549
+ eventId: "$reaction-root",
1550
+ targetEventId: "$root",
1551
+ key: "🧵",
1552
+ }),
1553
+ );
1554
+
1555
+ expect(enqueueSystemEvent).toHaveBeenCalledWith(
1556
+ "Matrix reaction added: 🧵 by sender on msg $root",
1557
+ {
1558
+ sessionKey: "agent:ops:main:thread:$root",
1559
+ contextKey: "matrix:reaction:add:!room:example.org:$root:@user:example.org:🧵",
1560
+ },
1561
+ );
1562
+ });
1563
+
1564
+ it("ignores reactions that do not target bot-authored messages", async () => {
1565
+ const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness({
1566
+ targetSender: "@other:example.org",
1567
+ });
1568
+
1569
+ await handler(
1570
+ "!room:example.org",
1571
+ createMatrixReactionEvent({
1572
+ eventId: "$reaction2",
1573
+ targetEventId: "$msg2",
1574
+ key: "👀",
1575
+ }),
1576
+ );
1577
+
1578
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
1579
+ expect(resolveAgentRoute).not.toHaveBeenCalled();
1580
+ });
1581
+
1582
+ it("does not create pairing requests for unauthorized dm reactions", async () => {
1583
+ const { handler, enqueueSystemEvent, upsertPairingRequest } = createReactionHarness({
1584
+ dmPolicy: "pairing",
1585
+ });
1586
+
1587
+ await handler(
1588
+ "!room:example.org",
1589
+ createMatrixReactionEvent({
1590
+ eventId: "$reaction3",
1591
+ targetEventId: "$msg3",
1592
+ key: "🔥",
1593
+ }),
1594
+ );
1595
+
1596
+ expect(upsertPairingRequest).not.toHaveBeenCalled();
1597
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
1598
+ });
1599
+
1600
+ it("honors account-scoped reaction notification overrides", async () => {
1601
+ const { handler, enqueueSystemEvent } = createReactionHarness({
1602
+ cfg: {
1603
+ channels: {
1604
+ matrix: {
1605
+ reactionNotifications: "own",
1606
+ accounts: {
1607
+ ops: {
1608
+ reactionNotifications: "off",
1609
+ },
1610
+ },
1611
+ },
1612
+ },
1613
+ },
1614
+ });
1615
+
1616
+ await handler(
1617
+ "!room:example.org",
1618
+ createMatrixReactionEvent({
1619
+ eventId: "$reaction4",
1620
+ targetEventId: "$msg4",
1621
+ key: "✅",
1622
+ }),
1623
+ );
1624
+
1625
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
1626
+ });
1627
+
1628
+ it("drops pre-startup dm messages on cold start", async () => {
1629
+ const resolveAgentRoute = vi.fn(() => ({
1630
+ agentId: "ops",
1631
+ channel: "matrix",
1632
+ accountId: "ops",
1633
+ sessionKey: "agent:ops:main",
1634
+ mainSessionKey: "agent:ops:main",
1635
+ matchedBy: "binding.account" as const,
1636
+ }));
1637
+ const { handler } = createMatrixHandlerTestHarness({
1638
+ resolveAgentRoute,
1639
+ isDirectMessage: true,
1640
+ startupMs: 1_000,
1641
+ startupGraceMs: 0,
1642
+ dropPreStartupMessages: true,
1643
+ });
1644
+
1645
+ await handler(
1646
+ "!room:example.org",
1647
+ createMatrixTextMessageEvent({
1648
+ eventId: "$old-cold-start",
1649
+ body: "hello",
1650
+ originServerTs: 999,
1651
+ }),
1652
+ );
1653
+
1654
+ expect(resolveAgentRoute).not.toHaveBeenCalled();
1655
+ });
1656
+
1657
+ it("replays pre-startup dm messages when persisted sync state exists", async () => {
1658
+ const resolveAgentRoute = vi.fn(() => ({
1659
+ agentId: "ops",
1660
+ channel: "matrix",
1661
+ accountId: "ops",
1662
+ sessionKey: "agent:ops:main",
1663
+ mainSessionKey: "agent:ops:main",
1664
+ matchedBy: "binding.account" as const,
1665
+ }));
1666
+ const { handler } = createMatrixHandlerTestHarness({
1667
+ resolveAgentRoute,
1668
+ isDirectMessage: true,
1669
+ startupMs: 1_000,
1670
+ startupGraceMs: 0,
1671
+ dropPreStartupMessages: false,
1672
+ });
1673
+
1674
+ await handler(
1675
+ "!room:example.org",
1676
+ createMatrixTextMessageEvent({
1677
+ eventId: "$old-resume",
1678
+ body: "hello",
1679
+ originServerTs: 999,
1680
+ }),
1681
+ );
1682
+
1683
+ expect(resolveAgentRoute).toHaveBeenCalledTimes(1);
1684
+ });
1685
+ });
1686
+
1687
+ describe("matrix monitor handler durable inbound dedupe", () => {
1688
+ it("skips replayed inbound events before session recording", async () => {
1689
+ const inboundDeduper = {
1690
+ claimEvent: vi.fn(() => false),
1691
+ commitEvent: vi.fn(async () => undefined),
1692
+ releaseEvent: vi.fn(),
1693
+ };
1694
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
1695
+ inboundDeduper,
1696
+ dispatchReplyFromConfig: vi.fn(async () => ({
1697
+ queuedFinal: true,
1698
+ counts: { final: 1, block: 0, tool: 0 },
1699
+ })),
1700
+ });
1701
+
1702
+ await handler(
1703
+ "!room:example.org",
1704
+ createMatrixTextMessageEvent({
1705
+ eventId: "$dup",
1706
+ body: "hello",
1707
+ }),
1708
+ );
1709
+
1710
+ expect(inboundDeduper.claimEvent).toHaveBeenCalledWith({
1711
+ roomId: "!room:example.org",
1712
+ eventId: "$dup",
1713
+ });
1714
+ expect(recordInboundSession).not.toHaveBeenCalled();
1715
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
1716
+ expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled();
1717
+ });
1718
+
1719
+ it("commits inbound events only after queued replies finish delivering", async () => {
1720
+ const callOrder: string[] = [];
1721
+ const inboundDeduper = {
1722
+ claimEvent: vi.fn(() => {
1723
+ callOrder.push("claim");
1724
+ return true;
1725
+ }),
1726
+ commitEvent: vi.fn(async () => {
1727
+ callOrder.push("commit");
1728
+ }),
1729
+ releaseEvent: vi.fn(() => {
1730
+ callOrder.push("release");
1731
+ }),
1732
+ };
1733
+ const recordInboundSession = vi.fn(async () => {
1734
+ callOrder.push("record");
1735
+ });
1736
+ const dispatchReplyFromConfig = vi.fn(async () => {
1737
+ callOrder.push("dispatch");
1738
+ return {
1739
+ queuedFinal: true,
1740
+ counts: { final: 1, block: 0, tool: 0 },
1741
+ };
1742
+ });
1743
+ const { handler } = createMatrixHandlerTestHarness({
1744
+ inboundDeduper,
1745
+ recordInboundSession,
1746
+ dispatchReplyFromConfig,
1747
+ createReplyDispatcherWithTyping: () => ({
1748
+ dispatcher: {
1749
+ markComplete: () => {
1750
+ callOrder.push("mark-complete");
1751
+ },
1752
+ waitForIdle: async () => {
1753
+ callOrder.push("wait-for-idle");
1754
+ },
1755
+ },
1756
+ replyOptions: {},
1757
+ markDispatchIdle: () => {
1758
+ callOrder.push("dispatch-idle");
1759
+ },
1760
+ markRunComplete: () => {
1761
+ callOrder.push("run-complete");
1762
+ },
1763
+ }),
1764
+ });
1765
+
1766
+ await handler(
1767
+ "!room:example.org",
1768
+ createMatrixTextMessageEvent({
1769
+ eventId: "$commit-order",
1770
+ body: "hello",
1771
+ }),
1772
+ );
1773
+
1774
+ expect(callOrder).toEqual([
1775
+ "claim",
1776
+ "record",
1777
+ "dispatch",
1778
+ "run-complete",
1779
+ "mark-complete",
1780
+ "wait-for-idle",
1781
+ "dispatch-idle",
1782
+ "commit",
1783
+ ]);
1784
+ expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled();
1785
+ });
1786
+
1787
+ it("releases a claimed event when reply dispatch fails before completion", async () => {
1788
+ const inboundDeduper = {
1789
+ claimEvent: vi.fn(() => true),
1790
+ commitEvent: vi.fn(async () => undefined),
1791
+ releaseEvent: vi.fn(),
1792
+ };
1793
+ const runtime = {
1794
+ error: vi.fn(),
1795
+ };
1796
+ const { handler } = createMatrixHandlerTestHarness({
1797
+ inboundDeduper,
1798
+ runtime: runtime as never,
1799
+ recordInboundSession: vi.fn(async () => {
1800
+ throw new Error("disk failed");
1801
+ }),
1802
+ dispatchReplyFromConfig: vi.fn(async () => ({
1803
+ queuedFinal: true,
1804
+ counts: { final: 1, block: 0, tool: 0 },
1805
+ })),
1806
+ });
1807
+
1808
+ await handler(
1809
+ "!room:example.org",
1810
+ createMatrixTextMessageEvent({
1811
+ eventId: "$release-on-error",
1812
+ body: "hello",
1813
+ }),
1814
+ );
1815
+
1816
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
1817
+ expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({
1818
+ roomId: "!room:example.org",
1819
+ eventId: "$release-on-error",
1820
+ });
1821
+ expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("matrix handler failed"));
1822
+ });
1823
+
1824
+ it("releases a claimed event when queued final delivery fails", async () => {
1825
+ const inboundDeduper = {
1826
+ claimEvent: vi.fn(() => true),
1827
+ commitEvent: vi.fn(async () => undefined),
1828
+ releaseEvent: vi.fn(),
1829
+ };
1830
+ const runtime = {
1831
+ error: vi.fn(),
1832
+ };
1833
+ const { handler } = createMatrixHandlerTestHarness({
1834
+ inboundDeduper,
1835
+ runtime: runtime as never,
1836
+ dispatchReplyFromConfig: vi.fn(async () => ({
1837
+ queuedFinal: true,
1838
+ counts: { final: 1, block: 0, tool: 0 },
1839
+ })),
1840
+ createReplyDispatcherWithTyping: (params) => ({
1841
+ dispatcher: {
1842
+ markComplete: () => {},
1843
+ waitForIdle: async () => {
1844
+ params?.onError?.(new Error("send failed"), { kind: "final" });
1845
+ },
1846
+ },
1847
+ replyOptions: {},
1848
+ markDispatchIdle: () => {},
1849
+ markRunComplete: () => {},
1850
+ }),
1851
+ });
1852
+
1853
+ await handler(
1854
+ "!room:example.org",
1855
+ createMatrixTextMessageEvent({
1856
+ eventId: "$release-on-final-delivery-error",
1857
+ body: "hello",
1858
+ }),
1859
+ );
1860
+
1861
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
1862
+ expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({
1863
+ roomId: "!room:example.org",
1864
+ eventId: "$release-on-final-delivery-error",
1865
+ });
1866
+ expect(runtime.error).toHaveBeenCalledWith(
1867
+ expect.stringContaining("matrix final reply failed"),
1868
+ );
1869
+ });
1870
+
1871
+ it.each(["tool", "block"] as const)(
1872
+ "releases a claimed event when queued %s delivery fails and no final reply exists",
1873
+ async (kind) => {
1874
+ const inboundDeduper = {
1875
+ claimEvent: vi.fn(() => true),
1876
+ commitEvent: vi.fn(async () => undefined),
1877
+ releaseEvent: vi.fn(),
1878
+ };
1879
+ const runtime = {
1880
+ error: vi.fn(),
1881
+ };
1882
+ const { handler } = createMatrixHandlerTestHarness({
1883
+ inboundDeduper,
1884
+ runtime: runtime as never,
1885
+ dispatchReplyFromConfig: vi.fn(async () => ({
1886
+ queuedFinal: false,
1887
+ counts: {
1888
+ final: 0,
1889
+ block: kind === "block" ? 1 : 0,
1890
+ tool: kind === "tool" ? 1 : 0,
1891
+ },
1892
+ })),
1893
+ createReplyDispatcherWithTyping: (params) => ({
1894
+ dispatcher: {
1895
+ markComplete: () => {},
1896
+ waitForIdle: async () => {
1897
+ params?.onError?.(new Error("send failed"), { kind });
1898
+ },
1899
+ },
1900
+ replyOptions: {},
1901
+ markDispatchIdle: () => {},
1902
+ markRunComplete: () => {},
1903
+ }),
1904
+ });
1905
+
1906
+ await handler(
1907
+ "!room:example.org",
1908
+ createMatrixTextMessageEvent({
1909
+ eventId: `$release-on-${kind}-delivery-error`,
1910
+ body: "hello",
1911
+ }),
1912
+ );
1913
+
1914
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
1915
+ expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({
1916
+ roomId: "!room:example.org",
1917
+ eventId: `$release-on-${kind}-delivery-error`,
1918
+ });
1919
+ expect(runtime.error).toHaveBeenCalledWith(
1920
+ expect.stringContaining(`matrix ${kind} reply failed`),
1921
+ );
1922
+ },
1923
+ );
1924
+
1925
+ it("commits a claimed event when dispatch completes without a final reply", async () => {
1926
+ const callOrder: string[] = [];
1927
+ const inboundDeduper = {
1928
+ claimEvent: vi.fn(() => {
1929
+ callOrder.push("claim");
1930
+ return true;
1931
+ }),
1932
+ commitEvent: vi.fn(async () => {
1933
+ callOrder.push("commit");
1934
+ }),
1935
+ releaseEvent: vi.fn(() => {
1936
+ callOrder.push("release");
1937
+ }),
1938
+ };
1939
+ const { handler } = createMatrixHandlerTestHarness({
1940
+ inboundDeduper,
1941
+ recordInboundSession: vi.fn(async () => {
1942
+ callOrder.push("record");
1943
+ }),
1944
+ dispatchReplyFromConfig: vi.fn(async () => {
1945
+ callOrder.push("dispatch");
1946
+ return {
1947
+ queuedFinal: false,
1948
+ counts: { final: 0, block: 0, tool: 0 },
1949
+ };
1950
+ }),
1951
+ });
1952
+
1953
+ await handler(
1954
+ "!room:example.org",
1955
+ createMatrixTextMessageEvent({
1956
+ eventId: "$no-final",
1957
+ body: "hello",
1958
+ }),
1959
+ );
1960
+
1961
+ expect(callOrder).toEqual(["claim", "record", "dispatch", "commit"]);
1962
+ expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled();
1963
+ });
1964
+ });
1965
+
1966
+ describe("matrix monitor handler draft streaming", () => {
1967
+ type DeliverFn = (
1968
+ payload: {
1969
+ text?: string;
1970
+ mediaUrl?: string;
1971
+ mediaUrls?: string[];
1972
+ isCompactionNotice?: boolean;
1973
+ replyToId?: string;
1974
+ },
1975
+ info: { kind: string },
1976
+ ) => Promise<void>;
1977
+ type ReplyOpts = {
1978
+ onPartialReply?: (payload: { text: string }) => void;
1979
+ onBlockReplyQueued?: (
1980
+ payload: {
1981
+ text?: string;
1982
+ isCompactionNotice?: boolean;
1983
+ },
1984
+ context?: { assistantMessageIndex?: number },
1985
+ ) => Promise<void> | void;
1986
+ onAssistantMessageStart?: () => void;
1987
+ disableBlockStreaming?: boolean;
1988
+ };
1989
+
1990
+ function createStreamingHarness(opts?: {
1991
+ replyToMode?: "off" | "first" | "all" | "batched";
1992
+ blockStreamingEnabled?: boolean;
1993
+ streaming?: "partial" | "quiet";
1994
+ }) {
1995
+ let capturedDeliver: DeliverFn | undefined;
1996
+ let capturedReplyOpts: ReplyOpts | undefined;
1997
+ // Gate that keeps the handler's model run alive until the test releases it.
1998
+ let resolveRunGate: (() => void) | undefined;
1999
+ const runGate = new Promise<void>((resolve) => {
2000
+ resolveRunGate = resolve;
2001
+ });
2002
+
2003
+ sendMessageMatrixMock.mockReset().mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
2004
+ sendSingleTextMessageMatrixMock
2005
+ .mockReset()
2006
+ .mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
2007
+ editMessageMatrixMock.mockReset().mockResolvedValue("$edited");
2008
+ deliverMatrixRepliesMock.mockReset().mockResolvedValue(true);
2009
+
2010
+ const redactEventMock = vi.fn(async () => "$redacted");
2011
+
2012
+ const { handler } = createMatrixHandlerTestHarness({
2013
+ streaming: opts?.streaming ?? "quiet",
2014
+ blockStreamingEnabled: opts?.blockStreamingEnabled ?? false,
2015
+ replyToMode: opts?.replyToMode ?? "off",
2016
+ client: { redactEvent: redactEventMock },
2017
+ createReplyDispatcherWithTyping: (params: Record<string, unknown> | undefined) => {
2018
+ capturedDeliver = params?.deliver as DeliverFn | undefined;
2019
+ return {
2020
+ dispatcher: {
2021
+ markComplete: () => {},
2022
+ waitForIdle: async () => {},
2023
+ },
2024
+ replyOptions: {},
2025
+ markDispatchIdle: () => {},
2026
+ markRunComplete: () => {},
2027
+ };
2028
+ },
2029
+ dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
2030
+ capturedReplyOpts = args?.replyOptions;
2031
+ // Block until the test is done exercising callbacks.
2032
+ await runGate;
2033
+ return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
2034
+ }) as never,
2035
+ withReplyDispatcher: async <T>(params: {
2036
+ dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
2037
+ run: () => Promise<T>;
2038
+ onSettled?: () => void | Promise<void>;
2039
+ }) => {
2040
+ const result = await params.run();
2041
+ await params.onSettled?.();
2042
+ return result;
2043
+ },
2044
+ });
2045
+
2046
+ const dispatch = async () => {
2047
+ // Start handler without awaiting — it blocks on runGate.
2048
+ const handlerDone = handler(
2049
+ "!room:example.org",
2050
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2051
+ );
2052
+ // Wait for callbacks to be captured.
2053
+ await vi.waitFor(() => {
2054
+ if (!capturedDeliver || !capturedReplyOpts) {
2055
+ throw new Error("Streaming callbacks not captured yet");
2056
+ }
2057
+ });
2058
+ return {
2059
+ deliver: capturedDeliver!,
2060
+ opts: capturedReplyOpts!,
2061
+ // Release the run gate and wait for the handler to finish
2062
+ // (including the finally block that stops the draft stream).
2063
+ finish: async () => {
2064
+ resolveRunGate?.();
2065
+ await handlerDone;
2066
+ },
2067
+ };
2068
+ };
2069
+
2070
+ return { dispatch, redactEventMock };
2071
+ }
2072
+
2073
+ it("finalizes a single quiet-preview block in place when block streaming is enabled", async () => {
2074
+ const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
2075
+ const { deliver, opts, finish } = await dispatch();
2076
+
2077
+ opts.onPartialReply?.({ text: "Single block" });
2078
+ await vi.waitFor(() => {
2079
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2080
+ });
2081
+
2082
+ deliverMatrixRepliesMock.mockClear();
2083
+ await deliver({ text: "Single block" }, { kind: "final" });
2084
+
2085
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2086
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2087
+ "!room:example.org",
2088
+ "$draft1",
2089
+ "Single block",
2090
+ expect.objectContaining({
2091
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
2092
+ }),
2093
+ );
2094
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2095
+ expect(redactEventMock).not.toHaveBeenCalled();
2096
+ await finish();
2097
+ });
2098
+
2099
+ it("keeps partial preview-first finalization on the existing draft when text is unchanged", async () => {
2100
+ const { dispatch, redactEventMock } = createStreamingHarness({
2101
+ blockStreamingEnabled: true,
2102
+ streaming: "partial",
2103
+ });
2104
+ const { deliver, opts, finish } = await dispatch();
2105
+
2106
+ opts.onPartialReply?.({ text: "Single block" });
2107
+ await vi.waitFor(() => {
2108
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2109
+ });
2110
+
2111
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledWith(
2112
+ "!room:example.org",
2113
+ "Single block",
2114
+ expect.not.objectContaining({
2115
+ msgtype: "m.notice",
2116
+ includeMentions: false,
2117
+ }),
2118
+ );
2119
+
2120
+ await deliver({ text: "Single block" }, { kind: "final" });
2121
+
2122
+ // MSC4357: even when text is unchanged, a finalize edit is sent to clear
2123
+ // the live marker so supporting clients stop the streaming animation.
2124
+ expect(editMessageMatrixMock).toHaveBeenCalledTimes(1);
2125
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2126
+ "!room:example.org",
2127
+ "$draft1",
2128
+ "Single block",
2129
+ expect.objectContaining({ live: false }),
2130
+ );
2131
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2132
+ expect(redactEventMock).not.toHaveBeenCalled();
2133
+ await finish();
2134
+ });
2135
+
2136
+ it("still edits partial preview-first drafts when the final text changes", async () => {
2137
+ const { dispatch, redactEventMock } = createStreamingHarness({
2138
+ blockStreamingEnabled: true,
2139
+ streaming: "partial",
2140
+ });
2141
+ const { deliver, opts, finish } = await dispatch();
2142
+
2143
+ opts.onPartialReply?.({ text: "Single" });
2144
+ await vi.waitFor(() => {
2145
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2146
+ });
2147
+
2148
+ await deliver({ text: "Single block" }, { kind: "final" });
2149
+
2150
+ expect(editMessageMatrixMock).toHaveBeenCalledTimes(1);
2151
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2152
+ "!room:example.org",
2153
+ "$draft1",
2154
+ "Single block",
2155
+ expect.not.objectContaining({ live: false }),
2156
+ );
2157
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2158
+ expect(redactEventMock).not.toHaveBeenCalled();
2159
+ await finish();
2160
+ });
2161
+
2162
+ it("preserves completed blocks by rotating to a new quiet preview", async () => {
2163
+ const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
2164
+ const { deliver, opts, finish } = await dispatch();
2165
+
2166
+ opts.onPartialReply?.({ text: "Block one" });
2167
+ await vi.waitFor(() => {
2168
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2169
+ });
2170
+
2171
+ deliverMatrixRepliesMock.mockClear();
2172
+ await deliver({ text: "Block one" }, { kind: "block" });
2173
+
2174
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2175
+ "!room:example.org",
2176
+ "$draft1",
2177
+ "Block one",
2178
+ expect.objectContaining({
2179
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
2180
+ }),
2181
+ );
2182
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2183
+ expect(redactEventMock).not.toHaveBeenCalled();
2184
+
2185
+ opts.onAssistantMessageStart?.();
2186
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2187
+ messageId: "$draft2",
2188
+ roomId: "!room",
2189
+ });
2190
+ opts.onPartialReply?.({ text: "Block two" });
2191
+ await vi.waitFor(() => {
2192
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(2);
2193
+ });
2194
+
2195
+ await deliver({ text: "Block two" }, { kind: "final" });
2196
+
2197
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2198
+ "!room:example.org",
2199
+ "$draft2",
2200
+ "Block two",
2201
+ expect.objectContaining({
2202
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
2203
+ }),
2204
+ );
2205
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2206
+ expect(redactEventMock).not.toHaveBeenCalled();
2207
+ await finish();
2208
+ });
2209
+
2210
+ it("queues late partials behind block-boundary rotation", async () => {
2211
+ const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
2212
+ const { deliver, opts, finish } = await dispatch();
2213
+
2214
+ opts.onPartialReply?.({ text: "Alpha" });
2215
+ await vi.waitFor(() => {
2216
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2217
+ });
2218
+
2219
+ await opts.onBlockReplyQueued?.({ text: "Alpha" });
2220
+
2221
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2222
+ messageId: "$draft2",
2223
+ roomId: "!room",
2224
+ });
2225
+ opts.onPartialReply?.({ text: "AlphaBeta" });
2226
+
2227
+ // The next block must not update the previous block's draft while the
2228
+ // prior block delivery is still draining.
2229
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2230
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2231
+
2232
+ await deliver({ text: "Alpha" }, { kind: "block" });
2233
+
2234
+ await vi.waitFor(() => {
2235
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(2);
2236
+ });
2237
+ expect(sendSingleTextMessageMatrixMock.mock.calls[1]?.[1]).toBe("Beta");
2238
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2239
+ expect(redactEventMock).not.toHaveBeenCalled();
2240
+ await finish();
2241
+ });
2242
+
2243
+ it("keeps delayed same-message block boundaries at the emitted block length", async () => {
2244
+ const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
2245
+ const { deliver, opts, finish } = await dispatch();
2246
+
2247
+ opts.onPartialReply?.({ text: "Alpha" });
2248
+ await vi.waitFor(() => {
2249
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2250
+ });
2251
+
2252
+ opts.onPartialReply?.({ text: "AlphaBeta" });
2253
+ await vi.waitFor(() => {
2254
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2255
+ "!room:example.org",
2256
+ "$draft1",
2257
+ "AlphaBeta",
2258
+ expect.anything(),
2259
+ );
2260
+ });
2261
+
2262
+ await opts.onBlockReplyQueued?.({ text: "Alpha" });
2263
+
2264
+ sendSingleTextMessageMatrixMock.mockClear();
2265
+ editMessageMatrixMock.mockClear();
2266
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2267
+ messageId: "$draft2",
2268
+ roomId: "!room",
2269
+ });
2270
+ await deliver({ text: "Alpha" }, { kind: "block" });
2271
+
2272
+ await vi.waitFor(() => {
2273
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2274
+ });
2275
+ expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
2276
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2277
+ "!room:example.org",
2278
+ "$draft1",
2279
+ "Alpha",
2280
+ expect.anything(),
2281
+ );
2282
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2283
+ expect(redactEventMock).not.toHaveBeenCalled();
2284
+ await finish();
2285
+ });
2286
+
2287
+ it("falls back to deliverMatrixReplies when final edit fails", async () => {
2288
+ const { dispatch } = createStreamingHarness();
2289
+ const { deliver, opts, finish } = await dispatch();
2290
+
2291
+ opts.onPartialReply?.({ text: "Hello" });
2292
+ await vi.waitFor(() => {
2293
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2294
+ });
2295
+
2296
+ editMessageMatrixMock.mockRejectedValueOnce(new Error("rate limited"));
2297
+
2298
+ await deliver({ text: "Hello world" }, { kind: "block" });
2299
+
2300
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2301
+ await finish();
2302
+ });
2303
+
2304
+ it("does not reset draft stream after final delivery", async () => {
2305
+ vi.useFakeTimers();
2306
+ try {
2307
+ const { dispatch } = createStreamingHarness();
2308
+ const { deliver, opts, finish } = await dispatch();
2309
+
2310
+ opts.onPartialReply?.({ text: "Hello" });
2311
+ await vi.waitFor(() => {
2312
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2313
+ });
2314
+
2315
+ // Final delivery — stream should stay stopped.
2316
+ await deliver({ text: "Hello" }, { kind: "final" });
2317
+
2318
+ // Further partial updates should NOT create new messages.
2319
+ sendSingleTextMessageMatrixMock.mockClear();
2320
+ opts.onPartialReply?.({ text: "Ghost" });
2321
+
2322
+ await vi.advanceTimersByTimeAsync(50);
2323
+ expect(sendSingleTextMessageMatrixMock).not.toHaveBeenCalled();
2324
+ await finish();
2325
+ } finally {
2326
+ vi.useRealTimers();
2327
+ }
2328
+ });
2329
+
2330
+ it("resets draft block offsets on assistant message start", async () => {
2331
+ const { dispatch } = createStreamingHarness();
2332
+ const { deliver, opts, finish } = await dispatch();
2333
+
2334
+ // Block 1: stream and deliver.
2335
+ opts.onPartialReply?.({ text: "Block one" });
2336
+ await vi.waitFor(() => {
2337
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2338
+ });
2339
+ await deliver({ text: "Block one" }, { kind: "block" });
2340
+
2341
+ // Tool call delivered (bypasses draft stream).
2342
+ await deliver({ text: "tool result" }, { kind: "tool" });
2343
+
2344
+ // New assistant message starts — payload.text will reset upstream.
2345
+ opts.onAssistantMessageStart?.();
2346
+
2347
+ // Block 2: partial text starts fresh (no stale offset).
2348
+ sendSingleTextMessageMatrixMock.mockClear();
2349
+ sendSingleTextMessageMatrixMock.mockResolvedValue({ messageId: "$draft2", roomId: "!room" });
2350
+
2351
+ opts.onPartialReply?.({ text: "Block two" });
2352
+ await vi.waitFor(() => {
2353
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2354
+ });
2355
+
2356
+ // The draft stream should have received "Block two", not empty string.
2357
+ const sentBody = sendSingleTextMessageMatrixMock.mock.calls[0]?.[1];
2358
+ expect(sentBody).toBeTruthy();
2359
+ await finish();
2360
+ });
2361
+
2362
+ it("preserves queued block boundaries across assistant message start", async () => {
2363
+ const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
2364
+ const { deliver, opts, finish } = await dispatch();
2365
+
2366
+ opts.onPartialReply?.({ text: "Alpha" });
2367
+ await vi.waitFor(() => {
2368
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2369
+ });
2370
+
2371
+ await opts.onBlockReplyQueued?.({ text: "Alpha" });
2372
+ opts.onAssistantMessageStart?.();
2373
+ opts.onPartialReply?.({ text: "Beta" });
2374
+
2375
+ await vi.waitFor(() => {
2376
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2377
+ "!room:example.org",
2378
+ "$draft1",
2379
+ "Beta",
2380
+ expect.anything(),
2381
+ );
2382
+ });
2383
+
2384
+ sendSingleTextMessageMatrixMock.mockClear();
2385
+ editMessageMatrixMock.mockClear();
2386
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2387
+ messageId: "$draft2",
2388
+ roomId: "!room",
2389
+ });
2390
+ await deliver({ text: "Alpha" }, { kind: "block" });
2391
+
2392
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2393
+ "!room:example.org",
2394
+ "$draft1",
2395
+ "Alpha",
2396
+ expect.anything(),
2397
+ );
2398
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2399
+ expect(redactEventMock).not.toHaveBeenCalled();
2400
+ await vi.waitFor(() => {
2401
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2402
+ });
2403
+ expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
2404
+
2405
+ await deliver({ text: "Beta" }, { kind: "final" });
2406
+
2407
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2408
+ expect(redactEventMock).not.toHaveBeenCalled();
2409
+ await finish();
2410
+ });
2411
+
2412
+ it("queues late block boundaries against the source assistant message", async () => {
2413
+ const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
2414
+ const { deliver, opts, finish } = await dispatch();
2415
+
2416
+ opts.onAssistantMessageStart?.();
2417
+ opts.onPartialReply?.({ text: "Alpha" });
2418
+ await vi.waitFor(() => {
2419
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2420
+ });
2421
+
2422
+ opts.onAssistantMessageStart?.();
2423
+ await opts.onBlockReplyQueued?.({ text: "Alpha" }, { assistantMessageIndex: 1 });
2424
+ opts.onPartialReply?.({ text: "Beta" });
2425
+
2426
+ await vi.waitFor(() => {
2427
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2428
+ "!room:example.org",
2429
+ "$draft1",
2430
+ "Beta",
2431
+ expect.anything(),
2432
+ );
2433
+ });
2434
+
2435
+ sendSingleTextMessageMatrixMock.mockClear();
2436
+ editMessageMatrixMock.mockClear();
2437
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2438
+ messageId: "$draft2",
2439
+ roomId: "!room",
2440
+ });
2441
+ await deliver({ text: "Alpha" }, { kind: "block" });
2442
+
2443
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2444
+ "!room:example.org",
2445
+ "$draft1",
2446
+ "Alpha",
2447
+ expect.anything(),
2448
+ );
2449
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2450
+ expect(redactEventMock).not.toHaveBeenCalled();
2451
+ await vi.waitFor(() => {
2452
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2453
+ });
2454
+ expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
2455
+
2456
+ await deliver({ text: "Beta" }, { kind: "final" });
2457
+
2458
+ expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
2459
+ expect(redactEventMock).not.toHaveBeenCalled();
2460
+ await finish();
2461
+ });
2462
+
2463
+ it("keeps queued block boundaries ordered while Matrix deliveries drain", async () => {
2464
+ const { dispatch } = createStreamingHarness({ blockStreamingEnabled: true });
2465
+ const { deliver, opts, finish } = await dispatch();
2466
+
2467
+ opts.onPartialReply?.({ text: "Alpha" });
2468
+ await vi.waitFor(() => {
2469
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2470
+ });
2471
+ expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Alpha");
2472
+
2473
+ await opts.onBlockReplyQueued?.({ text: "Alpha" });
2474
+ opts.onPartialReply?.({ text: "AlphaBeta" });
2475
+ await opts.onBlockReplyQueued?.({ text: "Beta" });
2476
+ opts.onPartialReply?.({ text: "AlphaBetaGamma" });
2477
+
2478
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2479
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2480
+
2481
+ sendSingleTextMessageMatrixMock.mockClear();
2482
+ editMessageMatrixMock.mockClear();
2483
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2484
+ messageId: "$draft2",
2485
+ roomId: "!room",
2486
+ });
2487
+ await deliver({ text: "Alpha" }, { kind: "block" });
2488
+
2489
+ await vi.waitFor(() => {
2490
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2491
+ });
2492
+ expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
2493
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2494
+ "!room:example.org",
2495
+ "$draft1",
2496
+ "Alpha",
2497
+ expect.objectContaining({
2498
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
2499
+ }),
2500
+ );
2501
+
2502
+ sendSingleTextMessageMatrixMock.mockClear();
2503
+ editMessageMatrixMock.mockClear();
2504
+ sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
2505
+ messageId: "$draft3",
2506
+ roomId: "!room",
2507
+ });
2508
+ await deliver({ text: "Beta" }, { kind: "block" });
2509
+
2510
+ await vi.waitFor(() => {
2511
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2512
+ });
2513
+ expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Gamma");
2514
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2515
+ "!room:example.org",
2516
+ "$draft2",
2517
+ "Beta",
2518
+ expect.objectContaining({
2519
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
2520
+ }),
2521
+ );
2522
+
2523
+ await finish();
2524
+ });
2525
+
2526
+ it("stops draft stream on handler error (no leaked timer)", async () => {
2527
+ vi.useFakeTimers();
2528
+ try {
2529
+ sendSingleTextMessageMatrixMock
2530
+ .mockReset()
2531
+ .mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
2532
+ editMessageMatrixMock.mockReset().mockResolvedValue("$edited");
2533
+ deliverMatrixRepliesMock.mockReset().mockResolvedValue(true);
2534
+ const redactEventMock = vi.fn(async () => "$redacted");
2535
+
2536
+ let capturedReplyOpts: ReplyOpts | undefined;
2537
+
2538
+ const { handler } = createMatrixHandlerTestHarness({
2539
+ streaming: "quiet",
2540
+ client: { redactEvent: redactEventMock },
2541
+ createReplyDispatcherWithTyping: () => ({
2542
+ dispatcher: { markComplete: () => {}, waitForIdle: async () => {} },
2543
+ replyOptions: {},
2544
+ markDispatchIdle: () => {},
2545
+ markRunComplete: () => {},
2546
+ }),
2547
+ dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
2548
+ capturedReplyOpts = args?.replyOptions;
2549
+ // Simulate streaming then model error.
2550
+ capturedReplyOpts?.onPartialReply?.({ text: "partial" });
2551
+ await vi.waitFor(() => {
2552
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2553
+ });
2554
+ throw new Error("model timeout");
2555
+ }) as never,
2556
+ withReplyDispatcher: async <T>(params: {
2557
+ dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
2558
+ run: () => Promise<T>;
2559
+ onSettled?: () => void | Promise<void>;
2560
+ }) => {
2561
+ const result = await params.run();
2562
+ await params.onSettled?.();
2563
+ return result;
2564
+ },
2565
+ });
2566
+
2567
+ // Handler should not throw (outer catch absorbs it).
2568
+ await handler(
2569
+ "!room:example.org",
2570
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2571
+ );
2572
+
2573
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2574
+
2575
+ // After handler exits, draft stream timer must not fire.
2576
+ sendSingleTextMessageMatrixMock.mockClear();
2577
+ editMessageMatrixMock.mockClear();
2578
+ await vi.advanceTimersByTimeAsync(50);
2579
+ expect(sendSingleTextMessageMatrixMock).not.toHaveBeenCalled();
2580
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2581
+ } finally {
2582
+ vi.useRealTimers();
2583
+ }
2584
+ });
2585
+
2586
+ it("redacts partial live drafts when generation aborts mid-stream", async () => {
2587
+ sendSingleTextMessageMatrixMock
2588
+ .mockReset()
2589
+ .mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
2590
+ editMessageMatrixMock.mockReset().mockResolvedValue("$edited");
2591
+ deliverMatrixRepliesMock.mockReset().mockResolvedValue(true);
2592
+
2593
+ const redactEventMock = vi.fn(async () => "$redacted");
2594
+ let capturedReplyOpts: ReplyOpts | undefined;
2595
+
2596
+ const { handler } = createMatrixHandlerTestHarness({
2597
+ streaming: "partial",
2598
+ client: { redactEvent: redactEventMock },
2599
+ createReplyDispatcherWithTyping: () => ({
2600
+ dispatcher: { markComplete: () => {}, waitForIdle: async () => {} },
2601
+ replyOptions: {},
2602
+ markDispatchIdle: () => {},
2603
+ markRunComplete: () => {},
2604
+ }),
2605
+ dispatchReplyFromConfig: vi.fn(async (args: { replyOptions?: ReplyOpts }) => {
2606
+ capturedReplyOpts = args?.replyOptions;
2607
+ capturedReplyOpts?.onPartialReply?.({ text: "partial" });
2608
+ await vi.waitFor(() => {
2609
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2610
+ });
2611
+ throw new Error("model timeout");
2612
+ }) as never,
2613
+ withReplyDispatcher: async <T>(params: {
2614
+ dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
2615
+ run: () => Promise<T>;
2616
+ onSettled?: () => void | Promise<void>;
2617
+ }) => {
2618
+ const result = await params.run();
2619
+ await params.onSettled?.();
2620
+ return result;
2621
+ },
2622
+ });
2623
+
2624
+ await handler(
2625
+ "!room:example.org",
2626
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2627
+ );
2628
+
2629
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2630
+ });
2631
+
2632
+ it("keeps shutdown cleanup for empty final payloads that send nothing", async () => {
2633
+ const { dispatch, redactEventMock } = createStreamingHarness({ streaming: "partial" });
2634
+ const { deliver, opts, finish } = await dispatch();
2635
+
2636
+ opts.onPartialReply?.({ text: "Partial reply" });
2637
+ await vi.waitFor(() => {
2638
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2639
+ });
2640
+
2641
+ deliverMatrixRepliesMock.mockClear();
2642
+ deliverMatrixRepliesMock.mockResolvedValue(false);
2643
+ await deliver({}, { kind: "final" });
2644
+
2645
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2646
+ expect(redactEventMock).not.toHaveBeenCalled();
2647
+
2648
+ await finish();
2649
+
2650
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2651
+ });
2652
+
2653
+ it("skips compaction notices in draft finalization", async () => {
2654
+ const { dispatch } = createStreamingHarness();
2655
+ const { deliver, opts, finish } = await dispatch();
2656
+
2657
+ opts.onPartialReply?.({ text: "Streaming" });
2658
+ await vi.waitFor(() => {
2659
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2660
+ });
2661
+
2662
+ // Compaction notice should bypass draft path and go to normal delivery.
2663
+ deliverMatrixRepliesMock.mockClear();
2664
+ await deliver({ text: "Compacting...", isCompactionNotice: true }, { kind: "block" });
2665
+
2666
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2667
+ // Edit should NOT have been called for the compaction notice.
2668
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2669
+ await finish();
2670
+ });
2671
+
2672
+ it("redacts stale draft when payload reply target mismatches", async () => {
2673
+ const { dispatch, redactEventMock } = createStreamingHarness({ replyToMode: "first" });
2674
+ const { deliver, opts, finish } = await dispatch();
2675
+
2676
+ // Simulate streaming: partial reply creates draft message.
2677
+ opts.onPartialReply?.({ text: "Partial reply" });
2678
+ await vi.waitFor(() => {
2679
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2680
+ });
2681
+
2682
+ // Final delivery carries a different replyToId than the draft's.
2683
+ deliverMatrixRepliesMock.mockClear();
2684
+ await deliver({ text: "Final text", replyToId: "$different_msg" }, { kind: "final" });
2685
+
2686
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2687
+ // Draft should be redacted since it can't change reply relation.
2688
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2689
+ // Final answer delivered via normal path.
2690
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2691
+ await finish();
2692
+ });
2693
+
2694
+ it("redacts stale draft when final payload intentionally drops reply threading", async () => {
2695
+ const { dispatch, redactEventMock } = createStreamingHarness({ replyToMode: "first" });
2696
+ const { deliver, opts, finish } = await dispatch();
2697
+
2698
+ // A tool payload can consume the first reply slot upstream while draft
2699
+ // streaming for the next assistant block still starts from the original
2700
+ // reply target.
2701
+ await deliver({ text: "tool result", replyToId: "$msg1" }, { kind: "tool" });
2702
+ opts.onAssistantMessageStart?.();
2703
+
2704
+ opts.onPartialReply?.({ text: "Partial reply" });
2705
+ await vi.waitFor(() => {
2706
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2707
+ });
2708
+
2709
+ deliverMatrixRepliesMock.mockClear();
2710
+ await deliver({ text: "Final text" }, { kind: "final" });
2711
+
2712
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2713
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2714
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2715
+ await finish();
2716
+ });
2717
+
2718
+ it("redacts stale draft for media-only finals", async () => {
2719
+ const { dispatch, redactEventMock } = createStreamingHarness();
2720
+ const { deliver, opts, finish } = await dispatch();
2721
+
2722
+ opts.onPartialReply?.({ text: "Partial reply" });
2723
+ await vi.waitFor(() => {
2724
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2725
+ });
2726
+
2727
+ deliverMatrixRepliesMock.mockClear();
2728
+ await deliver({ mediaUrl: "https://example.com/image.png" }, { kind: "final" });
2729
+
2730
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2731
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2732
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2733
+ await finish();
2734
+ });
2735
+
2736
+ it("finalizes partial drafts before reusing unchanged media captions", async () => {
2737
+ const { dispatch, redactEventMock } = createStreamingHarness({ streaming: "partial" });
2738
+ const { deliver, opts, finish } = await dispatch();
2739
+
2740
+ opts.onPartialReply?.({ text: "@room screenshot ready" });
2741
+ await vi.waitFor(() => {
2742
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2743
+ });
2744
+
2745
+ deliverMatrixRepliesMock.mockClear();
2746
+ await deliver(
2747
+ {
2748
+ text: "@room screenshot ready",
2749
+ mediaUrl: "https://example.com/image.png",
2750
+ },
2751
+ { kind: "final" },
2752
+ );
2753
+
2754
+ expect(editMessageMatrixMock).toHaveBeenCalledTimes(1);
2755
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2756
+ "!room:example.org",
2757
+ "$draft1",
2758
+ "@room screenshot ready",
2759
+ expect.objectContaining({ live: false }),
2760
+ );
2761
+ expect(redactEventMock).not.toHaveBeenCalled();
2762
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledWith(
2763
+ expect.objectContaining({
2764
+ replies: [
2765
+ expect.objectContaining({
2766
+ mediaUrl: "https://example.com/image.png",
2767
+ text: undefined,
2768
+ }),
2769
+ ],
2770
+ }),
2771
+ );
2772
+ await finish();
2773
+ });
2774
+
2775
+ it("finalizes quiet drafts before reusing unchanged media captions", async () => {
2776
+ const { dispatch, redactEventMock } = createStreamingHarness({ streaming: "quiet" });
2777
+ const { deliver, opts, finish } = await dispatch();
2778
+
2779
+ opts.onPartialReply?.({ text: "@room screenshot ready" });
2780
+ await vi.waitFor(() => {
2781
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2782
+ });
2783
+
2784
+ deliverMatrixRepliesMock.mockClear();
2785
+ await deliver(
2786
+ {
2787
+ text: "@room screenshot ready",
2788
+ mediaUrl: "https://example.com/image.png",
2789
+ },
2790
+ { kind: "final" },
2791
+ );
2792
+
2793
+ expect(editMessageMatrixMock).toHaveBeenCalledWith(
2794
+ "!room:example.org",
2795
+ "$draft1",
2796
+ "@room screenshot ready",
2797
+ expect.objectContaining({
2798
+ extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
2799
+ }),
2800
+ );
2801
+ expect(redactEventMock).not.toHaveBeenCalled();
2802
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledWith(
2803
+ expect.objectContaining({
2804
+ replies: [
2805
+ expect.objectContaining({
2806
+ mediaUrl: "https://example.com/image.png",
2807
+ text: undefined,
2808
+ }),
2809
+ ],
2810
+ }),
2811
+ );
2812
+ await finish();
2813
+ });
2814
+
2815
+ it("redacts stale draft and sends the final once when a later preview exceeds the event limit", async () => {
2816
+ const { dispatch, redactEventMock } = createStreamingHarness();
2817
+ const { deliver, opts, finish } = await dispatch();
2818
+
2819
+ opts.onPartialReply?.({ text: "1234" });
2820
+ await vi.waitFor(() => {
2821
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2822
+ });
2823
+
2824
+ prepareMatrixSingleTextMock.mockImplementation((text: string) => {
2825
+ const trimmedText = text.trim();
2826
+ return {
2827
+ trimmedText,
2828
+ convertedText: trimmedText,
2829
+ singleEventLimit: 5,
2830
+ fitsInSingleEvent: trimmedText.length <= 5,
2831
+ };
2832
+ });
2833
+
2834
+ opts.onPartialReply?.({ text: "123456" });
2835
+ await deliver({ text: "123456" }, { kind: "final" });
2836
+
2837
+ expect(editMessageMatrixMock).not.toHaveBeenCalled();
2838
+ expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
2839
+ expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
2840
+ expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
2841
+ await finish();
2842
+ });
2843
+ });
2844
+
2845
+ describe("matrix monitor handler block streaming config", () => {
2846
+ it("keeps final-only delivery when draft streaming is off by default", async () => {
2847
+ let capturedDisableBlockStreaming: boolean | undefined;
2848
+
2849
+ const { handler } = createMatrixHandlerTestHarness({
2850
+ streaming: "off",
2851
+ dispatchReplyFromConfig: vi.fn(
2852
+ async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
2853
+ capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
2854
+ return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
2855
+ },
2856
+ ) as never,
2857
+ });
2858
+
2859
+ await handler(
2860
+ "!room:example.org",
2861
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2862
+ );
2863
+
2864
+ expect(capturedDisableBlockStreaming).toBe(true);
2865
+ });
2866
+
2867
+ it("keeps block streaming disabled when partial previews are on and block streaming is off", async () => {
2868
+ let capturedDisableBlockStreaming: boolean | undefined;
2869
+
2870
+ const { handler } = createMatrixHandlerTestHarness({
2871
+ streaming: "partial",
2872
+ dispatchReplyFromConfig: vi.fn(
2873
+ async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
2874
+ capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
2875
+ return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
2876
+ },
2877
+ ) as never,
2878
+ });
2879
+
2880
+ await handler(
2881
+ "!room:example.org",
2882
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2883
+ );
2884
+
2885
+ expect(capturedDisableBlockStreaming).toBe(true);
2886
+ });
2887
+
2888
+ it("keeps block streaming disabled when quiet previews are on and block streaming is off", async () => {
2889
+ let capturedDisableBlockStreaming: boolean | undefined;
2890
+
2891
+ const { handler } = createMatrixHandlerTestHarness({
2892
+ streaming: "quiet",
2893
+ dispatchReplyFromConfig: vi.fn(
2894
+ async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
2895
+ capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
2896
+ return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
2897
+ },
2898
+ ) as never,
2899
+ });
2900
+
2901
+ await handler(
2902
+ "!room:example.org",
2903
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2904
+ );
2905
+
2906
+ expect(capturedDisableBlockStreaming).toBe(true);
2907
+ });
2908
+
2909
+ it("allows shared block streaming when partial previews and block streaming are both enabled", async () => {
2910
+ let capturedDisableBlockStreaming: boolean | undefined;
2911
+
2912
+ const { handler } = createMatrixHandlerTestHarness({
2913
+ streaming: "partial",
2914
+ blockStreamingEnabled: true,
2915
+ dispatchReplyFromConfig: vi.fn(
2916
+ async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
2917
+ capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
2918
+ return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
2919
+ },
2920
+ ) as never,
2921
+ });
2922
+
2923
+ await handler(
2924
+ "!room:example.org",
2925
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2926
+ );
2927
+
2928
+ expect(capturedDisableBlockStreaming).toBe(false);
2929
+ });
2930
+
2931
+ it("uses shared block streaming when explicitly enabled for Matrix", async () => {
2932
+ let capturedDisableBlockStreaming: boolean | undefined;
2933
+
2934
+ const { handler } = createMatrixHandlerTestHarness({
2935
+ streaming: "off",
2936
+ blockStreamingEnabled: true,
2937
+ dispatchReplyFromConfig: vi.fn(
2938
+ async (args: { replyOptions?: { disableBlockStreaming?: boolean } }) => {
2939
+ capturedDisableBlockStreaming = args.replyOptions?.disableBlockStreaming;
2940
+ return { queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } };
2941
+ },
2942
+ ) as never,
2943
+ });
2944
+
2945
+ await handler(
2946
+ "!room:example.org",
2947
+ createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
2948
+ );
2949
+
2950
+ expect(capturedDisableBlockStreaming).toBe(false);
2951
+ });
2952
+ });