@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.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- 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
|
+
});
|