@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,648 @@
1
+ /**
2
+ * Tests for Matrix group chat history accumulation.
3
+ *
4
+ * Covers two key scenarios:
5
+ *
6
+ * Scenario 1 — basic accumulation across agents:
7
+ * user: msg A (no mention, accumulates)
8
+ * user: @agent_a msg B (triggers agent_a; agent_a sees [A] in history, not B itself)
9
+ * user: @agent_b msg C (triggers agent_b; agent_b sees [A, B] — independent watermark)
10
+ * user: @agent_b msg D (triggers agent_b; agent_b sees [] — A/B/C were consumed)
11
+ *
12
+ * Scenario 2 — race condition safety:
13
+ * user: @agent_a msg A (triggers agent_a; agent starts processing, not yet replied)
14
+ * user: msg B (no mention, arrives during processing — must not be lost)
15
+ * agent_a: reply (watermark advances to just after A, not after B)
16
+ * user: @agent_a msg C (triggers agent_a; agent_a sees [B] in history)
17
+ */
18
+
19
+ import { beforeEach, describe, expect, it, vi } from "vitest";
20
+ import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
21
+ import {
22
+ createMatrixHandlerTestHarness,
23
+ createMatrixRoomMessageEvent,
24
+ createMatrixTextMessageEvent,
25
+ } from "./handler.test-helpers.js";
26
+ import { type MatrixRawEvent } from "./types.js";
27
+
28
+ const DEFAULT_ROOM = "!room:example.org";
29
+
30
+ function makeRoomTriggerEvent(params: { eventId: string; body: string; ts?: number }) {
31
+ // Use @room mention to trigger the bot without requiring agent-specific mention regexes
32
+ return createMatrixTextMessageEvent({
33
+ eventId: params.eventId,
34
+ body: `@room ${params.body}`,
35
+ originServerTs: params.ts ?? Date.now(),
36
+ mentions: { room: true },
37
+ });
38
+ }
39
+
40
+ function makeRoomPlainEvent(params: { eventId: string; body: string; ts?: number }) {
41
+ return createMatrixTextMessageEvent({
42
+ eventId: params.eventId,
43
+ body: params.body,
44
+ originServerTs: params.ts ?? Date.now(),
45
+ });
46
+ }
47
+
48
+ function makeDevRoute(agentId: string) {
49
+ return {
50
+ agentId,
51
+ channel: "matrix" as const,
52
+ accountId: "ops",
53
+ sessionKey: `agent:${agentId}:main`,
54
+ mainSessionKey: `agent:${agentId}:main`,
55
+ matchedBy: "binding.account" as const,
56
+ };
57
+ }
58
+
59
+ beforeEach(() => {
60
+ installMatrixMonitorTestRuntime();
61
+ });
62
+
63
+ function deferred<T>() {
64
+ let resolve!: (value: T | PromiseLike<T>) => void;
65
+ const promise = new Promise<T>((res) => {
66
+ resolve = res;
67
+ });
68
+ return { promise, resolve };
69
+ }
70
+
71
+ function createFinalDeliveryFailureHandler(finalizeInboundContext: (ctx: unknown) => unknown) {
72
+ let capturedOnError:
73
+ | ((err: unknown, info: { kind: "tool" | "block" | "final" }) => void)
74
+ | undefined;
75
+
76
+ return createMatrixHandlerTestHarness({
77
+ historyLimit: 20,
78
+ groupPolicy: "open",
79
+ isDirectMessage: false,
80
+ finalizeInboundContext,
81
+ dispatchReplyFromConfig: async () => ({
82
+ queuedFinal: true,
83
+ counts: { final: 1, block: 0, tool: 0 },
84
+ }),
85
+ createReplyDispatcherWithTyping: (params?: {
86
+ onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
87
+ }) => {
88
+ capturedOnError = params?.onError;
89
+ return {
90
+ dispatcher: {},
91
+ replyOptions: {},
92
+ markDispatchIdle: () => {},
93
+ markRunComplete: () => {},
94
+ };
95
+ },
96
+ withReplyDispatcher: async <T>(params: {
97
+ dispatcher: { markComplete?: () => void; waitForIdle?: () => Promise<void> };
98
+ run: () => Promise<T>;
99
+ onSettled?: () => void | Promise<void>;
100
+ }) => {
101
+ const result = await params.run();
102
+ capturedOnError?.(new Error("simulated delivery failure"), { kind: "final" });
103
+ params.dispatcher.markComplete?.();
104
+ await params.dispatcher.waitForIdle?.();
105
+ await params.onSettled?.();
106
+ return result;
107
+ },
108
+ });
109
+ }
110
+
111
+ describe("matrix group chat history — scenario 1: basic accumulation", () => {
112
+ it("pending messages appear in InboundHistory; trigger itself does not", async () => {
113
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
114
+ const { handler } = createMatrixHandlerTestHarness({
115
+ historyLimit: 20,
116
+ groupPolicy: "open",
117
+ isDirectMessage: false,
118
+ finalizeInboundContext,
119
+ dispatchReplyFromConfig: async () => ({
120
+ queuedFinal: true,
121
+ counts: { final: 1, block: 0, tool: 0 },
122
+ }),
123
+ });
124
+
125
+ // Non-trigger message A — should not dispatch
126
+ await handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$a", body: "msg A", ts: 1000 }));
127
+ expect(finalizeInboundContext).not.toHaveBeenCalled();
128
+
129
+ // Trigger B — history must contain [msg A] only, not the trigger itself
130
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$b", body: "msg B", ts: 2000 }));
131
+ expect(finalizeInboundContext).toHaveBeenCalledOnce();
132
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
133
+ const history = ctx["InboundHistory"] as Array<{ body: string; sender: string }>;
134
+ expect(history).toHaveLength(1);
135
+ expect(history[0]?.body).toContain("msg A");
136
+ });
137
+
138
+ it("multi-agent: each agent has an independent watermark", async () => {
139
+ let currentAgentId = "agent_a";
140
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
141
+ const { handler } = createMatrixHandlerTestHarness({
142
+ historyLimit: 20,
143
+ groupPolicy: "open",
144
+ isDirectMessage: false,
145
+ finalizeInboundContext,
146
+ resolveAgentRoute: vi.fn(() => makeDevRoute(currentAgentId)),
147
+ dispatchReplyFromConfig: async () => ({
148
+ queuedFinal: true,
149
+ counts: { final: 1, block: 0, tool: 0 },
150
+ }),
151
+ });
152
+
153
+ // msg A accumulates for all agents
154
+ await handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$a", body: "msg A", ts: 1000 }));
155
+
156
+ // @agent_a trigger B — agent_a sees [msg A]
157
+ currentAgentId = "agent_a";
158
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$b", body: "msg B", ts: 2000 }));
159
+ {
160
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
161
+ const history = ctx["InboundHistory"] as Array<{ body: string }>;
162
+ expect(history).toHaveLength(1);
163
+ expect(history[0]?.body).toContain("msg A");
164
+ }
165
+
166
+ // @agent_b trigger C — agent_b watermark is 0, so it sees [msg A, msg B]
167
+ currentAgentId = "agent_b";
168
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$c", body: "msg C", ts: 3000 }));
169
+ {
170
+ const ctx = finalizeInboundContext.mock.calls[1]?.[0] as Record<string, unknown>;
171
+ const history = ctx["InboundHistory"] as Array<{ body: string }>;
172
+ expect(history).toHaveLength(2);
173
+ expect(history.map((h) => h.body).some((b) => b.includes("msg A"))).toBe(true);
174
+ expect(history.map((h) => h.body).some((b) => b.includes("msg B"))).toBe(true);
175
+ }
176
+
177
+ // @agent_b trigger D — A/B/C consumed; history is empty
178
+ currentAgentId = "agent_b";
179
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$d", body: "msg D", ts: 4000 }));
180
+ {
181
+ const ctx = finalizeInboundContext.mock.calls[2]?.[0] as Record<string, unknown>;
182
+ const history = ctx["InboundHistory"] as Array<unknown> | undefined;
183
+ expect(history ?? []).toHaveLength(0);
184
+ }
185
+ });
186
+
187
+ it("respects historyLimit: caps to the most recent N entries", async () => {
188
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
189
+ const { handler } = createMatrixHandlerTestHarness({
190
+ historyLimit: 2,
191
+ groupPolicy: "open",
192
+ isDirectMessage: false,
193
+ finalizeInboundContext,
194
+ dispatchReplyFromConfig: async () => ({
195
+ queuedFinal: true,
196
+ counts: { final: 1, block: 0, tool: 0 },
197
+ }),
198
+ });
199
+
200
+ for (let i = 1; i <= 4; i++) {
201
+ await handler(
202
+ DEFAULT_ROOM,
203
+ makeRoomPlainEvent({ eventId: `$p${i}`, body: `pending ${i}`, ts: i * 1000 }),
204
+ );
205
+ }
206
+
207
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$t", body: "trigger", ts: 5000 }));
208
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
209
+ const history = ctx["InboundHistory"] as Array<{ body: string }>;
210
+ expect(history).toHaveLength(2);
211
+ expect(history[0]?.body).toContain("pending 3");
212
+ expect(history[1]?.body).toContain("pending 4");
213
+ });
214
+
215
+ it("historyLimit=0 disables history accumulation entirely", async () => {
216
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
217
+ const { handler } = createMatrixHandlerTestHarness({
218
+ historyLimit: 0,
219
+ groupPolicy: "open",
220
+ isDirectMessage: false,
221
+ finalizeInboundContext,
222
+ dispatchReplyFromConfig: async () => ({
223
+ queuedFinal: true,
224
+ counts: { final: 1, block: 0, tool: 0 },
225
+ }),
226
+ });
227
+
228
+ await handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$p", body: "pending" }));
229
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$t", body: "trigger" }));
230
+
231
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
232
+ const history = ctx["InboundHistory"] as Array<unknown> | undefined;
233
+ expect(history ?? []).toHaveLength(0);
234
+ });
235
+
236
+ it("historyLimit=0 does not serialize same-room ingress", async () => {
237
+ const firstUserId = deferred<string>();
238
+ let getUserIdCalls = 0;
239
+ const { handler } = createMatrixHandlerTestHarness({
240
+ historyLimit: 0,
241
+ groupPolicy: "open",
242
+ isDirectMessage: false,
243
+ client: {
244
+ getUserId: async () => {
245
+ getUserIdCalls += 1;
246
+ if (getUserIdCalls === 1) {
247
+ return await firstUserId.promise;
248
+ }
249
+ return "@bot:example.org";
250
+ },
251
+ },
252
+ dispatchReplyFromConfig: async () => ({
253
+ queuedFinal: true,
254
+ counts: { final: 1, block: 0, tool: 0 },
255
+ }),
256
+ });
257
+
258
+ const first = handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$a", body: "first" }));
259
+ await Promise.resolve();
260
+ const second = handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$b", body: "second" }));
261
+ await Promise.resolve();
262
+
263
+ expect(getUserIdCalls).toBe(2);
264
+
265
+ firstUserId.resolve("@bot:example.org");
266
+ await Promise.all([first, second]);
267
+ });
268
+
269
+ it("DMs do not accumulate history (group chat only)", async () => {
270
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
271
+ const { handler } = createMatrixHandlerTestHarness({
272
+ historyLimit: 20,
273
+ isDirectMessage: true,
274
+ finalizeInboundContext,
275
+ dispatchReplyFromConfig: async () => ({
276
+ queuedFinal: true,
277
+ counts: { final: 1, block: 0, tool: 0 },
278
+ }),
279
+ });
280
+
281
+ await handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$dm1", body: "dm message 1" }));
282
+ await handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$dm2", body: "dm message 2" }));
283
+
284
+ expect(finalizeInboundContext).toHaveBeenCalledTimes(2);
285
+ for (const call of finalizeInboundContext.mock.calls) {
286
+ const ctx = call[0] as Record<string, unknown>;
287
+ const history = ctx["InboundHistory"] as Array<unknown> | undefined;
288
+ expect(history ?? []).toHaveLength(0);
289
+ }
290
+ });
291
+
292
+ it("history-enabled rooms do not serialize DM ingress heavy work", async () => {
293
+ let resolveFirstName: (() => void) | undefined;
294
+ let nameLookupCalls = 0;
295
+ const getMemberDisplayName = vi.fn(async () => {
296
+ nameLookupCalls += 1;
297
+ if (nameLookupCalls === 1) {
298
+ await new Promise<void>((resolve) => {
299
+ resolveFirstName = resolve;
300
+ });
301
+ }
302
+ return "sender";
303
+ });
304
+
305
+ const { handler } = createMatrixHandlerTestHarness({
306
+ historyLimit: 20,
307
+ isDirectMessage: true,
308
+ getMemberDisplayName,
309
+ dispatchReplyFromConfig: async () => ({
310
+ queuedFinal: true,
311
+ counts: { final: 1, block: 0, tool: 0 },
312
+ }),
313
+ });
314
+
315
+ const first = handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$dm-a", body: "first dm" }));
316
+ await vi.waitFor(() => {
317
+ expect(resolveFirstName).toBeTypeOf("function");
318
+ });
319
+
320
+ const second = handler(
321
+ DEFAULT_ROOM,
322
+ makeRoomPlainEvent({ eventId: "$dm-b", body: "second dm" }),
323
+ );
324
+ await vi.waitFor(() => {
325
+ expect(nameLookupCalls).toBe(2);
326
+ });
327
+
328
+ resolveFirstName?.();
329
+ await Promise.all([first, second]);
330
+ });
331
+
332
+ it("includes skipped media-only room messages in next trigger history", async () => {
333
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
334
+ const { handler } = createMatrixHandlerTestHarness({
335
+ historyLimit: 20,
336
+ groupPolicy: "open",
337
+ isDirectMessage: false,
338
+ finalizeInboundContext,
339
+ dispatchReplyFromConfig: async () => ({
340
+ queuedFinal: true,
341
+ counts: { final: 1, block: 0, tool: 0 },
342
+ }),
343
+ });
344
+
345
+ // Unmentioned media-only message should be buffered as pending history context.
346
+ await handler(
347
+ DEFAULT_ROOM,
348
+ createMatrixRoomMessageEvent({
349
+ eventId: "$media-a",
350
+ originServerTs: 1000,
351
+ content: {
352
+ msgtype: "m.image",
353
+ body: "",
354
+ url: "mxc://example.org/media-a",
355
+ },
356
+ }),
357
+ );
358
+ expect(finalizeInboundContext).not.toHaveBeenCalled();
359
+
360
+ await handler(
361
+ DEFAULT_ROOM,
362
+ makeRoomTriggerEvent({ eventId: "$trigger-media", body: "trigger", ts: 2000 }),
363
+ );
364
+ expect(finalizeInboundContext).toHaveBeenCalledOnce();
365
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
366
+ const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined;
367
+ expect(history?.some((entry) => entry.body.includes("[matrix image attachment]"))).toBe(true);
368
+ });
369
+
370
+ it("includes skipped poll updates in next trigger history", async () => {
371
+ const getEvent = vi.fn(async () => ({
372
+ event_id: "$poll",
373
+ sender: "@user:example.org",
374
+ type: "m.poll.start",
375
+ origin_server_ts: Date.now(),
376
+ content: {
377
+ "m.poll.start": {
378
+ question: { "m.text": "Lunch?" },
379
+ kind: "m.poll.disclosed",
380
+ max_selections: 1,
381
+ answers: [{ id: "a1", "m.text": "Pizza" }],
382
+ },
383
+ },
384
+ }));
385
+ const getRelations = vi.fn(async () => ({
386
+ events: [],
387
+ nextBatch: null,
388
+ prevBatch: null,
389
+ }));
390
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
391
+ const { handler } = createMatrixHandlerTestHarness({
392
+ historyLimit: 20,
393
+ groupPolicy: "open",
394
+ isDirectMessage: false,
395
+ client: {
396
+ getEvent,
397
+ getRelations,
398
+ },
399
+ finalizeInboundContext,
400
+ dispatchReplyFromConfig: async () => ({
401
+ queuedFinal: true,
402
+ counts: { final: 1, block: 0, tool: 0 },
403
+ }),
404
+ });
405
+
406
+ await handler(DEFAULT_ROOM, {
407
+ type: "m.poll.response",
408
+ sender: "@user:example.org",
409
+ event_id: "$poll-response-1",
410
+ origin_server_ts: 1000,
411
+ content: {
412
+ "m.poll.response": {
413
+ answers: ["a1"],
414
+ },
415
+ "m.relates_to": {
416
+ rel_type: "m.reference",
417
+ event_id: "$poll",
418
+ },
419
+ },
420
+ } as MatrixRawEvent);
421
+ expect(finalizeInboundContext).not.toHaveBeenCalled();
422
+
423
+ await handler(
424
+ DEFAULT_ROOM,
425
+ makeRoomTriggerEvent({ eventId: "$trigger-poll", body: "trigger", ts: 2000 }),
426
+ );
427
+
428
+ expect(getEvent).toHaveBeenCalledOnce();
429
+ expect(getRelations).toHaveBeenCalledOnce();
430
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
431
+ const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined;
432
+ expect(history?.some((entry) => entry.body.includes("Lunch?"))).toBe(true);
433
+ });
434
+ });
435
+
436
+ describe("matrix group chat history — scenario 2: race condition safety", () => {
437
+ it("messages arriving during agent processing are visible on the next trigger", async () => {
438
+ let resolveFirstDispatch: (() => void) | undefined;
439
+ let firstDispatchStarted = false;
440
+
441
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
442
+ const dispatchReplyFromConfig = vi.fn(async () => {
443
+ if (!firstDispatchStarted) {
444
+ firstDispatchStarted = true;
445
+ await new Promise<void>((resolve) => {
446
+ resolveFirstDispatch = resolve;
447
+ });
448
+ }
449
+ return { queuedFinal: true, counts: { final: 1, block: 0, tool: 0 } };
450
+ });
451
+
452
+ const { handler } = createMatrixHandlerTestHarness({
453
+ historyLimit: 20,
454
+ groupPolicy: "open",
455
+ isDirectMessage: false,
456
+ finalizeInboundContext,
457
+ dispatchReplyFromConfig,
458
+ });
459
+
460
+ // Step 1: trigger msg A — don't await, let it block in dispatch
461
+ const firstHandlerDone = handler(
462
+ DEFAULT_ROOM,
463
+ makeRoomTriggerEvent({ eventId: "$a", body: "msg A", ts: 1000 }),
464
+ );
465
+
466
+ // Step 2: wait until dispatch is in-flight
467
+ await vi.waitFor(() => {
468
+ expect(firstDispatchStarted).toBe(true);
469
+ });
470
+
471
+ // Step 3: msg B arrives while agent is processing — must not be lost
472
+ await handler(DEFAULT_ROOM, makeRoomPlainEvent({ eventId: "$b", body: "msg B", ts: 2000 }));
473
+
474
+ // Step 4: unblock dispatch and complete
475
+ resolveFirstDispatch!();
476
+ await firstHandlerDone;
477
+ // watermark advances to snapshot taken at dispatch time (just after msg A), not to queue end
478
+
479
+ // Step 5: trigger msg C — should see [msg B] in history (msg A was consumed)
480
+ await handler(DEFAULT_ROOM, makeRoomTriggerEvent({ eventId: "$c", body: "msg C", ts: 3000 }));
481
+
482
+ expect(finalizeInboundContext).toHaveBeenCalledTimes(2);
483
+ const ctxForC = finalizeInboundContext.mock.calls[1]?.[0] as Record<string, unknown>;
484
+ const history = ctxForC["InboundHistory"] as Array<{ body: string }>;
485
+ expect(history.some((h) => h.body.includes("msg B"))).toBe(true);
486
+ expect(history.every((h) => !h.body.includes("msg A"))).toBe(true);
487
+ });
488
+
489
+ it("watermark does not advance when final reply delivery fails (retry sees same history)", async () => {
490
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
491
+ const { handler } = createFinalDeliveryFailureHandler(finalizeInboundContext);
492
+
493
+ await handler(
494
+ DEFAULT_ROOM,
495
+ makeRoomPlainEvent({ eventId: "$p", body: "pending msg", ts: 1000 }),
496
+ );
497
+
498
+ // First trigger — delivery fails; watermark must NOT advance
499
+ await handler(
500
+ DEFAULT_ROOM,
501
+ makeRoomTriggerEvent({ eventId: "$t1", body: "trigger 1", ts: 2000 }),
502
+ );
503
+ expect(finalizeInboundContext).toHaveBeenCalledOnce();
504
+ {
505
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
506
+ const history = ctx["InboundHistory"] as Array<{ body: string }>;
507
+ expect(history).toHaveLength(1);
508
+ expect(history[0]?.body).toContain("pending msg");
509
+ }
510
+
511
+ // Second trigger — pending msg must still be visible (watermark not advanced)
512
+ await handler(
513
+ DEFAULT_ROOM,
514
+ makeRoomTriggerEvent({ eventId: "$t2", body: "trigger 2", ts: 3000 }),
515
+ );
516
+ expect(finalizeInboundContext).toHaveBeenCalledTimes(2);
517
+ {
518
+ const ctx = finalizeInboundContext.mock.calls[1]?.[0] as Record<string, unknown>;
519
+ const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined;
520
+ expect(history?.some((h) => h.body.includes("pending msg"))).toBe(true);
521
+ }
522
+ });
523
+
524
+ it("retrying the same failed trigger reuses the original history window", async () => {
525
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
526
+ const { handler } = createFinalDeliveryFailureHandler(finalizeInboundContext);
527
+
528
+ await handler(
529
+ DEFAULT_ROOM,
530
+ makeRoomPlainEvent({ eventId: "$p", body: "pending msg", ts: 1000 }),
531
+ );
532
+
533
+ await handler(
534
+ DEFAULT_ROOM,
535
+ makeRoomTriggerEvent({ eventId: "$same", body: "trigger", ts: 2000 }),
536
+ );
537
+ await handler(
538
+ DEFAULT_ROOM,
539
+ makeRoomTriggerEvent({ eventId: "$same", body: "trigger", ts: 2000 }),
540
+ );
541
+
542
+ expect(finalizeInboundContext).toHaveBeenCalledTimes(2);
543
+ const firstHistory = (finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>)[
544
+ "InboundHistory"
545
+ ] as Array<{ body: string }>;
546
+ const retryHistory = (finalizeInboundContext.mock.calls[1]?.[0] as Record<string, unknown>)[
547
+ "InboundHistory"
548
+ ] as Array<{ body: string }>;
549
+
550
+ expect(firstHistory.map((entry) => entry.body)).toEqual(["pending msg"]);
551
+ expect(retryHistory.map((entry) => entry.body)).toEqual(["pending msg"]);
552
+ });
553
+
554
+ it("records pending history before sender-name lookup resolves", async () => {
555
+ let resolveFirstName: (() => void) | undefined;
556
+ let firstNameLookupStarted = false;
557
+ const getMemberDisplayName = vi.fn(async () => {
558
+ firstNameLookupStarted = true;
559
+ await new Promise<void>((resolve) => {
560
+ resolveFirstName = resolve;
561
+ });
562
+ return "sender";
563
+ });
564
+
565
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
566
+ const { handler } = createMatrixHandlerTestHarness({
567
+ historyLimit: 20,
568
+ groupPolicy: "open",
569
+ isDirectMessage: false,
570
+ getMemberDisplayName,
571
+ finalizeInboundContext,
572
+ dispatchReplyFromConfig: async () => ({
573
+ queuedFinal: true,
574
+ counts: { final: 1, block: 0, tool: 0 },
575
+ }),
576
+ });
577
+
578
+ // Unmentioned message should be buffered without waiting for async sender-name lookup.
579
+ await handler(
580
+ DEFAULT_ROOM,
581
+ makeRoomPlainEvent({ eventId: "$slow-name", body: "plain before trigger", ts: 1000 }),
582
+ );
583
+ expect(firstNameLookupStarted).toBe(false);
584
+
585
+ // Trigger reads pending history first, then can await sender-name lookup later.
586
+ const triggerDone = handler(
587
+ DEFAULT_ROOM,
588
+ makeRoomTriggerEvent({ eventId: "$trigger-after-slow-name", body: "trigger", ts: 2000 }),
589
+ );
590
+ await vi.waitFor(() => {
591
+ expect(firstNameLookupStarted).toBe(true);
592
+ });
593
+ resolveFirstName?.();
594
+ await triggerDone;
595
+
596
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
597
+ const history = ctx["InboundHistory"] as Array<{ body: string }> | undefined;
598
+ expect(history?.some((entry) => entry.body.includes("plain before trigger"))).toBe(true);
599
+ });
600
+
601
+ it("preserves arrival order when a plain message starts before a later trigger", async () => {
602
+ let releaseFirstGetUserId: (() => void) | undefined;
603
+ let getUserIdCalls = 0;
604
+
605
+ const finalizeInboundContext = vi.fn((ctx: unknown) => ctx);
606
+ const { handler } = createMatrixHandlerTestHarness({
607
+ historyLimit: 20,
608
+ groupPolicy: "open",
609
+ isDirectMessage: false,
610
+ client: {
611
+ async getUserId() {
612
+ getUserIdCalls += 1;
613
+ if (getUserIdCalls === 1) {
614
+ await new Promise<void>((resolve) => {
615
+ releaseFirstGetUserId = resolve;
616
+ });
617
+ }
618
+ return "@bot:example.org";
619
+ },
620
+ getEvent: async () => ({ sender: "@bot:example.org" }),
621
+ },
622
+ finalizeInboundContext,
623
+ dispatchReplyFromConfig: async () => ({
624
+ queuedFinal: true,
625
+ counts: { final: 1, block: 0, tool: 0 },
626
+ }),
627
+ });
628
+
629
+ const plainPromise = handler(
630
+ DEFAULT_ROOM,
631
+ makeRoomPlainEvent({ eventId: "$a", body: "msg A", ts: 1000 }),
632
+ );
633
+ await vi.waitFor(() => {
634
+ expect(releaseFirstGetUserId).toBeTypeOf("function");
635
+ });
636
+ const triggerPromise = handler(
637
+ DEFAULT_ROOM,
638
+ makeRoomTriggerEvent({ eventId: "$b", body: "msg B", ts: 2000 }),
639
+ );
640
+
641
+ releaseFirstGetUserId?.();
642
+ await Promise.all([plainPromise, triggerPromise]);
643
+
644
+ const ctx = finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
645
+ const history = ctx["InboundHistory"] as Array<{ body: string }>;
646
+ expect(history.map((entry) => entry.body)).toEqual(["msg A"]);
647
+ });
648
+ });