@katyella/legio 0.1.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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,1726 @@
1
+ /**
2
+ * Tests for the CLI mail command handlers.
3
+ *
4
+ * Tests CLI-level behavior like flag parsing and output formatting.
5
+ * Uses real SQLite databases in temp directories (no mocking).
6
+ */
7
+
8
+ import { access, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
12
+ import { createEventStore } from "../events/store.ts";
13
+ import { createMailClient } from "../mail/client.ts";
14
+ import { createMailStore } from "../mail/store.ts";
15
+ import type { StoredEvent } from "../types.ts";
16
+ import { mailCommand } from "./mail.ts";
17
+
18
+ describe("mailCommand", () => {
19
+ let tempDir: string;
20
+ let origCwd: string;
21
+ let origWrite: typeof process.stdout.write;
22
+ let origStderrWrite: typeof process.stderr.write;
23
+ let output: string;
24
+ let stderrOutput: string;
25
+
26
+ beforeEach(async () => {
27
+ tempDir = await mkdtemp(join(tmpdir(), "legio-mail-cmd-test-"));
28
+ await mkdir(join(tempDir, ".legio"), { recursive: true });
29
+
30
+ // Seed some messages via the store directly
31
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
32
+ const client = createMailClient(store);
33
+ client.send({
34
+ from: "orchestrator",
35
+ to: "builder-1",
36
+ subject: "Build task",
37
+ body: "Implement feature X",
38
+ });
39
+ client.send({
40
+ from: "orchestrator",
41
+ to: "scout-1",
42
+ subject: "Explore API",
43
+ body: "Investigate endpoints",
44
+ });
45
+ client.close();
46
+
47
+ // Change cwd to temp dir so the command finds .legio/mail.db
48
+ origCwd = process.cwd();
49
+ process.chdir(tempDir);
50
+
51
+ // Capture stdout
52
+ output = "";
53
+ origWrite = process.stdout.write;
54
+ process.stdout.write = ((chunk: string) => {
55
+ output += chunk;
56
+ return true;
57
+ }) as typeof process.stdout.write;
58
+
59
+ // Capture stderr
60
+ stderrOutput = "";
61
+ origStderrWrite = process.stderr.write;
62
+ process.stderr.write = ((chunk: string) => {
63
+ stderrOutput += chunk;
64
+ return true;
65
+ }) as typeof process.stderr.write;
66
+ });
67
+
68
+ afterEach(async () => {
69
+ process.stdout.write = origWrite;
70
+ process.stderr.write = origStderrWrite;
71
+ process.chdir(origCwd);
72
+ await rm(tempDir, { recursive: true, force: true });
73
+ });
74
+
75
+ describe("list", () => {
76
+ test("--unread shows all unread messages globally", async () => {
77
+ await mailCommand(["list", "--unread"]);
78
+ expect(output).toContain("Build task");
79
+ expect(output).toContain("Explore API");
80
+ expect(output).toContain("Total: 2 messages");
81
+ });
82
+
83
+ test("--agent filters by recipient (alias for --to)", async () => {
84
+ await mailCommand(["list", "--agent", "builder-1"]);
85
+ expect(output).toContain("Build task");
86
+ expect(output).not.toContain("Explore API");
87
+ expect(output).toContain("Total: 1 message");
88
+ });
89
+
90
+ test("--agent combined with --unread shows only unread for that agent", async () => {
91
+ // Mark builder-1's message as read
92
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
93
+ const client = createMailClient(store);
94
+ const msgs = client.list({ to: "builder-1" });
95
+ const msgId = msgs[0]?.id;
96
+ expect(msgId).toBeTruthy();
97
+ if (msgId) {
98
+ client.markRead(msgId);
99
+ }
100
+ client.close();
101
+
102
+ await mailCommand(["list", "--agent", "builder-1", "--unread"]);
103
+ expect(output).toContain("No messages found.");
104
+ });
105
+
106
+ test("--to takes precedence over --agent when both provided", async () => {
107
+ await mailCommand(["list", "--to", "scout-1", "--agent", "builder-1"]);
108
+ // --to is checked first via getFlag, so it should win
109
+ expect(output).toContain("Explore API");
110
+ expect(output).not.toContain("Build task");
111
+ });
112
+
113
+ test("list without filters shows all messages", async () => {
114
+ await mailCommand(["list"]);
115
+ expect(output).toContain("Build task");
116
+ expect(output).toContain("Explore API");
117
+ expect(output).toContain("Total: 2 messages");
118
+ });
119
+ });
120
+
121
+ describe("reply", () => {
122
+ test("reply to own sent message goes to original recipient", async () => {
123
+ // Get the message ID of the message orchestrator sent to builder-1
124
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
125
+ const client = createMailClient(store);
126
+ const msgs = client.list({ to: "builder-1" });
127
+ const originalId = msgs[0]?.id;
128
+ expect(originalId).toBeTruthy();
129
+ client.close();
130
+
131
+ if (!originalId) return;
132
+
133
+ // Reply as orchestrator (the original sender)
134
+ output = "";
135
+ await mailCommand(["reply", originalId, "--body", "Actually also do Y"]);
136
+
137
+ expect(output).toContain("Reply sent:");
138
+
139
+ // Verify the reply went to builder-1, not back to orchestrator
140
+ const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
141
+ const client2 = createMailClient(store2);
142
+ const allMsgs = client2.list();
143
+ const replyMsg = allMsgs.find((m) => m.subject === "Re: Build task");
144
+ expect(replyMsg).toBeDefined();
145
+ expect(replyMsg?.from).toBe("orchestrator");
146
+ expect(replyMsg?.to).toBe("builder-1");
147
+ client2.close();
148
+ });
149
+
150
+ test("reply as recipient goes to original sender", async () => {
151
+ // Get the message ID
152
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
153
+ const client = createMailClient(store);
154
+ const msgs = client.list({ to: "builder-1" });
155
+ const originalId = msgs[0]?.id;
156
+ expect(originalId).toBeTruthy();
157
+ client.close();
158
+
159
+ if (!originalId) return;
160
+
161
+ // Reply as builder-1 (the recipient of the original)
162
+ output = "";
163
+ await mailCommand(["reply", originalId, "--body", "Done", "--agent", "builder-1"]);
164
+
165
+ expect(output).toContain("Reply sent:");
166
+
167
+ // Verify the reply went to orchestrator (original sender)
168
+ const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
169
+ const client2 = createMailClient(store2);
170
+ const allMsgs = client2.list();
171
+ const replyMsg = allMsgs.find(
172
+ (m) => m.subject === "Re: Build task" && m.from === "builder-1",
173
+ );
174
+ expect(replyMsg).toBeDefined();
175
+ expect(replyMsg?.from).toBe("builder-1");
176
+ expect(replyMsg?.to).toBe("orchestrator");
177
+ client2.close();
178
+ });
179
+
180
+ test("reply with flags before positional ID extracts correct ID", async () => {
181
+ // Regression test for legio-6nq: flags before the positional ID
182
+ // caused the flag VALUE (e.g. 'scout') to be treated as the message ID.
183
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
184
+ const client = createMailClient(store);
185
+ const msgs = client.list({ to: "builder-1" });
186
+ const originalId = msgs[0]?.id;
187
+ expect(originalId).toBeTruthy();
188
+ client.close();
189
+
190
+ if (!originalId) return;
191
+
192
+ // Put --agent and --body flags BEFORE the positional message ID
193
+ output = "";
194
+ await mailCommand(["reply", "--agent", "scout-1", "--body", "Got it", originalId]);
195
+
196
+ expect(output).toContain("Reply sent:");
197
+
198
+ // Verify the reply used the correct message ID (not 'scout-1' or 'Got it')
199
+ const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
200
+ const client2 = createMailClient(store2);
201
+ const allMsgs = client2.list();
202
+ const replyMsg = allMsgs.find((m) => m.subject === "Re: Build task" && m.from === "scout-1");
203
+ expect(replyMsg).toBeDefined();
204
+ expect(replyMsg?.body).toBe("Got it");
205
+ client2.close();
206
+ });
207
+ });
208
+
209
+ describe("read", () => {
210
+ test("read with flags before positional ID extracts correct ID", async () => {
211
+ // Regression test for legio-6nq: same fragile pattern existed in handleRead.
212
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
213
+ const client = createMailClient(store);
214
+ const msgs = client.list({ to: "builder-1" });
215
+ const originalId = msgs[0]?.id;
216
+ expect(originalId).toBeTruthy();
217
+ client.close();
218
+
219
+ if (!originalId) return;
220
+
221
+ // Although read doesn't currently use --agent, test that any unknown
222
+ // flags followed by values don't get treated as the positional ID
223
+ output = "";
224
+ await mailCommand(["read", originalId]);
225
+
226
+ expect(output).toContain(`Marked ${originalId} as read.`);
227
+ });
228
+
229
+ test("read marks message as read", async () => {
230
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
231
+ const client = createMailClient(store);
232
+ const msgs = client.list({ to: "builder-1" });
233
+ const originalId = msgs[0]?.id;
234
+ expect(originalId).toBeTruthy();
235
+ client.close();
236
+
237
+ if (!originalId) return;
238
+
239
+ output = "";
240
+ await mailCommand(["read", originalId]);
241
+ expect(output).toContain(`Marked ${originalId} as read.`);
242
+
243
+ // Reading again should show already read
244
+ output = "";
245
+ await mailCommand(["read", originalId]);
246
+ expect(output).toContain("already read");
247
+ });
248
+ });
249
+
250
+ describe("auto-nudge (pending nudge markers)", () => {
251
+ test("urgent message writes pending nudge marker instead of tmux keys", async () => {
252
+ await mailCommand([
253
+ "send",
254
+ "--to",
255
+ "builder-1",
256
+ "--subject",
257
+ "Fix NOW",
258
+ "--body",
259
+ "Production is down",
260
+ "--priority",
261
+ "urgent",
262
+ ]);
263
+
264
+ // Verify pending nudge marker was written
265
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "builder-1.json");
266
+ {
267
+ let _e = false;
268
+ try {
269
+ await access(markerPath);
270
+ _e = true;
271
+ } catch {}
272
+ expect(_e).toBe(true);
273
+ }
274
+
275
+ const marker = JSON.parse(await readFile(markerPath, "utf-8"));
276
+ expect(marker.from).toBe("orchestrator");
277
+ expect(marker.reason).toBe("status");
278
+ expect(marker.subject).toBe("Fix NOW");
279
+ expect(marker.messageId).toBeTruthy();
280
+ expect(marker.createdAt).toBeTruthy();
281
+
282
+ // Output should mention queued nudge, not direct delivery
283
+ expect(output).toContain("Queued nudge");
284
+ expect(output).toContain("delivered on next prompt");
285
+ });
286
+
287
+ test("high priority message writes pending nudge marker", async () => {
288
+ await mailCommand([
289
+ "send",
290
+ "--to",
291
+ "scout-1",
292
+ "--subject",
293
+ "Important task",
294
+ "--body",
295
+ "Please prioritize",
296
+ "--priority",
297
+ "high",
298
+ ]);
299
+
300
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "scout-1.json");
301
+ {
302
+ let _e = false;
303
+ try {
304
+ await access(markerPath);
305
+ _e = true;
306
+ } catch {}
307
+ expect(_e).toBe(true);
308
+ }
309
+
310
+ const marker = JSON.parse(await readFile(markerPath, "utf-8"));
311
+ expect(marker.reason).toBe("status");
312
+ });
313
+
314
+ test("worker_done type writes pending nudge marker regardless of priority", async () => {
315
+ await mailCommand([
316
+ "send",
317
+ "--to",
318
+ "orchestrator",
319
+ "--subject",
320
+ "Task complete",
321
+ "--body",
322
+ "Builder finished",
323
+ "--type",
324
+ "worker_done",
325
+ "--from",
326
+ "builder-1",
327
+ ]);
328
+
329
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "orchestrator.json");
330
+ {
331
+ let _e = false;
332
+ try {
333
+ await access(markerPath);
334
+ _e = true;
335
+ } catch {}
336
+ expect(_e).toBe(true);
337
+ }
338
+
339
+ const marker = JSON.parse(await readFile(markerPath, "utf-8"));
340
+ expect(marker.reason).toBe("worker_done");
341
+ expect(marker.from).toBe("builder-1");
342
+ });
343
+
344
+ test("normal priority message writes pending nudge marker (always-nudge)", async () => {
345
+ await mailCommand(["send", "--to", "builder-1", "--subject", "FYI", "--body", "Just a note"]);
346
+
347
+ // All messages now write a pending nudge marker regardless of type/priority
348
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "builder-1.json");
349
+ {
350
+ let exists = false;
351
+ try {
352
+ await access(markerPath);
353
+ exists = true;
354
+ } catch {}
355
+ expect(exists).toBe(true);
356
+ }
357
+ const marker = JSON.parse(await readFile(markerPath, "utf-8")) as { reason: string };
358
+ expect(marker.reason).toBe("status");
359
+ });
360
+
361
+ test("mail check --inject surfaces pending nudge banner", async () => {
362
+ // Send an urgent message to create a pending nudge marker
363
+ await mailCommand([
364
+ "send",
365
+ "--to",
366
+ "builder-1",
367
+ "--subject",
368
+ "Critical fix",
369
+ "--body",
370
+ "Deploy hotfix",
371
+ "--priority",
372
+ "urgent",
373
+ ]);
374
+
375
+ // Now check as builder-1 with --inject
376
+ output = "";
377
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
378
+
379
+ // Should contain the priority banner from the pending nudge
380
+ // reason is now the message type (default "status"), not the priority level
381
+ expect(output).toContain("PRIORITY");
382
+ expect(output).toContain("status");
383
+ expect(output).toContain("Critical fix");
384
+
385
+ // Should also contain the actual message (from mail check)
386
+ expect(output).toContain("Deploy hotfix");
387
+ });
388
+
389
+ test("pending nudge marker is cleared after mail check --inject", async () => {
390
+ // Send urgent message
391
+ await mailCommand([
392
+ "send",
393
+ "--to",
394
+ "builder-1",
395
+ "--subject",
396
+ "Fix it",
397
+ "--body",
398
+ "Broken",
399
+ "--priority",
400
+ "urgent",
401
+ ]);
402
+
403
+ // First check clears the marker
404
+ output = "";
405
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
406
+ expect(output).toContain("PRIORITY");
407
+
408
+ // Second check should NOT have the priority banner
409
+ output = "";
410
+ await mailCommand(["check", "--inject", "--agent", "builder-1"]);
411
+ expect(output).not.toContain("PRIORITY");
412
+ });
413
+
414
+ test("json output for auto-nudge send does not include nudge banner", async () => {
415
+ await mailCommand([
416
+ "send",
417
+ "--to",
418
+ "builder-1",
419
+ "--subject",
420
+ "Urgent",
421
+ "--body",
422
+ "Fix",
423
+ "--priority",
424
+ "urgent",
425
+ "--json",
426
+ ]);
427
+
428
+ // JSON output should just have the message ID, not the nudge banner text
429
+ const parsed = JSON.parse(output.trim());
430
+ expect(parsed.id).toBeTruthy();
431
+ expect(output).not.toContain("Queued nudge");
432
+ });
433
+ });
434
+
435
+ describe("mail_sent event recording", () => {
436
+ test("mail send records mail_sent event to events.db", async () => {
437
+ await mailCommand([
438
+ "send",
439
+ "--to",
440
+ "builder-1",
441
+ "--subject",
442
+ "Test event",
443
+ "--body",
444
+ "Check events",
445
+ ]);
446
+
447
+ // Verify event was recorded
448
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
449
+ const store = createEventStore(eventsDbPath);
450
+ try {
451
+ const events: StoredEvent[] = store.getTimeline({
452
+ since: "2000-01-01T00:00:00Z",
453
+ });
454
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
455
+ expect(mailEvent).toBeDefined();
456
+ expect(mailEvent?.level).toBe("info");
457
+ expect(mailEvent?.agentName).toBe("orchestrator");
458
+
459
+ const data = JSON.parse(mailEvent?.data ?? "{}") as Record<string, unknown>;
460
+ expect(data.to).toBe("builder-1");
461
+ expect(data.subject).toBe("Test event");
462
+ expect(data.type).toBe("status");
463
+ expect(data.priority).toBe("normal");
464
+ expect(data.messageId).toBeTruthy();
465
+ } finally {
466
+ store.close();
467
+ }
468
+ });
469
+
470
+ test("mail send with custom --from records correct agentName", async () => {
471
+ await mailCommand([
472
+ "send",
473
+ "--to",
474
+ "orchestrator",
475
+ "--subject",
476
+ "Done",
477
+ "--body",
478
+ "Finished task",
479
+ "--from",
480
+ "builder-1",
481
+ "--type",
482
+ "worker_done",
483
+ ]);
484
+
485
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
486
+ const store = createEventStore(eventsDbPath);
487
+ try {
488
+ const events: StoredEvent[] = store.getTimeline({
489
+ since: "2000-01-01T00:00:00Z",
490
+ });
491
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
492
+ expect(mailEvent).toBeDefined();
493
+ expect(mailEvent?.agentName).toBe("builder-1");
494
+
495
+ const data = JSON.parse(mailEvent?.data ?? "{}") as Record<string, unknown>;
496
+ expect(data.to).toBe("orchestrator");
497
+ expect(data.type).toBe("worker_done");
498
+ } finally {
499
+ store.close();
500
+ }
501
+ });
502
+
503
+ test("mail send includes run_id when current-run.txt exists", async () => {
504
+ const runId = "run-test-mail-456";
505
+ await writeFile(join(tempDir, ".legio", "current-run.txt"), runId);
506
+
507
+ await mailCommand([
508
+ "send",
509
+ "--to",
510
+ "builder-1",
511
+ "--subject",
512
+ "With run ID",
513
+ "--body",
514
+ "Test",
515
+ ]);
516
+
517
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
518
+ const store = createEventStore(eventsDbPath);
519
+ try {
520
+ const events: StoredEvent[] = store.getTimeline({
521
+ since: "2000-01-01T00:00:00Z",
522
+ });
523
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
524
+ expect(mailEvent).toBeDefined();
525
+ expect(mailEvent?.runId).toBe(runId);
526
+ } finally {
527
+ store.close();
528
+ }
529
+ });
530
+
531
+ test("mail send without current-run.txt records null runId", async () => {
532
+ await mailCommand(["send", "--to", "builder-1", "--subject", "No run", "--body", "Test"]);
533
+
534
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
535
+ const store = createEventStore(eventsDbPath);
536
+ try {
537
+ const events: StoredEvent[] = store.getTimeline({
538
+ since: "2000-01-01T00:00:00Z",
539
+ });
540
+ const mailEvent = events.find((e) => e.eventType === "mail_sent");
541
+ expect(mailEvent).toBeDefined();
542
+ expect(mailEvent?.runId).toBeNull();
543
+ } finally {
544
+ store.close();
545
+ }
546
+ });
547
+ });
548
+
549
+ describe("mail check debounce", () => {
550
+ test("mail check without --debounce flag always executes", async () => {
551
+ // Send first message
552
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
553
+ const client = createMailClient(store);
554
+ client.send({
555
+ from: "orchestrator",
556
+ to: "test-agent",
557
+ subject: "Message 1",
558
+ body: "First message",
559
+ });
560
+ client.close();
561
+
562
+ // First check
563
+ output = "";
564
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
565
+ const firstOutput = output;
566
+
567
+ // Send second message
568
+ const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
569
+ const client2 = createMailClient(store2);
570
+ client2.send({
571
+ from: "orchestrator",
572
+ to: "test-agent",
573
+ subject: "Message 2",
574
+ body: "Second message",
575
+ });
576
+ client2.close();
577
+
578
+ // Second check immediately after
579
+ output = "";
580
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
581
+ const secondOutput = output;
582
+
583
+ // Both should execute (no debouncing without flag)
584
+ expect(firstOutput).toContain("Message 1");
585
+ expect(secondOutput).toContain("Message 2");
586
+ });
587
+
588
+ test("mail check with --debounce skips second check within window", async () => {
589
+ // First check with debounce (large window to survive concurrency)
590
+ output = "";
591
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
592
+ expect(output).toContain("Build task");
593
+
594
+ // Second check immediately (within debounce window)
595
+ output = "";
596
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
597
+ // Should be skipped silently
598
+ expect(output).toBe("");
599
+ });
600
+
601
+ test("mail check with --debounce allows check after window expires", async () => {
602
+ // Send first message
603
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
604
+ const client = createMailClient(store);
605
+ client.send({
606
+ from: "orchestrator",
607
+ to: "debounce-test",
608
+ subject: "First",
609
+ body: "First check",
610
+ });
611
+ client.close();
612
+
613
+ // First check with debounce
614
+ output = "";
615
+ await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
616
+ expect(output).toContain("First check");
617
+
618
+ // Wait for debounce window to expire
619
+ await new Promise((resolve) => setTimeout(resolve, 150));
620
+
621
+ // Send second message
622
+ const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
623
+ const client2 = createMailClient(store2);
624
+ client2.send({
625
+ from: "orchestrator",
626
+ to: "debounce-test",
627
+ subject: "Second",
628
+ body: "Second check",
629
+ });
630
+ client2.close();
631
+
632
+ // Second check after debounce window
633
+ output = "";
634
+ await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
635
+ expect(output).toContain("Second check");
636
+ });
637
+
638
+ test("mail check with --debounce 0 disables debouncing", async () => {
639
+ // Send first message
640
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
641
+ const client = createMailClient(store);
642
+ client.send({
643
+ from: "orchestrator",
644
+ to: "zero-debounce",
645
+ subject: "Msg 1",
646
+ body: "Message one",
647
+ });
648
+ client.close();
649
+
650
+ // First check with --debounce 0
651
+ output = "";
652
+ await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
653
+ expect(output).toContain("Message one");
654
+
655
+ // Send second message immediately
656
+ const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
657
+ const client2 = createMailClient(store2);
658
+ client2.send({
659
+ from: "orchestrator",
660
+ to: "zero-debounce",
661
+ subject: "Msg 2",
662
+ body: "Message two",
663
+ });
664
+ client2.close();
665
+
666
+ // Second check immediately (should work with debounce 0)
667
+ output = "";
668
+ await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
669
+ expect(output).toContain("Message two");
670
+ });
671
+
672
+ test("mail check debounce is per-agent", async () => {
673
+ // Check for builder-1 with debounce (large window to survive concurrency)
674
+ output = "";
675
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
676
+ expect(output).toContain("Build task");
677
+
678
+ // Check for scout-1 immediately (different agent, should NOT be debounced)
679
+ output = "";
680
+ await mailCommand(["check", "--agent", "scout-1", "--debounce", "10000"]);
681
+ expect(output).toContain("Explore API");
682
+
683
+ // Check for builder-1 again (should be debounced)
684
+ output = "";
685
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
686
+ expect(output).toBe("");
687
+ });
688
+
689
+ test("mail check --debounce with invalid value throws ValidationError", async () => {
690
+ try {
691
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "invalid"]);
692
+ expect(true).toBe(false); // Should not reach here
693
+ } catch (err) {
694
+ expect(err).toBeInstanceOf(Error);
695
+ if (err instanceof Error) {
696
+ expect(err.message).toContain("must be a non-negative integer");
697
+ }
698
+ }
699
+ });
700
+
701
+ test("mail check --debounce with negative value throws ValidationError", async () => {
702
+ try {
703
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "-100"]);
704
+ expect(true).toBe(false);
705
+ } catch (err) {
706
+ expect(err).toBeInstanceOf(Error);
707
+ if (err instanceof Error) {
708
+ expect(err.message).toContain("must be a non-negative integer");
709
+ }
710
+ }
711
+ });
712
+
713
+ test("mail check --inject with --debounce skips check within window", async () => {
714
+ // First inject check with debounce
715
+ output = "";
716
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
717
+ expect(output).toContain("Build task");
718
+
719
+ // Second inject check immediately (should be debounced)
720
+ output = "";
721
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
722
+ expect(output).toBe("");
723
+ });
724
+
725
+ test("mail check debounce state persists across invocations", async () => {
726
+ // First check
727
+ output = "";
728
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
729
+ expect(output).toContain("Build task");
730
+
731
+ // Verify state file was created
732
+ const statePath = join(tempDir, ".legio", "mail-check-state.json");
733
+ {
734
+ let _e = false;
735
+ try {
736
+ await access(statePath);
737
+ _e = true;
738
+ } catch {}
739
+ expect(_e).toBe(true);
740
+ }
741
+
742
+ const state = JSON.parse(await readFile(statePath, "utf-8")) as Record<string, number>;
743
+ expect(state["builder-1"]).toBeTruthy();
744
+ expect(typeof state["builder-1"]).toBe("number");
745
+ });
746
+
747
+ test("corrupted debounce state file is handled gracefully", async () => {
748
+ // Write corrupted state file
749
+ const statePath = join(tempDir, ".legio", "mail-check-state.json");
750
+ await writeFile(statePath, "not valid json");
751
+
752
+ // Should not throw, should treat as fresh state
753
+ output = "";
754
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
755
+ expect(output).toContain("Build task");
756
+
757
+ // State should be corrected
758
+ const state = JSON.parse(await readFile(statePath, "utf-8")) as Record<string, number>;
759
+ expect(state["builder-1"]).toBeTruthy();
760
+ });
761
+
762
+ test("mail check debounce only records timestamp when flag is provided", async () => {
763
+ const statePath = join(tempDir, ".legio", "mail-check-state.json");
764
+
765
+ // Check without debounce flag
766
+ await mailCommand(["check", "--agent", "builder-1"]);
767
+
768
+ // State file should not be created
769
+ await expect(access(statePath)).rejects.toThrow();
770
+
771
+ // Check with debounce flag
772
+ await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
773
+
774
+ // Now state file should exist
775
+ {
776
+ let _e = false;
777
+ try {
778
+ await access(statePath);
779
+ _e = true;
780
+ } catch {}
781
+ expect(_e).toBe(true);
782
+ }
783
+ });
784
+ });
785
+
786
+ describe("broadcast", () => {
787
+ // Helper to create active agent sessions for broadcast testing
788
+ async function seedActiveSessions(): Promise<void> {
789
+ const { createSessionStore } = await import("../sessions/store.ts");
790
+ const sessionsDbPath = join(tempDir, ".legio", "sessions.db");
791
+ const sessionStore = createSessionStore(sessionsDbPath);
792
+
793
+ const sessions = [
794
+ {
795
+ id: "session-orchestrator",
796
+ agentName: "orchestrator",
797
+ capability: "coordinator",
798
+ worktreePath: "/worktrees/orchestrator",
799
+ branchName: "main",
800
+ beadId: "bead-001",
801
+ tmuxSession: "legio-test-orchestrator",
802
+ state: "working" as const,
803
+ pid: 12345,
804
+ parentAgent: null,
805
+ depth: 0,
806
+ runId: "run-001",
807
+ startedAt: new Date().toISOString(),
808
+ lastActivity: new Date().toISOString(),
809
+ escalationLevel: 0,
810
+ stalledSince: null,
811
+ },
812
+ {
813
+ id: "session-builder-1",
814
+ agentName: "builder-1",
815
+ capability: "builder",
816
+ worktreePath: "/worktrees/builder-1",
817
+ branchName: "builder-1",
818
+ beadId: "bead-002",
819
+ tmuxSession: "legio-test-builder-1",
820
+ state: "working" as const,
821
+ pid: 12346,
822
+ parentAgent: "orchestrator",
823
+ depth: 1,
824
+ runId: "run-001",
825
+ startedAt: new Date().toISOString(),
826
+ lastActivity: new Date().toISOString(),
827
+ escalationLevel: 0,
828
+ stalledSince: null,
829
+ },
830
+ {
831
+ id: "session-builder-2",
832
+ agentName: "builder-2",
833
+ capability: "builder",
834
+ worktreePath: "/worktrees/builder-2",
835
+ branchName: "builder-2",
836
+ beadId: "bead-003",
837
+ tmuxSession: "legio-test-builder-2",
838
+ state: "working" as const,
839
+ pid: 12347,
840
+ parentAgent: "orchestrator",
841
+ depth: 1,
842
+ runId: "run-001",
843
+ startedAt: new Date().toISOString(),
844
+ lastActivity: new Date().toISOString(),
845
+ escalationLevel: 0,
846
+ stalledSince: null,
847
+ },
848
+ {
849
+ id: "session-scout-1",
850
+ agentName: "scout-1",
851
+ capability: "scout",
852
+ worktreePath: "/worktrees/scout-1",
853
+ branchName: "scout-1",
854
+ beadId: "bead-004",
855
+ tmuxSession: "legio-test-scout-1",
856
+ state: "working" as const,
857
+ pid: 12348,
858
+ parentAgent: "orchestrator",
859
+ depth: 1,
860
+ runId: "run-001",
861
+ startedAt: new Date().toISOString(),
862
+ lastActivity: new Date().toISOString(),
863
+ escalationLevel: 0,
864
+ stalledSince: null,
865
+ },
866
+ ];
867
+
868
+ for (const session of sessions) {
869
+ sessionStore.upsert(session);
870
+ }
871
+
872
+ sessionStore.close();
873
+ }
874
+
875
+ test("@all broadcasts to all active agents except sender", async () => {
876
+ await seedActiveSessions();
877
+
878
+ output = "";
879
+ await mailCommand([
880
+ "send",
881
+ "--to",
882
+ "@all",
883
+ "--subject",
884
+ "Team update",
885
+ "--body",
886
+ "Important announcement",
887
+ ]);
888
+
889
+ expect(output).toContain("Broadcast sent to 3 recipients (@all)");
890
+ expect(output).toContain("→ builder-1");
891
+ expect(output).toContain("→ builder-2");
892
+ expect(output).toContain("→ scout-1");
893
+ expect(output).not.toContain("orchestrator"); // sender excluded
894
+
895
+ // Verify messages were actually stored
896
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
897
+ const client = createMailClient(store);
898
+ const messages = client.list();
899
+ const broadcastMsgs = messages.filter((m) => m.subject === "Team update");
900
+ expect(broadcastMsgs.length).toBe(3);
901
+ expect(broadcastMsgs.map((m) => m.to).sort()).toEqual(["builder-1", "builder-2", "scout-1"]);
902
+ client.close();
903
+ });
904
+
905
+ test("@builders broadcasts to all builder agents", async () => {
906
+ await seedActiveSessions();
907
+
908
+ output = "";
909
+ await mailCommand([
910
+ "send",
911
+ "--to",
912
+ "@builders",
913
+ "--subject",
914
+ "Builder update",
915
+ "--body",
916
+ "Build instructions",
917
+ ]);
918
+
919
+ expect(output).toContain("Broadcast sent to 2 recipients (@builders)");
920
+ expect(output).toContain("→ builder-1");
921
+ expect(output).toContain("→ builder-2");
922
+ expect(output).not.toContain("scout-1");
923
+
924
+ // Verify messages
925
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
926
+ const client = createMailClient(store);
927
+ const messages = client.list();
928
+ const broadcastMsgs = messages.filter((m) => m.subject === "Builder update");
929
+ expect(broadcastMsgs.length).toBe(2);
930
+ client.close();
931
+ });
932
+
933
+ test("@scouts broadcasts to all scout agents", async () => {
934
+ await seedActiveSessions();
935
+
936
+ output = "";
937
+ await mailCommand([
938
+ "send",
939
+ "--to",
940
+ "@scouts",
941
+ "--subject",
942
+ "Scout task",
943
+ "--body",
944
+ "Explore this area",
945
+ ]);
946
+
947
+ expect(output).toContain("Broadcast sent to 1 recipient (@scouts)");
948
+ expect(output).toContain("→ scout-1");
949
+
950
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
951
+ const client = createMailClient(store);
952
+ const messages = client.list();
953
+ const broadcastMsgs = messages.filter((m) => m.subject === "Scout task");
954
+ expect(broadcastMsgs.length).toBe(1);
955
+ expect(broadcastMsgs[0]?.to).toBe("scout-1");
956
+ client.close();
957
+ });
958
+
959
+ test("singular alias @builder works same as @builders", async () => {
960
+ await seedActiveSessions();
961
+
962
+ output = "";
963
+ await mailCommand([
964
+ "send",
965
+ "--to",
966
+ "@builder",
967
+ "--subject",
968
+ "Builder task",
969
+ "--body",
970
+ "Singular alias test",
971
+ ]);
972
+
973
+ expect(output).toContain("Broadcast sent to 2 recipients (@builder)");
974
+ expect(output).toContain("→ builder-1");
975
+ expect(output).toContain("→ builder-2");
976
+ });
977
+
978
+ test("sender is excluded from broadcast recipients", async () => {
979
+ await seedActiveSessions();
980
+
981
+ output = "";
982
+ await mailCommand([
983
+ "send",
984
+ "--to",
985
+ "@builders",
986
+ "--from",
987
+ "builder-1",
988
+ "--subject",
989
+ "Peer message",
990
+ "--body",
991
+ "Message from builder-1",
992
+ ]);
993
+
994
+ expect(output).toContain("Broadcast sent to 1 recipient (@builders)");
995
+ expect(output).toContain("→ builder-2");
996
+ expect(output).not.toContain("builder-1");
997
+
998
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
999
+ const client = createMailClient(store);
1000
+ const messages = client.list();
1001
+ const broadcastMsgs = messages.filter((m) => m.subject === "Peer message");
1002
+ expect(broadcastMsgs.length).toBe(1);
1003
+ expect(broadcastMsgs[0]?.to).toBe("builder-2");
1004
+ client.close();
1005
+ });
1006
+
1007
+ test("throws when group resolves to zero recipients", async () => {
1008
+ await seedActiveSessions();
1009
+
1010
+ // @all from all agents (impossible — at least one agent needed)
1011
+ // Instead, test a capability group with no members
1012
+ let error: Error | null = null;
1013
+ try {
1014
+ await mailCommand(["send", "--to", "@reviewers", "--subject", "Test", "--body", "Body"]);
1015
+ } catch (e) {
1016
+ error = e as Error;
1017
+ }
1018
+
1019
+ expect(error).toBeTruthy();
1020
+ expect(error?.message).toContain("resolved to zero recipients");
1021
+ });
1022
+
1023
+ test("throws when group is unknown", async () => {
1024
+ await seedActiveSessions();
1025
+
1026
+ let error: Error | null = null;
1027
+ try {
1028
+ await mailCommand(["send", "--to", "@unknown", "--subject", "Test", "--body", "Body"]);
1029
+ } catch (e) {
1030
+ error = e as Error;
1031
+ }
1032
+
1033
+ expect(error).toBeTruthy();
1034
+ expect(error?.message).toContain("Unknown group address");
1035
+ });
1036
+
1037
+ test("broadcast with --json outputs message IDs and recipient count", async () => {
1038
+ await seedActiveSessions();
1039
+
1040
+ output = "";
1041
+ await mailCommand([
1042
+ "send",
1043
+ "--to",
1044
+ "@builders",
1045
+ "--subject",
1046
+ "Test",
1047
+ "--body",
1048
+ "Body",
1049
+ "--json",
1050
+ ]);
1051
+
1052
+ const result = JSON.parse(output) as { messageIds: string[]; recipientCount: number };
1053
+ expect(result.messageIds).toBeInstanceOf(Array);
1054
+ expect(result.messageIds.length).toBe(2);
1055
+ expect(result.recipientCount).toBe(2);
1056
+ });
1057
+
1058
+ test("broadcast records event for each individual message", async () => {
1059
+ await seedActiveSessions();
1060
+
1061
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1062
+ const eventStore = createEventStore(eventsDbPath);
1063
+ eventStore.close(); // Just to initialize the DB
1064
+
1065
+ output = "";
1066
+ await mailCommand(["send", "--to", "@builders", "--subject", "Test", "--body", "Body"]);
1067
+
1068
+ // Check events by agent (orchestrator is the sender)
1069
+ const eventStore2 = createEventStore(eventsDbPath);
1070
+ const events = eventStore2.getByAgent("orchestrator");
1071
+ eventStore2.close();
1072
+
1073
+ const mailSentEvents = events.filter((e) => e.eventType === "mail_sent");
1074
+ expect(mailSentEvents.length).toBe(2);
1075
+ for (const evt of mailSentEvents) {
1076
+ expect(evt.eventType).toBe("mail_sent");
1077
+ const data = JSON.parse(evt.data ?? "{}") as {
1078
+ to: string;
1079
+ broadcast: boolean;
1080
+ };
1081
+ expect(data.broadcast).toBe(true);
1082
+ expect(["builder-1", "builder-2"]).toContain(data.to);
1083
+ }
1084
+ });
1085
+
1086
+ test("broadcast with urgent priority writes pending nudge for each recipient", async () => {
1087
+ await seedActiveSessions();
1088
+
1089
+ output = "";
1090
+ await mailCommand([
1091
+ "send",
1092
+ "--to",
1093
+ "@builders",
1094
+ "--subject",
1095
+ "Urgent task",
1096
+ "--body",
1097
+ "Do this now",
1098
+ "--priority",
1099
+ "urgent",
1100
+ ]);
1101
+
1102
+ // Check pending nudge markers
1103
+ const nudgesDir = join(tempDir, ".legio", "pending-nudges");
1104
+ const nudgeFiles = await readdir(nudgesDir);
1105
+ expect(nudgeFiles).toContain("builder-1.json");
1106
+ expect(nudgeFiles).toContain("builder-2.json");
1107
+
1108
+ // Verify nudge content
1109
+ const nudge1 = JSON.parse(await readFile(join(nudgesDir, "builder-1.json"), "utf-8")) as {
1110
+ reason: string;
1111
+ subject: string;
1112
+ };
1113
+ expect(nudge1.reason).toBe("status");
1114
+ expect(nudge1.subject).toBe("Urgent task");
1115
+ });
1116
+
1117
+ test("broadcast with auto-nudge type writes pending nudge for each recipient", async () => {
1118
+ await seedActiveSessions();
1119
+
1120
+ output = "";
1121
+ await mailCommand([
1122
+ "send",
1123
+ "--to",
1124
+ "@builders",
1125
+ "--subject",
1126
+ "Error occurred",
1127
+ "--body",
1128
+ "Something went wrong",
1129
+ "--type",
1130
+ "error",
1131
+ ]);
1132
+
1133
+ // Check pending nudge markers
1134
+ const nudgesDir = join(tempDir, ".legio", "pending-nudges");
1135
+ const nudgeFiles = await readdir(nudgesDir);
1136
+ expect(nudgeFiles).toContain("builder-1.json");
1137
+ expect(nudgeFiles).toContain("builder-2.json");
1138
+
1139
+ const nudge1 = JSON.parse(await readFile(join(nudgesDir, "builder-1.json"), "utf-8")) as {
1140
+ reason: string;
1141
+ };
1142
+ expect(nudge1.reason).toBe("error");
1143
+ });
1144
+ });
1145
+
1146
+ describe("merge_ready reviewer validation", () => {
1147
+ // Helper to set up sessions in sessions.db
1148
+ async function seedSessions(
1149
+ sessions: Array<{
1150
+ agentName: string;
1151
+ capability: string;
1152
+ parentAgent: string | null;
1153
+ }>,
1154
+ ): Promise<void> {
1155
+ const { createSessionStore } = await import("../sessions/store.ts");
1156
+ const sessionsDbPath = join(tempDir, ".legio", "sessions.db");
1157
+ const sessionStore = createSessionStore(sessionsDbPath);
1158
+
1159
+ for (const [idx, session] of sessions.entries()) {
1160
+ sessionStore.upsert({
1161
+ id: `session-${idx}`,
1162
+ agentName: session.agentName,
1163
+ capability: session.capability as
1164
+ | "builder"
1165
+ | "reviewer"
1166
+ | "scout"
1167
+ | "coordinator"
1168
+ | "lead"
1169
+ | "merger"
1170
+ | "supervisor"
1171
+ | "monitor",
1172
+ worktreePath: `/worktrees/${session.agentName}`,
1173
+ branchName: session.agentName,
1174
+ beadId: `bead-${idx}`,
1175
+ tmuxSession: `legio-test-${session.agentName}`,
1176
+ state: "working" as const,
1177
+ pid: 10000 + idx,
1178
+ parentAgent: session.parentAgent,
1179
+ depth: 1,
1180
+ runId: "run-001",
1181
+ startedAt: new Date().toISOString(),
1182
+ lastActivity: new Date().toISOString(),
1183
+ escalationLevel: 0,
1184
+ stalledSince: null,
1185
+ });
1186
+ }
1187
+
1188
+ sessionStore.close();
1189
+ }
1190
+
1191
+ test("merge_ready with no reviewers emits warning", async () => {
1192
+ await seedSessions([
1193
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1194
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1195
+ ]);
1196
+
1197
+ output = "";
1198
+ stderrOutput = "";
1199
+ await mailCommand([
1200
+ "send",
1201
+ "--to",
1202
+ "coordinator",
1203
+ "--subject",
1204
+ "Ready to merge",
1205
+ "--body",
1206
+ "All builders complete",
1207
+ "--type",
1208
+ "merge_ready",
1209
+ "--from",
1210
+ "lead-1",
1211
+ ]);
1212
+
1213
+ // Verify warning on stderr
1214
+ expect(stderrOutput).toContain("WARNING");
1215
+ expect(stderrOutput).toContain("NO reviewer sessions found");
1216
+ expect(stderrOutput).toContain("lead-1");
1217
+ expect(stderrOutput).toContain("2 builder(s)");
1218
+ expect(stderrOutput).toContain("review-before-merge requirement");
1219
+ expect(stderrOutput).toContain("REVIEW_SKIP");
1220
+ });
1221
+
1222
+ test("merge_ready with partial reviewers emits note", async () => {
1223
+ await seedSessions([
1224
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1225
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1226
+ { agentName: "builder-3", capability: "builder", parentAgent: "lead-1" },
1227
+ { agentName: "reviewer-1", capability: "reviewer", parentAgent: "lead-1" },
1228
+ ]);
1229
+
1230
+ output = "";
1231
+ stderrOutput = "";
1232
+ await mailCommand([
1233
+ "send",
1234
+ "--to",
1235
+ "coordinator",
1236
+ "--subject",
1237
+ "Ready to merge",
1238
+ "--body",
1239
+ "Partial review complete",
1240
+ "--type",
1241
+ "merge_ready",
1242
+ "--from",
1243
+ "lead-1",
1244
+ ]);
1245
+
1246
+ // Verify note on stderr
1247
+ expect(stderrOutput).toContain("NOTE");
1248
+ expect(stderrOutput).toContain("Only 1 reviewer(s) for 3 builder(s)");
1249
+ expect(stderrOutput).toContain("review-verified");
1250
+ });
1251
+
1252
+ test("merge_ready with full coverage emits no warning", async () => {
1253
+ await seedSessions([
1254
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1255
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1256
+ { agentName: "reviewer-1", capability: "reviewer", parentAgent: "lead-1" },
1257
+ { agentName: "reviewer-2", capability: "reviewer", parentAgent: "lead-1" },
1258
+ ]);
1259
+
1260
+ output = "";
1261
+ stderrOutput = "";
1262
+ await mailCommand([
1263
+ "send",
1264
+ "--to",
1265
+ "coordinator",
1266
+ "--subject",
1267
+ "Ready to merge",
1268
+ "--body",
1269
+ "Full review complete",
1270
+ "--type",
1271
+ "merge_ready",
1272
+ "--from",
1273
+ "lead-1",
1274
+ ]);
1275
+
1276
+ // No warning should be emitted
1277
+ expect(stderrOutput).toBe("");
1278
+ });
1279
+
1280
+ test("non-merge_ready types skip reviewer check", async () => {
1281
+ await seedSessions([
1282
+ { agentName: "builder-1", capability: "builder", parentAgent: "lead-1" },
1283
+ { agentName: "builder-2", capability: "builder", parentAgent: "lead-1" },
1284
+ ]);
1285
+
1286
+ output = "";
1287
+ stderrOutput = "";
1288
+ await mailCommand([
1289
+ "send",
1290
+ "--to",
1291
+ "coordinator",
1292
+ "--subject",
1293
+ "Status update",
1294
+ "--body",
1295
+ "Work in progress",
1296
+ "--type",
1297
+ "status",
1298
+ "--from",
1299
+ "lead-1",
1300
+ ]);
1301
+
1302
+ // No warning should be emitted for non-merge_ready types
1303
+ expect(stderrOutput).toBe("");
1304
+ });
1305
+ });
1306
+
1307
+ describe("smart push delivery (agent-busy)", () => {
1308
+ /** Find a nudge event recorded by nudgeAgent in the EventStore. */
1309
+ async function findNudgeEvent(eventsDbPath: string): Promise<StoredEvent | undefined> {
1310
+ const eventStore = createEventStore(eventsDbPath);
1311
+ try {
1312
+ const events = eventStore.getTimeline({ since: "2000-01-01T00:00:00Z" });
1313
+ return events.find((e) => {
1314
+ if (e.eventType !== "custom" || !e.data) return false;
1315
+ const data = JSON.parse(e.data) as { type?: string };
1316
+ return data.type === "nudge";
1317
+ });
1318
+ } finally {
1319
+ eventStore.close();
1320
+ }
1321
+ }
1322
+
1323
+ test("urgent message to idle agent (no busy marker) triggers direct nudge", async () => {
1324
+ // No agent-busy marker written = recipient is idle
1325
+ await mailCommand([
1326
+ "send",
1327
+ "--to",
1328
+ "builder-1",
1329
+ "--subject",
1330
+ "Fix NOW",
1331
+ "--body",
1332
+ "Production is down",
1333
+ "--priority",
1334
+ "urgent",
1335
+ ]);
1336
+
1337
+ // Pending marker should still be written (always)
1338
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "builder-1.json");
1339
+ {
1340
+ let exists = false;
1341
+ try {
1342
+ await access(markerPath);
1343
+ exists = true;
1344
+ } catch {}
1345
+ expect(exists).toBe(true);
1346
+ }
1347
+
1348
+ // nudgeAgent was called: EventStore has a nudge event
1349
+ const nudgeEvent = await findNudgeEvent(join(tempDir, ".legio", "events.db"));
1350
+ expect(nudgeEvent).toBeDefined();
1351
+ expect(nudgeEvent?.eventType).toBe("custom");
1352
+ const data = JSON.parse(nudgeEvent?.data ?? "{}") as { type: string; delivered: boolean };
1353
+ expect(data.type).toBe("nudge");
1354
+ });
1355
+
1356
+ test("urgent message to busy agent (busy marker present) skips direct nudge", async () => {
1357
+ // Write agent-busy marker = recipient is busy
1358
+ const busyDir = join(tempDir, ".legio", "agent-busy");
1359
+ await mkdir(busyDir, { recursive: true });
1360
+ await writeFile(join(busyDir, "builder-1"), "busy");
1361
+
1362
+ await mailCommand([
1363
+ "send",
1364
+ "--to",
1365
+ "builder-1",
1366
+ "--subject",
1367
+ "Fix NOW",
1368
+ "--body",
1369
+ "Production is down",
1370
+ "--priority",
1371
+ "urgent",
1372
+ ]);
1373
+
1374
+ // Pending marker should still be written (always)
1375
+ const markerPath = join(tempDir, ".legio", "pending-nudges", "builder-1.json");
1376
+ {
1377
+ let exists = false;
1378
+ try {
1379
+ await access(markerPath);
1380
+ exists = true;
1381
+ } catch {}
1382
+ expect(exists).toBe(true);
1383
+ }
1384
+
1385
+ // nudgeAgent was NOT called: no nudge event in EventStore
1386
+ const nudgeEvent = await findNudgeEvent(join(tempDir, ".legio", "events.db"));
1387
+ expect(nudgeEvent).toBeUndefined();
1388
+ });
1389
+
1390
+ test("worker_done to idle agent triggers direct nudge", async () => {
1391
+ // No busy marker = idle
1392
+ await mailCommand([
1393
+ "send",
1394
+ "--to",
1395
+ "orchestrator",
1396
+ "--subject",
1397
+ "Task complete",
1398
+ "--body",
1399
+ "Builder finished",
1400
+ "--type",
1401
+ "worker_done",
1402
+ "--from",
1403
+ "builder-1",
1404
+ ]);
1405
+
1406
+ const nudgeEvent = await findNudgeEvent(join(tempDir, ".legio", "events.db"));
1407
+ expect(nudgeEvent).toBeDefined();
1408
+ });
1409
+
1410
+ test("worker_done to busy agent skips direct nudge", async () => {
1411
+ // Write agent-busy marker
1412
+ const busyDir = join(tempDir, ".legio", "agent-busy");
1413
+ await mkdir(busyDir, { recursive: true });
1414
+ await writeFile(join(busyDir, "orchestrator"), "busy");
1415
+
1416
+ await mailCommand([
1417
+ "send",
1418
+ "--to",
1419
+ "orchestrator",
1420
+ "--subject",
1421
+ "Task complete",
1422
+ "--body",
1423
+ "Builder finished",
1424
+ "--type",
1425
+ "worker_done",
1426
+ "--from",
1427
+ "builder-1",
1428
+ ]);
1429
+
1430
+ // No nudge event in EventStore
1431
+ const nudgeEvent = await findNudgeEvent(join(tempDir, ".legio", "events.db"));
1432
+ expect(nudgeEvent).toBeUndefined();
1433
+ });
1434
+
1435
+ test("normal priority message triggers direct nudge when agent is idle (always-nudge)", async () => {
1436
+ // No busy marker = idle; all messages now trigger nudge regardless of priority
1437
+ await mailCommand(["send", "--to", "builder-1", "--subject", "FYI", "--body", "Just a note"]);
1438
+
1439
+ // nudgeAgent is called for idle agents for all message types
1440
+ const nudgeEvent = await findNudgeEvent(join(tempDir, ".legio", "events.db"));
1441
+ expect(nudgeEvent).toBeDefined();
1442
+ });
1443
+
1444
+ test("busy marker is per-agent: idle agent gets direct nudge, busy agent does not", async () => {
1445
+ // Make builder-1 busy, leave scout-1 idle
1446
+ const busyDir = join(tempDir, ".legio", "agent-busy");
1447
+ await mkdir(busyDir, { recursive: true });
1448
+ await writeFile(join(busyDir, "builder-1"), "busy");
1449
+
1450
+ // Send urgent to idle scout-1 (no busy marker)
1451
+ await mailCommand([
1452
+ "send",
1453
+ "--to",
1454
+ "scout-1",
1455
+ "--subject",
1456
+ "Urgent scout task",
1457
+ "--body",
1458
+ "Explore this",
1459
+ "--priority",
1460
+ "urgent",
1461
+ ]);
1462
+
1463
+ // scout-1 is idle → nudge event recorded
1464
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1465
+ const eventStore = createEventStore(eventsDbPath);
1466
+ let events: StoredEvent[] = [];
1467
+ try {
1468
+ events = eventStore.getTimeline({ since: "2000-01-01T00:00:00Z" });
1469
+ } finally {
1470
+ eventStore.close();
1471
+ }
1472
+ const nudgeEvents = events.filter((e) => {
1473
+ if (e.eventType !== "custom" || !e.data) return false;
1474
+ const data = JSON.parse(e.data) as { type?: string };
1475
+ return data.type === "nudge";
1476
+ });
1477
+ expect(nudgeEvents.length).toBe(1); // Only one nudge (scout-1)
1478
+ });
1479
+
1480
+ test("broadcast urgent to idle agents triggers direct nudge for each idle recipient", async () => {
1481
+ const { createSessionStore } = await import("../sessions/store.ts");
1482
+ const sessionsDbPath = join(tempDir, ".legio", "sessions.db");
1483
+ const sessionStore = createSessionStore(sessionsDbPath);
1484
+ const sessions = [
1485
+ {
1486
+ id: "session-orchestrator",
1487
+ agentName: "orchestrator",
1488
+ capability: "coordinator" as const,
1489
+ worktreePath: "/worktrees/orchestrator",
1490
+ branchName: "main",
1491
+ beadId: "bead-001",
1492
+ tmuxSession: "legio-fake-orchestrator",
1493
+ state: "working" as const,
1494
+ pid: 12345,
1495
+ parentAgent: null,
1496
+ depth: 0,
1497
+ runId: "run-001",
1498
+ startedAt: new Date().toISOString(),
1499
+ lastActivity: new Date().toISOString(),
1500
+ escalationLevel: 0,
1501
+ stalledSince: null,
1502
+ },
1503
+ {
1504
+ id: "session-builder-1",
1505
+ agentName: "builder-1",
1506
+ capability: "builder" as const,
1507
+ worktreePath: "/worktrees/builder-1",
1508
+ branchName: "builder-1",
1509
+ beadId: "bead-002",
1510
+ tmuxSession: "legio-fake-builder-1",
1511
+ state: "working" as const,
1512
+ pid: 12346,
1513
+ parentAgent: "orchestrator",
1514
+ depth: 1,
1515
+ runId: "run-001",
1516
+ startedAt: new Date().toISOString(),
1517
+ lastActivity: new Date().toISOString(),
1518
+ escalationLevel: 0,
1519
+ stalledSince: null,
1520
+ },
1521
+ {
1522
+ id: "session-builder-2",
1523
+ agentName: "builder-2",
1524
+ capability: "builder" as const,
1525
+ worktreePath: "/worktrees/builder-2",
1526
+ branchName: "builder-2",
1527
+ beadId: "bead-003",
1528
+ tmuxSession: "legio-fake-builder-2",
1529
+ state: "working" as const,
1530
+ pid: 12347,
1531
+ parentAgent: "orchestrator",
1532
+ depth: 1,
1533
+ runId: "run-001",
1534
+ startedAt: new Date().toISOString(),
1535
+ lastActivity: new Date().toISOString(),
1536
+ escalationLevel: 0,
1537
+ stalledSince: null,
1538
+ },
1539
+ ];
1540
+ for (const session of sessions) {
1541
+ sessionStore.upsert(session);
1542
+ }
1543
+ sessionStore.close();
1544
+
1545
+ // Make builder-1 busy, builder-2 idle
1546
+ const busyDir = join(tempDir, ".legio", "agent-busy");
1547
+ await mkdir(busyDir, { recursive: true });
1548
+ await writeFile(join(busyDir, "builder-1"), "busy");
1549
+
1550
+ await mailCommand([
1551
+ "send",
1552
+ "--to",
1553
+ "@builders",
1554
+ "--subject",
1555
+ "Urgent broadcast",
1556
+ "--body",
1557
+ "Do this now",
1558
+ "--priority",
1559
+ "urgent",
1560
+ ]);
1561
+
1562
+ // Pending markers for both builders
1563
+ const nudgesDir = join(tempDir, ".legio", "pending-nudges");
1564
+ const nudgeFiles = await readdir(nudgesDir);
1565
+ expect(nudgeFiles).toContain("builder-1.json");
1566
+ expect(nudgeFiles).toContain("builder-2.json");
1567
+
1568
+ // Only builder-2 (idle) gets a direct nudge event
1569
+ const eventsDbPath = join(tempDir, ".legio", "events.db");
1570
+ const eventStore = createEventStore(eventsDbPath);
1571
+ let events: StoredEvent[] = [];
1572
+ try {
1573
+ events = eventStore.getTimeline({ since: "2000-01-01T00:00:00Z" });
1574
+ } finally {
1575
+ eventStore.close();
1576
+ }
1577
+ const nudgeEvents = events.filter((e) => {
1578
+ if (e.eventType !== "custom" || !e.data) return false;
1579
+ const data = JSON.parse(e.data) as { type?: string };
1580
+ return data.type === "nudge";
1581
+ });
1582
+ // Only 1 nudge (builder-2 is idle, builder-1 is busy)
1583
+ expect(nudgeEvents.length).toBe(1);
1584
+ });
1585
+ });
1586
+
1587
+ describe("audience flag", () => {
1588
+ test("handleSend with explicit --audience human sends without error", async () => {
1589
+ output = "";
1590
+ await mailCommand([
1591
+ "send",
1592
+ "--to",
1593
+ "builder-1",
1594
+ "--subject",
1595
+ "Human message",
1596
+ "--body",
1597
+ "For human operators",
1598
+ "--audience",
1599
+ "human",
1600
+ ]);
1601
+
1602
+ expect(output).toContain("Sent message");
1603
+ expect(output).toContain("builder-1");
1604
+ });
1605
+
1606
+ test("handleSend auto-derives audience 'agent' for protocol type worker_done", async () => {
1607
+ output = "";
1608
+ await mailCommand([
1609
+ "send",
1610
+ "--to",
1611
+ "orchestrator",
1612
+ "--subject",
1613
+ "Done",
1614
+ "--body",
1615
+ "Task complete",
1616
+ "--type",
1617
+ "worker_done",
1618
+ "--from",
1619
+ "builder-1",
1620
+ ]);
1621
+
1622
+ // Message should be sent without error (audience auto-derived as 'agent')
1623
+ expect(output).toContain("Sent message");
1624
+ });
1625
+
1626
+ test("handleSend auto-derives audience 'both' for semantic type status", async () => {
1627
+ output = "";
1628
+ await mailCommand([
1629
+ "send",
1630
+ "--to",
1631
+ "orchestrator",
1632
+ "--subject",
1633
+ "Update",
1634
+ "--body",
1635
+ "Status update",
1636
+ "--type",
1637
+ "status",
1638
+ "--from",
1639
+ "builder-1",
1640
+ ]);
1641
+
1642
+ // Message should be sent without error (audience auto-derived as 'both')
1643
+ expect(output).toContain("Sent message");
1644
+ });
1645
+
1646
+ test("handleSend rejects invalid --audience value with ValidationError", async () => {
1647
+ let caughtError: unknown;
1648
+ try {
1649
+ await mailCommand([
1650
+ "send",
1651
+ "--to",
1652
+ "builder-1",
1653
+ "--subject",
1654
+ "Test",
1655
+ "--body",
1656
+ "Body",
1657
+ "--audience",
1658
+ "invalid",
1659
+ ]);
1660
+ } catch (err) {
1661
+ caughtError = err;
1662
+ }
1663
+
1664
+ expect(caughtError).toBeInstanceOf(Error);
1665
+ if (caughtError instanceof Error) {
1666
+ expect(caughtError.message).toContain('Invalid --audience "invalid"');
1667
+ expect(caughtError.message).toContain("human, agent, both");
1668
+ }
1669
+ });
1670
+
1671
+ test("handleCheck with --audience agent runs without error and filters messages", async () => {
1672
+ output = "";
1673
+ // Existing messages (seeded in beforeEach) have no audience field set.
1674
+ // Filtering by "agent" will exclude them (undefined !== "agent").
1675
+ await mailCommand(["check", "--agent", "builder-1", "--audience", "agent"]);
1676
+
1677
+ // No messages match the audience filter, so inbox appears empty
1678
+ expect(output).toContain("No new messages");
1679
+ });
1680
+
1681
+ test("handleCheck with invalid --audience throws ValidationError", async () => {
1682
+ let caughtError: unknown;
1683
+ try {
1684
+ await mailCommand(["check", "--agent", "builder-1", "--audience", "robots"]);
1685
+ } catch (err) {
1686
+ caughtError = err;
1687
+ }
1688
+
1689
+ expect(caughtError).toBeInstanceOf(Error);
1690
+ if (caughtError instanceof Error) {
1691
+ expect(caughtError.message).toContain('Invalid --audience "robots"');
1692
+ }
1693
+ });
1694
+
1695
+ test("handleList with --audience human runs without error and filters messages", async () => {
1696
+ output = "";
1697
+ // Seeded messages default to audience "both" (status type → non-protocol → "both").
1698
+ // Filtering by "human" excludes them since none were sent with audience "human".
1699
+ await mailCommand(["list", "--audience", "human"]);
1700
+
1701
+ // No messages match the audience filter
1702
+ expect(output).toContain("No messages found");
1703
+ });
1704
+
1705
+ test("handleList with invalid --audience throws ValidationError", async () => {
1706
+ let caughtError: unknown;
1707
+ try {
1708
+ await mailCommand(["list", "--audience", "everyone"]);
1709
+ } catch (err) {
1710
+ caughtError = err;
1711
+ }
1712
+
1713
+ expect(caughtError).toBeInstanceOf(Error);
1714
+ if (caughtError instanceof Error) {
1715
+ expect(caughtError.message).toContain('Invalid --audience "everyone"');
1716
+ }
1717
+ });
1718
+
1719
+ test("MAIL_HELP includes --audience flag documentation", async () => {
1720
+ output = "";
1721
+ await mailCommand(["--help"]);
1722
+
1723
+ expect(output).toContain("--audience <human|agent|both>");
1724
+ });
1725
+ });
1726
+ });