@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,3816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for REST API route handlers.
|
|
3
|
+
*
|
|
4
|
+
* Uses real SQLite databases in temp directories. No mocking of store logic.
|
|
5
|
+
* gatherStatus/gatherInspectData are integration calls — tested via error paths.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { accessSync, constants as fsConstants, readFileSync } from "node:fs";
|
|
9
|
+
import { vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
// Stub the global Bun object because production modules (e.g., config.ts) still use Bun APIs
|
|
12
|
+
// and have not yet been migrated to Node.js equivalents. This shim provides only the subset
|
|
13
|
+
// of the Bun API surface required by the code paths exercised by these route tests.
|
|
14
|
+
vi.stubGlobal("Bun", {
|
|
15
|
+
file: (path: string) => ({
|
|
16
|
+
exists: async () => {
|
|
17
|
+
try {
|
|
18
|
+
accessSync(path, fsConstants.F_OK);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
text: async () => readFileSync(path, "utf-8"),
|
|
25
|
+
json: async () => JSON.parse(readFileSync(path, "utf-8")),
|
|
26
|
+
}),
|
|
27
|
+
spawn: () => {
|
|
28
|
+
throw new Error("Bun.spawn not available in Node.js/vitest environment");
|
|
29
|
+
},
|
|
30
|
+
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
31
|
+
write: async (path: string, content: string) => {
|
|
32
|
+
const { writeFileSync } = await import("node:fs");
|
|
33
|
+
writeFileSync(path, content, "utf-8");
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Mock the beads client so strategy tests can run without `bd` on PATH.
|
|
38
|
+
// list/ready return [] (keeps existing /api/issues tests passing).
|
|
39
|
+
// show throws (existing /api/issues/:id test expects 404 on error).
|
|
40
|
+
// create returns a predictable issue ID for strategy approve tests.
|
|
41
|
+
// list returns a closed and a blocked issue fixture when all=true to verify all-statuses behavior.
|
|
42
|
+
vi.mock("../beads/client.ts", () => ({
|
|
43
|
+
createBeadsClient: () => ({
|
|
44
|
+
ready: async () => [],
|
|
45
|
+
list: async (options?: { status?: string; limit?: number; all?: boolean }) => {
|
|
46
|
+
// Return closed and blocked issue fixtures when all is true
|
|
47
|
+
if (options?.all) {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
id: "bead-closed-001",
|
|
51
|
+
title: "Closed issue",
|
|
52
|
+
status: "closed",
|
|
53
|
+
priority: 3,
|
|
54
|
+
type: "task",
|
|
55
|
+
closedAt: "2026-01-01T00:00:00.000Z",
|
|
56
|
+
closeReason: "Done",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "bead-blocked-001",
|
|
60
|
+
title: "Blocked issue",
|
|
61
|
+
status: "blocked",
|
|
62
|
+
priority: 2,
|
|
63
|
+
type: "task",
|
|
64
|
+
dependency_count: 1,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
},
|
|
70
|
+
show: async (id: string) => {
|
|
71
|
+
throw new Error(`bd not available: ${id}`);
|
|
72
|
+
},
|
|
73
|
+
create: async () => "bead-test-001",
|
|
74
|
+
claim: async () => {},
|
|
75
|
+
close: async () => {},
|
|
76
|
+
}),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
// Mock tmux so tests don't interfere with real developer tmux sessions.
|
|
80
|
+
// isSessionAlive defaults to true so that tests which seed an active session
|
|
81
|
+
// (and are not testing the stale-session path) pass through correctly.
|
|
82
|
+
// sendKeys is a no-op — we only test up to the 404 paths here.
|
|
83
|
+
const { mockIsSessionAlive, mockSendKeys } = vi.hoisted(() => ({
|
|
84
|
+
mockIsSessionAlive: vi.fn().mockResolvedValue(true),
|
|
85
|
+
mockSendKeys: vi.fn().mockResolvedValue(undefined),
|
|
86
|
+
}));
|
|
87
|
+
vi.mock("../worktree/tmux.ts", () => ({
|
|
88
|
+
isSessionAlive: mockIsSessionAlive,
|
|
89
|
+
sendKeys: mockSendKeys,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
93
|
+
import { tmpdir } from "node:os";
|
|
94
|
+
import { join } from "node:path";
|
|
95
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
96
|
+
import { createEventStore } from "../events/store.ts";
|
|
97
|
+
import { createMailStore } from "../mail/store.ts";
|
|
98
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
99
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
100
|
+
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
101
|
+
import { createAuditStore } from "./audit-store.ts";
|
|
102
|
+
import { handleApiRequest } from "./routes.ts";
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Test helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function makeRequest(path: string, query?: Record<string, string>): Request {
|
|
109
|
+
const url = new URL(`http://localhost${path}`);
|
|
110
|
+
if (query) {
|
|
111
|
+
for (const [k, v] of Object.entries(query)) {
|
|
112
|
+
url.searchParams.set(k, v);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return new Request(url.toString(), { method: "GET" });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function json(res: Response): Promise<unknown> {
|
|
119
|
+
return res.json();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function makePostRequest(path: string, body: unknown): Request {
|
|
123
|
+
return new Request(`http://localhost${path}`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Content-Type": "application/json" },
|
|
126
|
+
body: JSON.stringify(body),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Fixture builders
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
function seedMailDb(dbPath: string): void {
|
|
135
|
+
const store = createMailStore(dbPath);
|
|
136
|
+
store.insert({
|
|
137
|
+
id: "msg-aaa111",
|
|
138
|
+
from: "agent1",
|
|
139
|
+
to: "agent2",
|
|
140
|
+
subject: "Hello",
|
|
141
|
+
body: "First message",
|
|
142
|
+
type: "status",
|
|
143
|
+
priority: "normal",
|
|
144
|
+
threadId: "thread-1",
|
|
145
|
+
audience: "both",
|
|
146
|
+
});
|
|
147
|
+
store.insert({
|
|
148
|
+
id: "msg-bbb222",
|
|
149
|
+
from: "agent2",
|
|
150
|
+
to: "agent1",
|
|
151
|
+
subject: "Reply",
|
|
152
|
+
body: "Second message",
|
|
153
|
+
type: "result",
|
|
154
|
+
priority: "high",
|
|
155
|
+
threadId: "thread-1",
|
|
156
|
+
audience: "human",
|
|
157
|
+
});
|
|
158
|
+
store.insert({
|
|
159
|
+
id: "msg-ccc333",
|
|
160
|
+
from: "agent1",
|
|
161
|
+
to: "orchestrator",
|
|
162
|
+
subject: "Unread",
|
|
163
|
+
body: "Third message unread",
|
|
164
|
+
type: "status",
|
|
165
|
+
priority: "normal",
|
|
166
|
+
threadId: null,
|
|
167
|
+
audience: "agent",
|
|
168
|
+
});
|
|
169
|
+
// mark first two as read
|
|
170
|
+
store.markRead("msg-aaa111");
|
|
171
|
+
store.markRead("msg-bbb222");
|
|
172
|
+
store.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function seedSessionDb(dbPath: string): void {
|
|
176
|
+
const store = createSessionStore(dbPath);
|
|
177
|
+
const now = new Date().toISOString();
|
|
178
|
+
store.upsert({
|
|
179
|
+
id: "sess-001",
|
|
180
|
+
agentName: "scout-1",
|
|
181
|
+
capability: "scout",
|
|
182
|
+
worktreePath: "/tmp/wt/scout-1",
|
|
183
|
+
branchName: "legio/scout-1/task-1",
|
|
184
|
+
beadId: "task-1",
|
|
185
|
+
tmuxSession: "legio-test-scout-1",
|
|
186
|
+
state: "working",
|
|
187
|
+
pid: 12345,
|
|
188
|
+
parentAgent: null,
|
|
189
|
+
depth: 1,
|
|
190
|
+
runId: "run-001",
|
|
191
|
+
startedAt: now,
|
|
192
|
+
lastActivity: now,
|
|
193
|
+
escalationLevel: 0,
|
|
194
|
+
stalledSince: null,
|
|
195
|
+
});
|
|
196
|
+
store.upsert({
|
|
197
|
+
id: "sess-002",
|
|
198
|
+
agentName: "builder-1",
|
|
199
|
+
capability: "builder",
|
|
200
|
+
worktreePath: "/tmp/wt/builder-1",
|
|
201
|
+
branchName: "legio/builder-1/task-2",
|
|
202
|
+
beadId: "task-2",
|
|
203
|
+
tmuxSession: "legio-test-builder-1",
|
|
204
|
+
state: "completed",
|
|
205
|
+
pid: null,
|
|
206
|
+
parentAgent: "scout-1",
|
|
207
|
+
depth: 2,
|
|
208
|
+
runId: "run-001",
|
|
209
|
+
startedAt: now,
|
|
210
|
+
lastActivity: now,
|
|
211
|
+
escalationLevel: 0,
|
|
212
|
+
stalledSince: null,
|
|
213
|
+
});
|
|
214
|
+
store.close();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function seedRunDb(dbPath: string): void {
|
|
218
|
+
const store = createRunStore(dbPath);
|
|
219
|
+
store.createRun({
|
|
220
|
+
id: "run-001",
|
|
221
|
+
startedAt: new Date().toISOString(),
|
|
222
|
+
coordinatorSessionId: "sess-001",
|
|
223
|
+
status: "active",
|
|
224
|
+
});
|
|
225
|
+
store.close();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function seedEventDb(dbPath: string): void {
|
|
229
|
+
const store = createEventStore(dbPath);
|
|
230
|
+
const ts = new Date().toISOString();
|
|
231
|
+
store.insert({
|
|
232
|
+
runId: "run-001",
|
|
233
|
+
agentName: "scout-1",
|
|
234
|
+
sessionId: "sess-001",
|
|
235
|
+
eventType: "tool_start",
|
|
236
|
+
toolName: "Bash",
|
|
237
|
+
toolArgs: '["ls"]',
|
|
238
|
+
toolDurationMs: null,
|
|
239
|
+
level: "info",
|
|
240
|
+
data: null,
|
|
241
|
+
});
|
|
242
|
+
store.insert({
|
|
243
|
+
runId: "run-001",
|
|
244
|
+
agentName: "builder-1",
|
|
245
|
+
sessionId: "sess-002",
|
|
246
|
+
eventType: "tool_start",
|
|
247
|
+
toolName: "Read",
|
|
248
|
+
toolArgs: null,
|
|
249
|
+
toolDurationMs: null,
|
|
250
|
+
level: "info",
|
|
251
|
+
data: null,
|
|
252
|
+
});
|
|
253
|
+
store.insert({
|
|
254
|
+
runId: "run-001",
|
|
255
|
+
agentName: "scout-1",
|
|
256
|
+
sessionId: "sess-001",
|
|
257
|
+
eventType: "tool_start",
|
|
258
|
+
toolName: "Bash",
|
|
259
|
+
toolArgs: '["git status"]',
|
|
260
|
+
toolDurationMs: null,
|
|
261
|
+
level: "error",
|
|
262
|
+
data: "something went wrong",
|
|
263
|
+
});
|
|
264
|
+
// unused — just to satisfy linting on unused import
|
|
265
|
+
void ts;
|
|
266
|
+
store.close();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function seedMetricsDb(dbPath: string): void {
|
|
270
|
+
const store = createMetricsStore(dbPath);
|
|
271
|
+
const now = new Date().toISOString();
|
|
272
|
+
store.recordSession({
|
|
273
|
+
agentName: "scout-1",
|
|
274
|
+
beadId: "task-1",
|
|
275
|
+
capability: "scout",
|
|
276
|
+
startedAt: now,
|
|
277
|
+
completedAt: now,
|
|
278
|
+
durationMs: 5000,
|
|
279
|
+
exitCode: 0,
|
|
280
|
+
mergeResult: null,
|
|
281
|
+
parentAgent: null,
|
|
282
|
+
inputTokens: 100,
|
|
283
|
+
outputTokens: 50,
|
|
284
|
+
cacheReadTokens: 10,
|
|
285
|
+
cacheCreationTokens: 5,
|
|
286
|
+
estimatedCostUsd: 0.001,
|
|
287
|
+
modelUsed: "claude-sonnet-4-6",
|
|
288
|
+
});
|
|
289
|
+
store.recordSession({
|
|
290
|
+
agentName: "builder-1",
|
|
291
|
+
beadId: "task-2",
|
|
292
|
+
capability: "builder",
|
|
293
|
+
startedAt: now,
|
|
294
|
+
completedAt: null,
|
|
295
|
+
durationMs: 3000,
|
|
296
|
+
exitCode: null,
|
|
297
|
+
mergeResult: null,
|
|
298
|
+
parentAgent: "scout-1",
|
|
299
|
+
inputTokens: 200,
|
|
300
|
+
outputTokens: 100,
|
|
301
|
+
cacheReadTokens: 20,
|
|
302
|
+
cacheCreationTokens: 10,
|
|
303
|
+
estimatedCostUsd: 0.002,
|
|
304
|
+
modelUsed: "claude-sonnet-4-6",
|
|
305
|
+
});
|
|
306
|
+
store.close();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function seedMergeQueueDb(dbPath: string): void {
|
|
310
|
+
const queue = createMergeQueue(dbPath);
|
|
311
|
+
queue.enqueue({
|
|
312
|
+
branchName: "legio/scout-1/task-1",
|
|
313
|
+
beadId: "task-1",
|
|
314
|
+
agentName: "scout-1",
|
|
315
|
+
filesModified: ["src/foo.ts"],
|
|
316
|
+
});
|
|
317
|
+
queue.enqueue({
|
|
318
|
+
branchName: "legio/builder-1/task-2",
|
|
319
|
+
beadId: "task-2",
|
|
320
|
+
agentName: "builder-1",
|
|
321
|
+
filesModified: ["src/bar.ts", "src/baz.ts"],
|
|
322
|
+
});
|
|
323
|
+
queue.close();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Test suite
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
let tempDir: string;
|
|
331
|
+
let legioDir: string;
|
|
332
|
+
let projectRoot: string;
|
|
333
|
+
|
|
334
|
+
beforeEach(async () => {
|
|
335
|
+
tempDir = await (async () => {
|
|
336
|
+
const base = join(tmpdir(), `routes-test-${Date.now()}`);
|
|
337
|
+
await mkdir(base, { recursive: true });
|
|
338
|
+
return base;
|
|
339
|
+
})();
|
|
340
|
+
legioDir = join(tempDir, ".legio");
|
|
341
|
+
projectRoot = tempDir;
|
|
342
|
+
await mkdir(legioDir, { recursive: true });
|
|
343
|
+
|
|
344
|
+
// Write minimal config.yaml for loadConfig
|
|
345
|
+
await writeFile(
|
|
346
|
+
join(legioDir, "config.yaml"),
|
|
347
|
+
[
|
|
348
|
+
"project:",
|
|
349
|
+
" name: test",
|
|
350
|
+
" canonicalBranch: main",
|
|
351
|
+
"agents:",
|
|
352
|
+
" maxDepth: 2",
|
|
353
|
+
"coordinator:",
|
|
354
|
+
" model: claude-sonnet-4-6",
|
|
355
|
+
].join("\n"),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
afterEach(async () => {
|
|
360
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Helper: dispatch request with test dirs
|
|
364
|
+
async function dispatch(path: string, query?: Record<string, string>): Promise<Response> {
|
|
365
|
+
return handleApiRequest(makeRequest(path, query), legioDir, projectRoot);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function dispatchPost(path: string, body: unknown): Promise<Response> {
|
|
369
|
+
return handleApiRequest(makePostRequest(path, body), legioDir, projectRoot);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Core routes
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
describe("GET /api/health", () => {
|
|
377
|
+
it("returns 200 with ok:true and timestamp", async () => {
|
|
378
|
+
const res = await dispatch("/api/health");
|
|
379
|
+
expect(res.status).toBe(200);
|
|
380
|
+
const body = (await json(res)) as { ok: boolean; timestamp: string };
|
|
381
|
+
expect(body.ok).toBe(true);
|
|
382
|
+
expect(typeof body.timestamp).toBe("string");
|
|
383
|
+
// Should be a valid ISO timestamp
|
|
384
|
+
expect(() => new Date(body.timestamp)).not.toThrow();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("GET /api/config", () => {
|
|
389
|
+
it("returns config when config.yaml exists", async () => {
|
|
390
|
+
const res = await dispatch("/api/config");
|
|
391
|
+
expect(res.status).toBe(200);
|
|
392
|
+
const body = (await json(res)) as { project: { name: string } };
|
|
393
|
+
expect(body.project.name).toBe("test");
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Agent routes
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
describe("GET /api/agents", () => {
|
|
402
|
+
it("returns empty array when no sessions.db", async () => {
|
|
403
|
+
const res = await dispatch("/api/agents");
|
|
404
|
+
expect(res.status).toBe(200);
|
|
405
|
+
const body = await json(res);
|
|
406
|
+
expect(body).toEqual([]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("returns seeded agent sessions", async () => {
|
|
410
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
411
|
+
const res = await dispatch("/api/agents");
|
|
412
|
+
expect(res.status).toBe(200);
|
|
413
|
+
const body = (await json(res)) as unknown[];
|
|
414
|
+
expect(body.length).toBe(2);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe("GET /api/agents/active", () => {
|
|
419
|
+
it("returns empty array when no sessions.db", async () => {
|
|
420
|
+
const res = await dispatch("/api/agents/active");
|
|
421
|
+
expect(res.status).toBe(200);
|
|
422
|
+
expect(await json(res)).toEqual([]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("returns only active agents", async () => {
|
|
426
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
427
|
+
const res = await dispatch("/api/agents/active");
|
|
428
|
+
expect(res.status).toBe(200);
|
|
429
|
+
const body = (await json(res)) as Array<{ state: string }>;
|
|
430
|
+
// scout-1 is 'working' (active), builder-1 is 'completed' (not active)
|
|
431
|
+
expect(body.length).toBe(1);
|
|
432
|
+
expect(body[0]?.state).toBe("working");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("GET /api/agents/:name", () => {
|
|
437
|
+
it("returns specific agent by name", async () => {
|
|
438
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
439
|
+
const res = await dispatch("/api/agents/scout-1");
|
|
440
|
+
expect(res.status).toBe(200);
|
|
441
|
+
const body = (await json(res)) as { agentName: string };
|
|
442
|
+
expect(body.agentName).toBe("scout-1");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("returns 404 for unknown agent", async () => {
|
|
446
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
447
|
+
const res = await dispatch("/api/agents/nonexistent");
|
|
448
|
+
expect(res.status).toBe(404);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("returns 404 when sessions.db does not exist", async () => {
|
|
452
|
+
const res = await dispatch("/api/agents/any-agent");
|
|
453
|
+
expect(res.status).toBe(404);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Coordinator status route
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
describe("GET /api/coordinator/status", () => {
|
|
462
|
+
it("returns stopped when no sessions.db and no orchestrator-tmux.json", async () => {
|
|
463
|
+
const res = await dispatch("/api/coordinator/status");
|
|
464
|
+
expect(res.status).toBe(200);
|
|
465
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
466
|
+
expect(body.running).toBe(false);
|
|
467
|
+
expect(body.tmuxSession).toBeUndefined();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("returns running when coordinator session exists with working state", async () => {
|
|
471
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
472
|
+
const store = createSessionStore(dbPath);
|
|
473
|
+
const now = new Date().toISOString();
|
|
474
|
+
store.upsert({
|
|
475
|
+
id: "sess-coord-001",
|
|
476
|
+
agentName: "coordinator",
|
|
477
|
+
capability: "coordinator",
|
|
478
|
+
worktreePath: "/tmp/wt/coordinator",
|
|
479
|
+
branchName: "main",
|
|
480
|
+
beadId: "coord-task",
|
|
481
|
+
tmuxSession: "legio-test-coordinator",
|
|
482
|
+
state: "working",
|
|
483
|
+
pid: 99999,
|
|
484
|
+
parentAgent: null,
|
|
485
|
+
depth: 0,
|
|
486
|
+
runId: "run-001",
|
|
487
|
+
startedAt: now,
|
|
488
|
+
lastActivity: now,
|
|
489
|
+
escalationLevel: 0,
|
|
490
|
+
stalledSince: null,
|
|
491
|
+
});
|
|
492
|
+
store.close();
|
|
493
|
+
|
|
494
|
+
const res = await dispatch("/api/coordinator/status");
|
|
495
|
+
expect(res.status).toBe(200);
|
|
496
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
497
|
+
expect(body.running).toBe(true);
|
|
498
|
+
expect(body.tmuxSession).toBe("legio-test-coordinator");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("returns stopped when coordinator session is in zombie state", async () => {
|
|
502
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
503
|
+
const store = createSessionStore(dbPath);
|
|
504
|
+
const now = new Date().toISOString();
|
|
505
|
+
store.upsert({
|
|
506
|
+
id: "sess-coord-002",
|
|
507
|
+
agentName: "coordinator",
|
|
508
|
+
capability: "coordinator",
|
|
509
|
+
worktreePath: "/tmp/wt/coordinator",
|
|
510
|
+
branchName: "main",
|
|
511
|
+
beadId: "coord-task",
|
|
512
|
+
tmuxSession: "legio-test-coordinator",
|
|
513
|
+
state: "zombie",
|
|
514
|
+
pid: null,
|
|
515
|
+
parentAgent: null,
|
|
516
|
+
depth: 0,
|
|
517
|
+
runId: "run-001",
|
|
518
|
+
startedAt: now,
|
|
519
|
+
lastActivity: now,
|
|
520
|
+
escalationLevel: 0,
|
|
521
|
+
stalledSince: null,
|
|
522
|
+
});
|
|
523
|
+
store.close();
|
|
524
|
+
|
|
525
|
+
const res = await dispatch("/api/coordinator/status");
|
|
526
|
+
expect(res.status).toBe(200);
|
|
527
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
528
|
+
// zombie state → resolveTerminalSession returns null → stopped
|
|
529
|
+
// (unless orchestrator-tmux.json exists, which it doesn't here)
|
|
530
|
+
expect(body.running).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("returns running when coordinator session state is completed but orchestrator-tmux.json exists", async () => {
|
|
534
|
+
// Write orchestrator-tmux.json as fallback
|
|
535
|
+
await writeFile(
|
|
536
|
+
join(projectRoot, ".legio", "orchestrator-tmux.json"),
|
|
537
|
+
JSON.stringify({ tmuxSession: "legio-orchestrator-fallback" }),
|
|
538
|
+
);
|
|
539
|
+
// No sessions.db — falls back to orchestrator-tmux.json
|
|
540
|
+
const res = await dispatch("/api/coordinator/status");
|
|
541
|
+
expect(res.status).toBe(200);
|
|
542
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
543
|
+
expect(body.running).toBe(true);
|
|
544
|
+
expect(body.tmuxSession).toBe("legio-orchestrator-fallback");
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Mail routes
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
describe("GET /api/mail", () => {
|
|
553
|
+
it("returns empty array when no mail.db", async () => {
|
|
554
|
+
const res = await dispatch("/api/mail");
|
|
555
|
+
expect(res.status).toBe(200);
|
|
556
|
+
expect(await json(res)).toEqual([]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("returns all messages", async () => {
|
|
560
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
561
|
+
const res = await dispatch("/api/mail");
|
|
562
|
+
expect(res.status).toBe(200);
|
|
563
|
+
const body = (await json(res)) as unknown[];
|
|
564
|
+
expect(body.length).toBe(3);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("filters by ?from=agent1", async () => {
|
|
568
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
569
|
+
const res = await dispatch("/api/mail", { from: "agent1" });
|
|
570
|
+
expect(res.status).toBe(200);
|
|
571
|
+
const body = (await json(res)) as Array<{ from: string }>;
|
|
572
|
+
expect(body.every((m) => m.from === "agent1")).toBe(true);
|
|
573
|
+
expect(body.length).toBe(2);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("filters by ?audience=human returns only human-audience messages", async () => {
|
|
577
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
578
|
+
const res = await dispatch("/api/mail", { audience: "human" });
|
|
579
|
+
expect(res.status).toBe(200);
|
|
580
|
+
const body = (await json(res)) as Array<{ audience: string }>;
|
|
581
|
+
expect(body.length).toBe(1);
|
|
582
|
+
expect(body.every((m) => m.audience === "human")).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("filters by ?audience=agent returns only agent-audience messages", async () => {
|
|
586
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
587
|
+
const res = await dispatch("/api/mail", { audience: "agent" });
|
|
588
|
+
expect(res.status).toBe(200);
|
|
589
|
+
const body = (await json(res)) as Array<{ audience: string }>;
|
|
590
|
+
expect(body.length).toBe(1);
|
|
591
|
+
expect(body.every((m) => m.audience === "agent")).toBe(true);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("returns all messages when no audience param (backward compat)", async () => {
|
|
595
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
596
|
+
const res = await dispatch("/api/mail");
|
|
597
|
+
expect(res.status).toBe(200);
|
|
598
|
+
const body = (await json(res)) as unknown[];
|
|
599
|
+
expect(body.length).toBe(3);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("GET /api/mail/unread", () => {
|
|
604
|
+
it("returns unread messages for agent", async () => {
|
|
605
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
606
|
+
const res = await dispatch("/api/mail/unread", { agent: "orchestrator" });
|
|
607
|
+
expect(res.status).toBe(200);
|
|
608
|
+
const body = (await json(res)) as unknown[];
|
|
609
|
+
// msg-ccc333 is unread and sent to 'orchestrator'
|
|
610
|
+
expect(body.length).toBe(1);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("returns 400 when agent param is missing", async () => {
|
|
614
|
+
const res = await dispatch("/api/mail/unread");
|
|
615
|
+
expect(res.status).toBe(400);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
describe("GET /api/mail/:id", () => {
|
|
620
|
+
it("returns specific message by id", async () => {
|
|
621
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
622
|
+
const res = await dispatch("/api/mail/msg-aaa111");
|
|
623
|
+
expect(res.status).toBe(200);
|
|
624
|
+
const body = (await json(res)) as { id: string };
|
|
625
|
+
expect(body.id).toBe("msg-aaa111");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("returns 404 for unknown message id", async () => {
|
|
629
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
630
|
+
const res = await dispatch("/api/mail/msg-unknown");
|
|
631
|
+
expect(res.status).toBe(404);
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// POST /api/mail/send
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
describe("POST /api/mail/send", () => {
|
|
640
|
+
it("creates a message and returns 201 with the message", async () => {
|
|
641
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
642
|
+
from: "agent1",
|
|
643
|
+
to: "agent2",
|
|
644
|
+
subject: "Test message",
|
|
645
|
+
body: "Hello from test",
|
|
646
|
+
});
|
|
647
|
+
expect(res.status).toBe(201);
|
|
648
|
+
const body = (await json(res)) as {
|
|
649
|
+
id: string;
|
|
650
|
+
from: string;
|
|
651
|
+
to: string;
|
|
652
|
+
subject: string;
|
|
653
|
+
body: string;
|
|
654
|
+
type: string;
|
|
655
|
+
priority: string;
|
|
656
|
+
read: boolean;
|
|
657
|
+
};
|
|
658
|
+
expect(body.from).toBe("agent1");
|
|
659
|
+
expect(body.to).toBe("agent2");
|
|
660
|
+
expect(body.subject).toBe("Test message");
|
|
661
|
+
expect(body.body).toBe("Hello from test");
|
|
662
|
+
expect(body.type).toBe("status");
|
|
663
|
+
expect(body.priority).toBe("normal");
|
|
664
|
+
expect(body.read).toBe(false);
|
|
665
|
+
expect(body.id).toMatch(/^msg-/);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("accepts optional type and priority fields", async () => {
|
|
669
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
670
|
+
from: "agent1",
|
|
671
|
+
to: "agent2",
|
|
672
|
+
subject: "Worker done",
|
|
673
|
+
body: "Task complete",
|
|
674
|
+
type: "worker_done",
|
|
675
|
+
priority: "high",
|
|
676
|
+
});
|
|
677
|
+
expect(res.status).toBe(201);
|
|
678
|
+
const body = (await json(res)) as { type: string; priority: string };
|
|
679
|
+
expect(body.type).toBe("worker_done");
|
|
680
|
+
expect(body.priority).toBe("high");
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("accepts optional threadId field", async () => {
|
|
684
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
685
|
+
from: "agent1",
|
|
686
|
+
to: "agent2",
|
|
687
|
+
subject: "Reply",
|
|
688
|
+
body: "In thread",
|
|
689
|
+
threadId: "thread-abc",
|
|
690
|
+
});
|
|
691
|
+
expect(res.status).toBe(201);
|
|
692
|
+
const body = (await json(res)) as { threadId: string | null };
|
|
693
|
+
expect(body.threadId).toBe("thread-abc");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("falls back to 'status' type for invalid type value", async () => {
|
|
697
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
698
|
+
from: "agent1",
|
|
699
|
+
to: "agent2",
|
|
700
|
+
subject: "Test",
|
|
701
|
+
body: "Body",
|
|
702
|
+
type: "invalid_type_xyz",
|
|
703
|
+
});
|
|
704
|
+
expect(res.status).toBe(201);
|
|
705
|
+
const body = (await json(res)) as { type: string };
|
|
706
|
+
expect(body.type).toBe("status");
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("falls back to 'normal' priority for invalid priority value", async () => {
|
|
710
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
711
|
+
from: "agent1",
|
|
712
|
+
to: "agent2",
|
|
713
|
+
subject: "Test",
|
|
714
|
+
body: "Body",
|
|
715
|
+
priority: "extreme",
|
|
716
|
+
});
|
|
717
|
+
expect(res.status).toBe(201);
|
|
718
|
+
const body = (await json(res)) as { priority: string };
|
|
719
|
+
expect(body.priority).toBe("normal");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("returns 400 when 'from' is missing", async () => {
|
|
723
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
724
|
+
to: "agent2",
|
|
725
|
+
subject: "Test",
|
|
726
|
+
body: "Body",
|
|
727
|
+
});
|
|
728
|
+
expect(res.status).toBe(400);
|
|
729
|
+
const body = (await json(res)) as { error: string };
|
|
730
|
+
expect(body.error).toContain("from");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("returns 400 when 'to' is missing", async () => {
|
|
734
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
735
|
+
from: "agent1",
|
|
736
|
+
subject: "Test",
|
|
737
|
+
body: "Body",
|
|
738
|
+
});
|
|
739
|
+
expect(res.status).toBe(400);
|
|
740
|
+
const body = (await json(res)) as { error: string };
|
|
741
|
+
expect(body.error).toContain("to");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("returns 400 when 'subject' is missing", async () => {
|
|
745
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
746
|
+
from: "agent1",
|
|
747
|
+
to: "agent2",
|
|
748
|
+
body: "Body",
|
|
749
|
+
});
|
|
750
|
+
expect(res.status).toBe(400);
|
|
751
|
+
const body = (await json(res)) as { error: string };
|
|
752
|
+
expect(body.error).toContain("subject");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("returns 400 when 'body' is missing", async () => {
|
|
756
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
757
|
+
from: "agent1",
|
|
758
|
+
to: "agent2",
|
|
759
|
+
subject: "Test",
|
|
760
|
+
});
|
|
761
|
+
expect(res.status).toBe(400);
|
|
762
|
+
const body = (await json(res)) as { error: string };
|
|
763
|
+
expect(body.error).toContain("body");
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("calls wsManager.broadcastEvent with mail_new event after successful insert", async () => {
|
|
767
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
768
|
+
const mockWsManager = {
|
|
769
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
770
|
+
events.push(event);
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const res = await handleApiRequest(
|
|
775
|
+
makePostRequest("/api/mail/send", {
|
|
776
|
+
from: "agent1",
|
|
777
|
+
to: "agent2",
|
|
778
|
+
subject: "Worker done",
|
|
779
|
+
body: "Task complete",
|
|
780
|
+
type: "worker_done",
|
|
781
|
+
}),
|
|
782
|
+
legioDir,
|
|
783
|
+
projectRoot,
|
|
784
|
+
mockWsManager,
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
expect(res.status).toBe(201);
|
|
788
|
+
expect(events.length).toBe(1);
|
|
789
|
+
expect(events[0]?.type).toBe("mail_new");
|
|
790
|
+
const data = events[0]?.data as { from: string; to: string; subject: string; type: string };
|
|
791
|
+
expect(data.from).toBe("agent1");
|
|
792
|
+
expect(data.to).toBe("agent2");
|
|
793
|
+
expect(data.subject).toBe("Worker done");
|
|
794
|
+
expect(data.type).toBe("worker_done");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("works when wsManager is null (backward compat)", async () => {
|
|
798
|
+
const res = await handleApiRequest(
|
|
799
|
+
makePostRequest("/api/mail/send", {
|
|
800
|
+
from: "agent1",
|
|
801
|
+
to: "agent2",
|
|
802
|
+
subject: "Test",
|
|
803
|
+
body: "Body",
|
|
804
|
+
}),
|
|
805
|
+
legioDir,
|
|
806
|
+
projectRoot,
|
|
807
|
+
null,
|
|
808
|
+
);
|
|
809
|
+
expect(res.status).toBe(201);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("works when wsManager is undefined (backward compat)", async () => {
|
|
813
|
+
const res = await handleApiRequest(
|
|
814
|
+
makePostRequest("/api/mail/send", {
|
|
815
|
+
from: "agent1",
|
|
816
|
+
to: "agent2",
|
|
817
|
+
subject: "Test",
|
|
818
|
+
body: "Body",
|
|
819
|
+
}),
|
|
820
|
+
legioDir,
|
|
821
|
+
projectRoot,
|
|
822
|
+
);
|
|
823
|
+
expect(res.status).toBe(201);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("returns 400 for non-JSON body", async () => {
|
|
827
|
+
const res = await handleApiRequest(
|
|
828
|
+
new Request("http://localhost/api/mail/send", {
|
|
829
|
+
method: "POST",
|
|
830
|
+
body: "not json",
|
|
831
|
+
headers: { "Content-Type": "text/plain" },
|
|
832
|
+
}),
|
|
833
|
+
legioDir,
|
|
834
|
+
projectRoot,
|
|
835
|
+
);
|
|
836
|
+
expect(res.status).toBe(400);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("returns 405 for POST to other /api/* paths", async () => {
|
|
840
|
+
const res = await handleApiRequest(
|
|
841
|
+
new Request("http://localhost/api/mail", { method: "POST", body: "{}" }),
|
|
842
|
+
legioDir,
|
|
843
|
+
projectRoot,
|
|
844
|
+
);
|
|
845
|
+
expect(res.status).toBe(405);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("accepts audience field and persists it", async () => {
|
|
849
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
850
|
+
from: "agent1",
|
|
851
|
+
to: "agent2",
|
|
852
|
+
subject: "Human message",
|
|
853
|
+
body: "For humans only",
|
|
854
|
+
audience: "human",
|
|
855
|
+
});
|
|
856
|
+
expect(res.status).toBe(201);
|
|
857
|
+
const body = (await json(res)) as { audience: string };
|
|
858
|
+
expect(body.audience).toBe("human");
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("defaults audience to 'agent' for non-orchestrator sender", async () => {
|
|
862
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
863
|
+
from: "agent1",
|
|
864
|
+
to: "agent2",
|
|
865
|
+
subject: "Default audience",
|
|
866
|
+
body: "No audience specified",
|
|
867
|
+
});
|
|
868
|
+
expect(res.status).toBe(201);
|
|
869
|
+
const body = (await json(res)) as { audience: string };
|
|
870
|
+
expect(body.audience).toBe("agent");
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("defaults audience to 'both' for orchestrator sender", async () => {
|
|
874
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
875
|
+
from: "orchestrator",
|
|
876
|
+
to: "agent2",
|
|
877
|
+
subject: "Orchestrator message",
|
|
878
|
+
body: "From orchestrator",
|
|
879
|
+
});
|
|
880
|
+
expect(res.status).toBe(201);
|
|
881
|
+
const body = (await json(res)) as { audience: string };
|
|
882
|
+
expect(body.audience).toBe("both");
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("defaults audience to 'both' for coordinator sender", async () => {
|
|
886
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
887
|
+
from: "coordinator",
|
|
888
|
+
to: "agent2",
|
|
889
|
+
subject: "Coordinator message",
|
|
890
|
+
body: "From coordinator",
|
|
891
|
+
});
|
|
892
|
+
expect(res.status).toBe(201);
|
|
893
|
+
const body = (await json(res)) as { audience: string };
|
|
894
|
+
expect(body.audience).toBe("both");
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it("falls back to sender-appropriate default for invalid audience value", async () => {
|
|
898
|
+
const res = await dispatchPost("/api/mail/send", {
|
|
899
|
+
from: "agent1",
|
|
900
|
+
to: "agent2",
|
|
901
|
+
subject: "Bad audience",
|
|
902
|
+
body: "Invalid audience value",
|
|
903
|
+
audience: "invalid_audience_xyz",
|
|
904
|
+
});
|
|
905
|
+
expect(res.status).toBe(201);
|
|
906
|
+
const body = (await json(res)) as { audience: string };
|
|
907
|
+
expect(body.audience).toBe("agent");
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
// GET /api/mail/conversations
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
describe("GET /api/mail/conversations", () => {
|
|
916
|
+
it("returns empty array when mail.db does not exist", async () => {
|
|
917
|
+
const res = await dispatch("/api/mail/conversations");
|
|
918
|
+
expect(res.status).toBe(200);
|
|
919
|
+
expect(await json(res)).toEqual([]);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("returns grouped conversations with correct structure", async () => {
|
|
923
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
924
|
+
const res = await dispatch("/api/mail/conversations");
|
|
925
|
+
expect(res.status).toBe(200);
|
|
926
|
+
const body = (await json(res)) as Array<{
|
|
927
|
+
participants: [string, string];
|
|
928
|
+
lastMessage: { id: string };
|
|
929
|
+
messageCount: number;
|
|
930
|
+
unreadCount: number;
|
|
931
|
+
}>;
|
|
932
|
+
// 3 messages: agent1<->agent2 (2 messages), agent1<->orchestrator (1 message)
|
|
933
|
+
expect(body.length).toBe(2);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("groups agent1<->agent2 messages (both directions) into one conversation", async () => {
|
|
937
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
938
|
+
const res = await dispatch("/api/mail/conversations");
|
|
939
|
+
const body = (await json(res)) as Array<{
|
|
940
|
+
participants: [string, string];
|
|
941
|
+
messageCount: number;
|
|
942
|
+
unreadCount: number;
|
|
943
|
+
}>;
|
|
944
|
+
const conv = body.find(
|
|
945
|
+
(c) => c.participants.includes("agent1") && c.participants.includes("agent2"),
|
|
946
|
+
);
|
|
947
|
+
expect(conv).toBeDefined();
|
|
948
|
+
expect(conv?.messageCount).toBe(2);
|
|
949
|
+
// both messages are marked read
|
|
950
|
+
expect(conv?.unreadCount).toBe(0);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it("computes unread count correctly", async () => {
|
|
954
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
955
|
+
const res = await dispatch("/api/mail/conversations");
|
|
956
|
+
const body = (await json(res)) as Array<{
|
|
957
|
+
participants: [string, string];
|
|
958
|
+
unreadCount: number;
|
|
959
|
+
}>;
|
|
960
|
+
// agent1<->orchestrator: 1 unread message (msg-ccc333)
|
|
961
|
+
const conv = body.find(
|
|
962
|
+
(c) => c.participants.includes("agent1") && c.participants.includes("orchestrator"),
|
|
963
|
+
);
|
|
964
|
+
expect(conv?.unreadCount).toBe(1);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("participants are sorted alphabetically", async () => {
|
|
968
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
969
|
+
const res = await dispatch("/api/mail/conversations");
|
|
970
|
+
const body = (await json(res)) as Array<{ participants: [string, string] }>;
|
|
971
|
+
for (const conv of body) {
|
|
972
|
+
expect(conv.participants[0] <= conv.participants[1]).toBe(true);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("filters conversations by ?agent= param", async () => {
|
|
977
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
978
|
+
const res = await dispatch("/api/mail/conversations", { agent: "orchestrator" });
|
|
979
|
+
const body = (await json(res)) as Array<{ participants: [string, string] }>;
|
|
980
|
+
// Only agent1<->orchestrator conversation
|
|
981
|
+
expect(body.length).toBe(1);
|
|
982
|
+
expect(body[0]?.participants).toContain("orchestrator");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("returns empty array when agent filter matches no conversations", async () => {
|
|
986
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
987
|
+
const res = await dispatch("/api/mail/conversations", { agent: "nobody" });
|
|
988
|
+
const body = (await json(res)) as unknown[];
|
|
989
|
+
expect(body.length).toBe(0);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("filters conversations by ?audience=human to only include human-audience messages", async () => {
|
|
993
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
994
|
+
const res = await dispatch("/api/mail/conversations", { audience: "human" });
|
|
995
|
+
expect(res.status).toBe(200);
|
|
996
|
+
const body = (await json(res)) as Array<{
|
|
997
|
+
participants: [string, string];
|
|
998
|
+
messageCount: number;
|
|
999
|
+
}>;
|
|
1000
|
+
// Only msg-bbb222 has audience="human" (agent2→agent1)
|
|
1001
|
+
// So only the agent1<->agent2 conversation appears, with 1 message
|
|
1002
|
+
expect(body.length).toBe(1);
|
|
1003
|
+
expect(body[0]?.messageCount).toBe(1);
|
|
1004
|
+
expect(body[0]?.participants).toContain("agent1");
|
|
1005
|
+
expect(body[0]?.participants).toContain("agent2");
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("sorts conversations by most recent message first", async () => {
|
|
1009
|
+
seedMailDb(join(legioDir, "mail.db"));
|
|
1010
|
+
const res = await dispatch("/api/mail/conversations");
|
|
1011
|
+
const body = (await json(res)) as Array<{
|
|
1012
|
+
lastMessage: { createdAt: string };
|
|
1013
|
+
}>;
|
|
1014
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
1015
|
+
const curr = body[i];
|
|
1016
|
+
const next = body[i + 1];
|
|
1017
|
+
if (curr && next) {
|
|
1018
|
+
expect(curr.lastMessage.createdAt >= next.lastMessage.createdAt).toBe(true);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
// Event routes
|
|
1026
|
+
// ---------------------------------------------------------------------------
|
|
1027
|
+
|
|
1028
|
+
describe("GET /api/events", () => {
|
|
1029
|
+
it("returns 400 when since param is missing", async () => {
|
|
1030
|
+
const res = await dispatch("/api/events");
|
|
1031
|
+
expect(res.status).toBe(400);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("returns events for given since timestamp", async () => {
|
|
1035
|
+
seedEventDb(join(legioDir, "events.db"));
|
|
1036
|
+
// Use epoch to ensure all events are included regardless of clock precision
|
|
1037
|
+
const since = "1970-01-01T00:00:00.000Z";
|
|
1038
|
+
const res = await dispatch("/api/events", { since });
|
|
1039
|
+
expect(res.status).toBe(200);
|
|
1040
|
+
const body = (await json(res)) as unknown[];
|
|
1041
|
+
expect(body.length).toBeGreaterThan(0);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
describe("GET /api/events/errors", () => {
|
|
1046
|
+
it("returns error-level events", async () => {
|
|
1047
|
+
seedEventDb(join(legioDir, "events.db"));
|
|
1048
|
+
const res = await dispatch("/api/events/errors");
|
|
1049
|
+
expect(res.status).toBe(200);
|
|
1050
|
+
const body = (await json(res)) as Array<{ level: string }>;
|
|
1051
|
+
expect(body.every((e) => e.level === "error")).toBe(true);
|
|
1052
|
+
expect(body.length).toBe(1);
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
// Metrics routes
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
|
|
1060
|
+
describe("GET /api/metrics", () => {
|
|
1061
|
+
it("returns empty array when no metrics.db", async () => {
|
|
1062
|
+
const res = await dispatch("/api/metrics");
|
|
1063
|
+
expect(res.status).toBe(200);
|
|
1064
|
+
expect(await json(res)).toEqual([]);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it("returns session metrics", async () => {
|
|
1068
|
+
seedMetricsDb(join(legioDir, "metrics.db"));
|
|
1069
|
+
const res = await dispatch("/api/metrics");
|
|
1070
|
+
expect(res.status).toBe(200);
|
|
1071
|
+
const body = (await json(res)) as unknown[];
|
|
1072
|
+
expect(body.length).toBe(2);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("filters by since param", async () => {
|
|
1076
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
1077
|
+
store.recordSession({
|
|
1078
|
+
agentName: "agent-a",
|
|
1079
|
+
beadId: "task-old",
|
|
1080
|
+
capability: "builder",
|
|
1081
|
+
startedAt: "2026-01-01T10:00:00Z",
|
|
1082
|
+
completedAt: null,
|
|
1083
|
+
durationMs: 1000,
|
|
1084
|
+
exitCode: 0,
|
|
1085
|
+
mergeResult: null,
|
|
1086
|
+
parentAgent: null,
|
|
1087
|
+
inputTokens: 10,
|
|
1088
|
+
outputTokens: 5,
|
|
1089
|
+
cacheReadTokens: 0,
|
|
1090
|
+
cacheCreationTokens: 0,
|
|
1091
|
+
estimatedCostUsd: null,
|
|
1092
|
+
modelUsed: null,
|
|
1093
|
+
});
|
|
1094
|
+
store.recordSession({
|
|
1095
|
+
agentName: "agent-b",
|
|
1096
|
+
beadId: "task-new",
|
|
1097
|
+
capability: "scout",
|
|
1098
|
+
startedAt: "2026-06-01T10:00:00Z",
|
|
1099
|
+
completedAt: null,
|
|
1100
|
+
durationMs: 2000,
|
|
1101
|
+
exitCode: 0,
|
|
1102
|
+
mergeResult: null,
|
|
1103
|
+
parentAgent: null,
|
|
1104
|
+
inputTokens: 20,
|
|
1105
|
+
outputTokens: 10,
|
|
1106
|
+
cacheReadTokens: 0,
|
|
1107
|
+
cacheCreationTokens: 0,
|
|
1108
|
+
estimatedCostUsd: null,
|
|
1109
|
+
modelUsed: null,
|
|
1110
|
+
});
|
|
1111
|
+
store.close();
|
|
1112
|
+
|
|
1113
|
+
const res = await dispatch("/api/metrics", { since: "2026-03-01T00:00:00Z" });
|
|
1114
|
+
expect(res.status).toBe(200);
|
|
1115
|
+
const body = (await json(res)) as Array<{ beadId: string }>;
|
|
1116
|
+
expect(body).toHaveLength(1);
|
|
1117
|
+
expect(body[0]?.beadId).toBe("task-new");
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it("filters by until param", async () => {
|
|
1121
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
1122
|
+
store.recordSession({
|
|
1123
|
+
agentName: "agent-a",
|
|
1124
|
+
beadId: "task-old",
|
|
1125
|
+
capability: "builder",
|
|
1126
|
+
startedAt: "2026-01-01T10:00:00Z",
|
|
1127
|
+
completedAt: null,
|
|
1128
|
+
durationMs: 1000,
|
|
1129
|
+
exitCode: 0,
|
|
1130
|
+
mergeResult: null,
|
|
1131
|
+
parentAgent: null,
|
|
1132
|
+
inputTokens: 10,
|
|
1133
|
+
outputTokens: 5,
|
|
1134
|
+
cacheReadTokens: 0,
|
|
1135
|
+
cacheCreationTokens: 0,
|
|
1136
|
+
estimatedCostUsd: null,
|
|
1137
|
+
modelUsed: null,
|
|
1138
|
+
});
|
|
1139
|
+
store.recordSession({
|
|
1140
|
+
agentName: "agent-b",
|
|
1141
|
+
beadId: "task-new",
|
|
1142
|
+
capability: "scout",
|
|
1143
|
+
startedAt: "2026-06-01T10:00:00Z",
|
|
1144
|
+
completedAt: null,
|
|
1145
|
+
durationMs: 2000,
|
|
1146
|
+
exitCode: 0,
|
|
1147
|
+
mergeResult: null,
|
|
1148
|
+
parentAgent: null,
|
|
1149
|
+
inputTokens: 20,
|
|
1150
|
+
outputTokens: 10,
|
|
1151
|
+
cacheReadTokens: 0,
|
|
1152
|
+
cacheCreationTokens: 0,
|
|
1153
|
+
estimatedCostUsd: null,
|
|
1154
|
+
modelUsed: null,
|
|
1155
|
+
});
|
|
1156
|
+
store.close();
|
|
1157
|
+
|
|
1158
|
+
const res = await dispatch("/api/metrics", { until: "2026-03-01T00:00:00Z" });
|
|
1159
|
+
expect(res.status).toBe(200);
|
|
1160
|
+
const body = (await json(res)) as Array<{ beadId: string }>;
|
|
1161
|
+
expect(body).toHaveLength(1);
|
|
1162
|
+
expect(body[0]?.beadId).toBe("task-old");
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
describe("GET /api/metrics/by-model", () => {
|
|
1167
|
+
it("returns empty array when no metrics.db", async () => {
|
|
1168
|
+
const res = await dispatch("/api/metrics/by-model");
|
|
1169
|
+
expect(res.status).toBe(200);
|
|
1170
|
+
expect(await json(res)).toEqual([]);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it("groups sessions by model", async () => {
|
|
1174
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
1175
|
+
store.recordSession({
|
|
1176
|
+
agentName: "agent-a",
|
|
1177
|
+
beadId: "task-1",
|
|
1178
|
+
capability: "builder",
|
|
1179
|
+
startedAt: "2026-01-01T10:00:00Z",
|
|
1180
|
+
completedAt: null,
|
|
1181
|
+
durationMs: 1000,
|
|
1182
|
+
exitCode: 0,
|
|
1183
|
+
mergeResult: null,
|
|
1184
|
+
parentAgent: null,
|
|
1185
|
+
inputTokens: 100,
|
|
1186
|
+
outputTokens: 50,
|
|
1187
|
+
cacheReadTokens: 10,
|
|
1188
|
+
cacheCreationTokens: 5,
|
|
1189
|
+
estimatedCostUsd: 1.0,
|
|
1190
|
+
modelUsed: "claude-opus-4-6",
|
|
1191
|
+
});
|
|
1192
|
+
store.recordSession({
|
|
1193
|
+
agentName: "agent-b",
|
|
1194
|
+
beadId: "task-2",
|
|
1195
|
+
capability: "scout",
|
|
1196
|
+
startedAt: "2026-01-01T12:00:00Z",
|
|
1197
|
+
completedAt: null,
|
|
1198
|
+
durationMs: 2000,
|
|
1199
|
+
exitCode: 0,
|
|
1200
|
+
mergeResult: null,
|
|
1201
|
+
parentAgent: null,
|
|
1202
|
+
inputTokens: 200,
|
|
1203
|
+
outputTokens: 100,
|
|
1204
|
+
cacheReadTokens: 20,
|
|
1205
|
+
cacheCreationTokens: 10,
|
|
1206
|
+
estimatedCostUsd: 0.5,
|
|
1207
|
+
modelUsed: "claude-sonnet-4-6",
|
|
1208
|
+
});
|
|
1209
|
+
store.close();
|
|
1210
|
+
|
|
1211
|
+
const res = await dispatch("/api/metrics/by-model");
|
|
1212
|
+
expect(res.status).toBe(200);
|
|
1213
|
+
const body = (await json(res)) as Array<{
|
|
1214
|
+
model: string;
|
|
1215
|
+
sessions: number;
|
|
1216
|
+
inputTokens: number;
|
|
1217
|
+
}>;
|
|
1218
|
+
expect(body).toHaveLength(2);
|
|
1219
|
+
const opus = body.find((r) => r.model === "claude-opus-4-6");
|
|
1220
|
+
expect(opus?.sessions).toBe(1);
|
|
1221
|
+
expect(opus?.inputTokens).toBe(100);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it("accepts since/until filter", async () => {
|
|
1225
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
1226
|
+
store.recordSession({
|
|
1227
|
+
agentName: "agent-a",
|
|
1228
|
+
beadId: "task-old",
|
|
1229
|
+
capability: "builder",
|
|
1230
|
+
startedAt: "2026-01-01T10:00:00Z",
|
|
1231
|
+
completedAt: null,
|
|
1232
|
+
durationMs: 1000,
|
|
1233
|
+
exitCode: 0,
|
|
1234
|
+
mergeResult: null,
|
|
1235
|
+
parentAgent: null,
|
|
1236
|
+
inputTokens: 100,
|
|
1237
|
+
outputTokens: 50,
|
|
1238
|
+
cacheReadTokens: 0,
|
|
1239
|
+
cacheCreationTokens: 0,
|
|
1240
|
+
estimatedCostUsd: 1.0,
|
|
1241
|
+
modelUsed: "claude-opus-4-6",
|
|
1242
|
+
});
|
|
1243
|
+
store.recordSession({
|
|
1244
|
+
agentName: "agent-b",
|
|
1245
|
+
beadId: "task-new",
|
|
1246
|
+
capability: "scout",
|
|
1247
|
+
startedAt: "2026-06-01T10:00:00Z",
|
|
1248
|
+
completedAt: null,
|
|
1249
|
+
durationMs: 2000,
|
|
1250
|
+
exitCode: 0,
|
|
1251
|
+
mergeResult: null,
|
|
1252
|
+
parentAgent: null,
|
|
1253
|
+
inputTokens: 200,
|
|
1254
|
+
outputTokens: 100,
|
|
1255
|
+
cacheReadTokens: 0,
|
|
1256
|
+
cacheCreationTokens: 0,
|
|
1257
|
+
estimatedCostUsd: 0.5,
|
|
1258
|
+
modelUsed: "claude-sonnet-4-6",
|
|
1259
|
+
});
|
|
1260
|
+
store.close();
|
|
1261
|
+
|
|
1262
|
+
const res = await dispatch("/api/metrics/by-model", { since: "2026-03-01T00:00:00Z" });
|
|
1263
|
+
expect(res.status).toBe(200);
|
|
1264
|
+
const body = (await json(res)) as Array<{ model: string }>;
|
|
1265
|
+
expect(body).toHaveLength(1);
|
|
1266
|
+
expect(body[0]?.model).toBe("claude-sonnet-4-6");
|
|
1267
|
+
});
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
describe("GET /api/metrics/by-date", () => {
|
|
1271
|
+
it("returns empty array when no metrics.db", async () => {
|
|
1272
|
+
const res = await dispatch("/api/metrics/by-date");
|
|
1273
|
+
expect(res.status).toBe(200);
|
|
1274
|
+
expect(await json(res)).toEqual([]);
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it("groups sessions by date", async () => {
|
|
1278
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
1279
|
+
store.recordSession({
|
|
1280
|
+
agentName: "agent-a",
|
|
1281
|
+
beadId: "task-1",
|
|
1282
|
+
capability: "builder",
|
|
1283
|
+
startedAt: "2026-01-01T10:00:00Z",
|
|
1284
|
+
completedAt: null,
|
|
1285
|
+
durationMs: 1000,
|
|
1286
|
+
exitCode: 0,
|
|
1287
|
+
mergeResult: null,
|
|
1288
|
+
parentAgent: null,
|
|
1289
|
+
inputTokens: 100,
|
|
1290
|
+
outputTokens: 50,
|
|
1291
|
+
cacheReadTokens: 10,
|
|
1292
|
+
cacheCreationTokens: 5,
|
|
1293
|
+
estimatedCostUsd: 0.5,
|
|
1294
|
+
modelUsed: "claude-sonnet-4-6",
|
|
1295
|
+
});
|
|
1296
|
+
store.recordSession({
|
|
1297
|
+
agentName: "agent-b",
|
|
1298
|
+
beadId: "task-2",
|
|
1299
|
+
capability: "scout",
|
|
1300
|
+
startedAt: "2026-01-01T14:00:00Z",
|
|
1301
|
+
completedAt: null,
|
|
1302
|
+
durationMs: 2000,
|
|
1303
|
+
exitCode: 0,
|
|
1304
|
+
mergeResult: null,
|
|
1305
|
+
parentAgent: null,
|
|
1306
|
+
inputTokens: 200,
|
|
1307
|
+
outputTokens: 100,
|
|
1308
|
+
cacheReadTokens: 20,
|
|
1309
|
+
cacheCreationTokens: 10,
|
|
1310
|
+
estimatedCostUsd: 0.5,
|
|
1311
|
+
modelUsed: "claude-sonnet-4-6",
|
|
1312
|
+
});
|
|
1313
|
+
store.recordSession({
|
|
1314
|
+
agentName: "agent-c",
|
|
1315
|
+
beadId: "task-3",
|
|
1316
|
+
capability: "builder",
|
|
1317
|
+
startedAt: "2026-01-02T08:00:00Z",
|
|
1318
|
+
completedAt: null,
|
|
1319
|
+
durationMs: 3000,
|
|
1320
|
+
exitCode: 0,
|
|
1321
|
+
mergeResult: null,
|
|
1322
|
+
parentAgent: null,
|
|
1323
|
+
inputTokens: 300,
|
|
1324
|
+
outputTokens: 150,
|
|
1325
|
+
cacheReadTokens: 30,
|
|
1326
|
+
cacheCreationTokens: 15,
|
|
1327
|
+
estimatedCostUsd: 0.3,
|
|
1328
|
+
modelUsed: "claude-opus-4-6",
|
|
1329
|
+
});
|
|
1330
|
+
store.close();
|
|
1331
|
+
|
|
1332
|
+
const res = await dispatch("/api/metrics/by-date");
|
|
1333
|
+
expect(res.status).toBe(200);
|
|
1334
|
+
const body = (await json(res)) as Array<{
|
|
1335
|
+
date: string;
|
|
1336
|
+
sessions: number;
|
|
1337
|
+
inputTokens: number;
|
|
1338
|
+
}>;
|
|
1339
|
+
expect(body).toHaveLength(2);
|
|
1340
|
+
expect(body[0]?.date).toBe("2026-01-01");
|
|
1341
|
+
expect(body[0]?.sessions).toBe(2);
|
|
1342
|
+
expect(body[0]?.inputTokens).toBe(300);
|
|
1343
|
+
expect(body[1]?.date).toBe("2026-01-02");
|
|
1344
|
+
expect(body[1]?.sessions).toBe(1);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it("accepts since/until filter", async () => {
|
|
1348
|
+
const store = createMetricsStore(join(legioDir, "metrics.db"));
|
|
1349
|
+
store.recordSession({
|
|
1350
|
+
agentName: "agent-a",
|
|
1351
|
+
beadId: "task-1",
|
|
1352
|
+
capability: "builder",
|
|
1353
|
+
startedAt: "2026-01-01T10:00:00Z",
|
|
1354
|
+
completedAt: null,
|
|
1355
|
+
durationMs: 1000,
|
|
1356
|
+
exitCode: 0,
|
|
1357
|
+
mergeResult: null,
|
|
1358
|
+
parentAgent: null,
|
|
1359
|
+
inputTokens: 100,
|
|
1360
|
+
outputTokens: 50,
|
|
1361
|
+
cacheReadTokens: 0,
|
|
1362
|
+
cacheCreationTokens: 0,
|
|
1363
|
+
estimatedCostUsd: 1.0,
|
|
1364
|
+
modelUsed: null,
|
|
1365
|
+
});
|
|
1366
|
+
store.recordSession({
|
|
1367
|
+
agentName: "agent-b",
|
|
1368
|
+
beadId: "task-2",
|
|
1369
|
+
capability: "scout",
|
|
1370
|
+
startedAt: "2026-06-01T10:00:00Z",
|
|
1371
|
+
completedAt: null,
|
|
1372
|
+
durationMs: 2000,
|
|
1373
|
+
exitCode: 0,
|
|
1374
|
+
mergeResult: null,
|
|
1375
|
+
parentAgent: null,
|
|
1376
|
+
inputTokens: 200,
|
|
1377
|
+
outputTokens: 100,
|
|
1378
|
+
cacheReadTokens: 0,
|
|
1379
|
+
cacheCreationTokens: 0,
|
|
1380
|
+
estimatedCostUsd: 2.0,
|
|
1381
|
+
modelUsed: null,
|
|
1382
|
+
});
|
|
1383
|
+
store.close();
|
|
1384
|
+
|
|
1385
|
+
const res = await dispatch("/api/metrics/by-date", { since: "2026-03-01T00:00:00Z" });
|
|
1386
|
+
expect(res.status).toBe(200);
|
|
1387
|
+
const body = (await json(res)) as Array<{ date: string }>;
|
|
1388
|
+
expect(body).toHaveLength(1);
|
|
1389
|
+
expect(body[0]?.date).toBe("2026-06-01");
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
// ---------------------------------------------------------------------------
|
|
1394
|
+
// Runs routes
|
|
1395
|
+
// ---------------------------------------------------------------------------
|
|
1396
|
+
|
|
1397
|
+
describe("GET /api/runs", () => {
|
|
1398
|
+
it("returns empty array when no sessions.db", async () => {
|
|
1399
|
+
const res = await dispatch("/api/runs");
|
|
1400
|
+
expect(res.status).toBe(200);
|
|
1401
|
+
expect(await json(res)).toEqual([]);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
it("returns run list", async () => {
|
|
1405
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
1406
|
+
seedRunDb(join(legioDir, "sessions.db"));
|
|
1407
|
+
const res = await dispatch("/api/runs");
|
|
1408
|
+
expect(res.status).toBe(200);
|
|
1409
|
+
const body = (await json(res)) as unknown[];
|
|
1410
|
+
expect(body.length).toBe(1);
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
describe("GET /api/runs/active", () => {
|
|
1415
|
+
it("returns null when no active run", async () => {
|
|
1416
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
1417
|
+
const res = await dispatch("/api/runs/active");
|
|
1418
|
+
expect(res.status).toBe(200);
|
|
1419
|
+
expect(await json(res)).toBeNull();
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
it("returns active run", async () => {
|
|
1423
|
+
seedSessionDb(join(legioDir, "sessions.db"));
|
|
1424
|
+
seedRunDb(join(legioDir, "sessions.db"));
|
|
1425
|
+
const res = await dispatch("/api/runs/active");
|
|
1426
|
+
expect(res.status).toBe(200);
|
|
1427
|
+
const body = (await json(res)) as { id: string; status: string };
|
|
1428
|
+
expect(body.id).toBe("run-001");
|
|
1429
|
+
expect(body.status).toBe("active");
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
// ---------------------------------------------------------------------------
|
|
1434
|
+
// Merge queue routes
|
|
1435
|
+
// ---------------------------------------------------------------------------
|
|
1436
|
+
|
|
1437
|
+
describe("GET /api/merge-queue", () => {
|
|
1438
|
+
it("returns empty array when no merge-queue.db", async () => {
|
|
1439
|
+
const res = await dispatch("/api/merge-queue");
|
|
1440
|
+
expect(res.status).toBe(200);
|
|
1441
|
+
expect(await json(res)).toEqual([]);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it("returns queue entries", async () => {
|
|
1445
|
+
seedMergeQueueDb(join(legioDir, "merge-queue.db"));
|
|
1446
|
+
const res = await dispatch("/api/merge-queue");
|
|
1447
|
+
expect(res.status).toBe(200);
|
|
1448
|
+
const body = (await json(res)) as unknown[];
|
|
1449
|
+
expect(body.length).toBe(2);
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// ---------------------------------------------------------------------------
|
|
1454
|
+
// Issues routes
|
|
1455
|
+
// ---------------------------------------------------------------------------
|
|
1456
|
+
|
|
1457
|
+
describe("GET /api/issues", () => {
|
|
1458
|
+
it("returns 200 with JSON array (empty if bd unavailable)", async () => {
|
|
1459
|
+
const res = await dispatch("/api/issues");
|
|
1460
|
+
expect(res.status).toBe(200);
|
|
1461
|
+
const body = await json(res);
|
|
1462
|
+
expect(Array.isArray(body)).toBe(true);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it("accepts status query param", async () => {
|
|
1466
|
+
const res = await dispatch("/api/issues", { status: "open" });
|
|
1467
|
+
expect(res.status).toBe(200);
|
|
1468
|
+
expect(Array.isArray(await json(res))).toBe(true);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it("defaults to returning all statuses (all=true)", async () => {
|
|
1472
|
+
const res = await dispatch("/api/issues");
|
|
1473
|
+
expect(res.status).toBe(200);
|
|
1474
|
+
const body = (await json(res)) as Array<{ status: string }>;
|
|
1475
|
+
// Mock returns a closed and blocked issue when all=true
|
|
1476
|
+
expect(body.length).toBe(2);
|
|
1477
|
+
const statuses = body.map((i) => i.status);
|
|
1478
|
+
expect(statuses).toContain("closed");
|
|
1479
|
+
expect(statuses).toContain("blocked");
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
it("returns closedAt and closeReason fields on closed issues", async () => {
|
|
1483
|
+
const res = await dispatch("/api/issues");
|
|
1484
|
+
expect(res.status).toBe(200);
|
|
1485
|
+
const body = (await json(res)) as Array<{
|
|
1486
|
+
id: string;
|
|
1487
|
+
closedAt?: string;
|
|
1488
|
+
closeReason?: string;
|
|
1489
|
+
}>;
|
|
1490
|
+
const closedIssue = body.find((i) => i.id === "bead-closed-001");
|
|
1491
|
+
expect(closedIssue?.closedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
1492
|
+
expect(closedIssue?.closeReason).toBe("Done");
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it("returns blocked issues with status=blocked", async () => {
|
|
1496
|
+
const res = await dispatch("/api/issues");
|
|
1497
|
+
expect(res.status).toBe(200);
|
|
1498
|
+
const body = (await json(res)) as Array<{ id: string; status: string }>;
|
|
1499
|
+
const blockedIssue = body.find((i) => i.id === "bead-blocked-001");
|
|
1500
|
+
expect(blockedIssue).toBeDefined();
|
|
1501
|
+
expect(blockedIssue?.status).toBe("blocked");
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
it("passes all=false to client when ?all=false query param is set", async () => {
|
|
1505
|
+
const res = await dispatch("/api/issues", { all: "false" });
|
|
1506
|
+
expect(res.status).toBe(200);
|
|
1507
|
+
const body = await json(res);
|
|
1508
|
+
expect(Array.isArray(body)).toBe(true);
|
|
1509
|
+
// Mock returns [] when all is false
|
|
1510
|
+
expect((body as unknown[]).length).toBe(0);
|
|
1511
|
+
});
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
describe("GET /api/issues/ready", () => {
|
|
1515
|
+
it("returns 200 with JSON array (empty if bd unavailable)", async () => {
|
|
1516
|
+
const res = await dispatch("/api/issues/ready");
|
|
1517
|
+
expect(res.status).toBe(200);
|
|
1518
|
+
const body = await json(res);
|
|
1519
|
+
expect(Array.isArray(body)).toBe(true);
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
describe("GET /api/issues/:id", () => {
|
|
1524
|
+
it("returns 404 for nonexistent issue (or if bd unavailable)", async () => {
|
|
1525
|
+
const res = await dispatch("/api/issues/nonexistent-id");
|
|
1526
|
+
expect(res.status).toBe(404);
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
1529
|
+
|
|
1530
|
+
// ---------------------------------------------------------------------------
|
|
1531
|
+
// Terminal routes
|
|
1532
|
+
// Real tmux calls are not made in tests (would interfere with dev sessions).
|
|
1533
|
+
// We test validation, 404 paths (no sessions.db / orchestrator-tmux.json),
|
|
1534
|
+
// and correct response shapes.
|
|
1535
|
+
// ---------------------------------------------------------------------------
|
|
1536
|
+
|
|
1537
|
+
describe("POST /api/terminal/send", () => {
|
|
1538
|
+
it("returns 400 when text field is missing", async () => {
|
|
1539
|
+
const res = await dispatchPost("/api/terminal/send", { agent: "coordinator" });
|
|
1540
|
+
expect(res.status).toBe(400);
|
|
1541
|
+
const body = (await json(res)) as { error: string };
|
|
1542
|
+
expect(body.error).toContain("text");
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it("returns 400 when text is empty string", async () => {
|
|
1546
|
+
const res = await dispatchPost("/api/terminal/send", { text: "", agent: "coordinator" });
|
|
1547
|
+
expect(res.status).toBe(400);
|
|
1548
|
+
const body = (await json(res)) as { error: string };
|
|
1549
|
+
expect(body.error).toContain("text");
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
it("returns 400 when text is whitespace only", async () => {
|
|
1553
|
+
const res = await dispatchPost("/api/terminal/send", { text: " " });
|
|
1554
|
+
expect(res.status).toBe(400);
|
|
1555
|
+
const body = (await json(res)) as { error: string };
|
|
1556
|
+
expect(body.error).toContain("text");
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
it("returns 400 for invalid JSON body", async () => {
|
|
1560
|
+
const res = await handleApiRequest(
|
|
1561
|
+
new Request("http://localhost/api/terminal/send", {
|
|
1562
|
+
method: "POST",
|
|
1563
|
+
body: "not json",
|
|
1564
|
+
headers: { "Content-Type": "text/plain" },
|
|
1565
|
+
}),
|
|
1566
|
+
legioDir,
|
|
1567
|
+
projectRoot,
|
|
1568
|
+
);
|
|
1569
|
+
expect(res.status).toBe(400);
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
it("returns 404 when agent session cannot be resolved (defaults to coordinator)", async () => {
|
|
1573
|
+
// No sessions.db, no orchestrator-tmux.json — session cannot be resolved
|
|
1574
|
+
const res = await dispatchPost("/api/terminal/send", { text: "Hello coordinator" });
|
|
1575
|
+
expect(res.status).toBe(404);
|
|
1576
|
+
const body = (await json(res)) as { error: string };
|
|
1577
|
+
expect(body.error).toContain("coordinator");
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
it("returns 404 when named agent session cannot be resolved", async () => {
|
|
1581
|
+
const res = await dispatchPost("/api/terminal/send", {
|
|
1582
|
+
text: "Hello",
|
|
1583
|
+
agent: "nonexistent-agent",
|
|
1584
|
+
});
|
|
1585
|
+
expect(res.status).toBe(404);
|
|
1586
|
+
const body = (await json(res)) as { error: string };
|
|
1587
|
+
expect(body.error).toContain("nonexistent-agent");
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
it("returns 404 when agent session is in DB but tmux session is not alive", async () => {
|
|
1591
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
1592
|
+
const store = createSessionStore(dbPath);
|
|
1593
|
+
const now = new Date().toISOString();
|
|
1594
|
+
store.upsert({
|
|
1595
|
+
id: "sess-terminal-stale-001",
|
|
1596
|
+
agentName: "stale-agent",
|
|
1597
|
+
capability: "builder",
|
|
1598
|
+
worktreePath: "/tmp/wt/stale-agent",
|
|
1599
|
+
branchName: "legio/stale-agent/task-x",
|
|
1600
|
+
beadId: "task-x",
|
|
1601
|
+
tmuxSession: "legio-test-stale-agent",
|
|
1602
|
+
state: "working",
|
|
1603
|
+
pid: 99999,
|
|
1604
|
+
parentAgent: null,
|
|
1605
|
+
depth: 1,
|
|
1606
|
+
runId: "run-001",
|
|
1607
|
+
startedAt: now,
|
|
1608
|
+
lastActivity: now,
|
|
1609
|
+
escalationLevel: 0,
|
|
1610
|
+
stalledSince: null,
|
|
1611
|
+
});
|
|
1612
|
+
store.close();
|
|
1613
|
+
|
|
1614
|
+
mockIsSessionAlive.mockResolvedValueOnce(false);
|
|
1615
|
+
const res = await dispatchPost("/api/terminal/send", {
|
|
1616
|
+
text: "hello",
|
|
1617
|
+
agent: "stale-agent",
|
|
1618
|
+
});
|
|
1619
|
+
expect(res.status).toBe(404);
|
|
1620
|
+
const body = (await json(res)) as { error: string };
|
|
1621
|
+
expect(body.error).toContain("not alive");
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
it("returns 405 for GET requests on /api/terminal/send", async () => {
|
|
1625
|
+
const res = await dispatch("/api/terminal/send");
|
|
1626
|
+
// /api/terminal/send is only POST; GET falls through to the catch-all 404
|
|
1627
|
+
// since there is no GET handler registered for this path
|
|
1628
|
+
expect([404, 405]).toContain(res.status);
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
describe("GET /api/terminal/capture", () => {
|
|
1633
|
+
it("returns 404 when agent session cannot be resolved (defaults to coordinator)", async () => {
|
|
1634
|
+
// No sessions.db, no orchestrator-tmux.json
|
|
1635
|
+
const res = await dispatch("/api/terminal/capture");
|
|
1636
|
+
expect(res.status).toBe(404);
|
|
1637
|
+
const body = (await json(res)) as { error: string };
|
|
1638
|
+
expect(body.error).toContain("coordinator");
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it("returns 404 with named agent when session cannot be resolved", async () => {
|
|
1642
|
+
const res = await dispatch("/api/terminal/capture", { agent: "my-agent" });
|
|
1643
|
+
expect(res.status).toBe(404);
|
|
1644
|
+
const body = (await json(res)) as { error: string };
|
|
1645
|
+
expect(body.error).toContain("my-agent");
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
it("accepts custom lines param without error (returns 404 on no session)", async () => {
|
|
1649
|
+
const res = await dispatch("/api/terminal/capture", { lines: "50" });
|
|
1650
|
+
expect(res.status).toBe(404);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
it("returns 405 for POST requests on /api/terminal/capture", async () => {
|
|
1654
|
+
const res = await handleApiRequest(
|
|
1655
|
+
new Request("http://localhost/api/terminal/capture", {
|
|
1656
|
+
method: "POST",
|
|
1657
|
+
body: "{}",
|
|
1658
|
+
}),
|
|
1659
|
+
legioDir,
|
|
1660
|
+
projectRoot,
|
|
1661
|
+
);
|
|
1662
|
+
expect(res.status).toBe(405);
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
// ---------------------------------------------------------------------------
|
|
1667
|
+
// Audit routes
|
|
1668
|
+
// ---------------------------------------------------------------------------
|
|
1669
|
+
|
|
1670
|
+
function seedAuditDb(dbPath: string): void {
|
|
1671
|
+
const store = createAuditStore(dbPath);
|
|
1672
|
+
store.insert({ type: "command", agent: "orchestrator", source: "web_ui", summary: "cmd1" });
|
|
1673
|
+
store.insert({ type: "response", agent: "coordinator", source: "system", summary: "resp1" });
|
|
1674
|
+
store.insert({ type: "error", agent: "orchestrator", source: "cli", summary: "err1" });
|
|
1675
|
+
store.close();
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
describe("POST /api/audit", () => {
|
|
1679
|
+
it("creates an audit event and returns 201 with the event", async () => {
|
|
1680
|
+
const res = await dispatchPost("/api/audit", {
|
|
1681
|
+
type: "command",
|
|
1682
|
+
summary: "User sent a command",
|
|
1683
|
+
agent: "orchestrator",
|
|
1684
|
+
source: "web_ui",
|
|
1685
|
+
});
|
|
1686
|
+
expect(res.status).toBe(201);
|
|
1687
|
+
const body = (await json(res)) as {
|
|
1688
|
+
id: number;
|
|
1689
|
+
type: string;
|
|
1690
|
+
summary: string;
|
|
1691
|
+
agent: string;
|
|
1692
|
+
source: string;
|
|
1693
|
+
};
|
|
1694
|
+
expect(body.type).toBe("command");
|
|
1695
|
+
expect(body.summary).toBe("User sent a command");
|
|
1696
|
+
expect(body.agent).toBe("orchestrator");
|
|
1697
|
+
expect(body.source).toBe("web_ui");
|
|
1698
|
+
expect(typeof body.id).toBe("number");
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
it("defaults source to web_ui when not provided", async () => {
|
|
1702
|
+
const res = await dispatchPost("/api/audit", {
|
|
1703
|
+
type: "command",
|
|
1704
|
+
summary: "No source provided",
|
|
1705
|
+
});
|
|
1706
|
+
expect(res.status).toBe(201);
|
|
1707
|
+
const body = (await json(res)) as { source: string };
|
|
1708
|
+
expect(body.source).toBe("web_ui");
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
it("returns 400 when type is missing", async () => {
|
|
1712
|
+
const res = await dispatchPost("/api/audit", { summary: "Missing type" });
|
|
1713
|
+
expect(res.status).toBe(400);
|
|
1714
|
+
const body = (await json(res)) as { error: string };
|
|
1715
|
+
expect(body.error).toContain("type");
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
it("returns 400 when summary is missing", async () => {
|
|
1719
|
+
const res = await dispatchPost("/api/audit", { type: "command" });
|
|
1720
|
+
expect(res.status).toBe(400);
|
|
1721
|
+
const body = (await json(res)) as { error: string };
|
|
1722
|
+
expect(body.error).toContain("summary");
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it("returns 400 for non-JSON body", async () => {
|
|
1726
|
+
const res = await handleApiRequest(
|
|
1727
|
+
new Request("http://localhost/api/audit", {
|
|
1728
|
+
method: "POST",
|
|
1729
|
+
body: "not json",
|
|
1730
|
+
headers: { "Content-Type": "text/plain" },
|
|
1731
|
+
}),
|
|
1732
|
+
legioDir,
|
|
1733
|
+
projectRoot,
|
|
1734
|
+
);
|
|
1735
|
+
expect(res.status).toBe(400);
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
it("accepts optional detail and sessionId fields", async () => {
|
|
1739
|
+
const res = await dispatchPost("/api/audit", {
|
|
1740
|
+
type: "state_change",
|
|
1741
|
+
summary: "Agent started",
|
|
1742
|
+
detail: "Starting work on task",
|
|
1743
|
+
sessionId: "sess-abc",
|
|
1744
|
+
});
|
|
1745
|
+
expect(res.status).toBe(201);
|
|
1746
|
+
const body = (await json(res)) as { detail: string; sessionId: string };
|
|
1747
|
+
expect(body.detail).toBe("Starting work on task");
|
|
1748
|
+
expect(body.sessionId).toBe("sess-abc");
|
|
1749
|
+
});
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
describe("GET /api/audit", () => {
|
|
1753
|
+
it("returns empty array when audit.db does not exist", async () => {
|
|
1754
|
+
const res = await dispatch("/api/audit");
|
|
1755
|
+
expect(res.status).toBe(200);
|
|
1756
|
+
expect(await json(res)).toEqual([]);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
it("returns all audit events", async () => {
|
|
1760
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1761
|
+
const res = await dispatch("/api/audit");
|
|
1762
|
+
expect(res.status).toBe(200);
|
|
1763
|
+
const body = (await json(res)) as unknown[];
|
|
1764
|
+
expect(body.length).toBe(3);
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
it("filters by ?type= param", async () => {
|
|
1768
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1769
|
+
const res = await dispatch("/api/audit", { type: "command" });
|
|
1770
|
+
expect(res.status).toBe(200);
|
|
1771
|
+
const body = (await json(res)) as Array<{ type: string }>;
|
|
1772
|
+
expect(body.length).toBe(1);
|
|
1773
|
+
expect(body[0]?.type).toBe("command");
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
it("filters by ?agent= param", async () => {
|
|
1777
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1778
|
+
const res = await dispatch("/api/audit", { agent: "orchestrator" });
|
|
1779
|
+
expect(res.status).toBe(200);
|
|
1780
|
+
const body = (await json(res)) as Array<{ agent: string }>;
|
|
1781
|
+
expect(body.length).toBe(2);
|
|
1782
|
+
expect(body.every((e) => e.agent === "orchestrator")).toBe(true);
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
it("filters by ?source= param", async () => {
|
|
1786
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1787
|
+
const res = await dispatch("/api/audit", { source: "web_ui" });
|
|
1788
|
+
expect(res.status).toBe(200);
|
|
1789
|
+
const body = (await json(res)) as Array<{ source: string }>;
|
|
1790
|
+
expect(body.length).toBe(1);
|
|
1791
|
+
expect(body[0]?.source).toBe("web_ui");
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it("applies ?limit= param", async () => {
|
|
1795
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1796
|
+
const res = await dispatch("/api/audit", { limit: "2" });
|
|
1797
|
+
expect(res.status).toBe(200);
|
|
1798
|
+
const body = (await json(res)) as unknown[];
|
|
1799
|
+
expect(body.length).toBe(2);
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
it("returns 405 for unsupported methods", async () => {
|
|
1803
|
+
const res = await handleApiRequest(
|
|
1804
|
+
new Request("http://localhost/api/audit", { method: "PUT", body: "{}" }),
|
|
1805
|
+
legioDir,
|
|
1806
|
+
projectRoot,
|
|
1807
|
+
);
|
|
1808
|
+
expect(res.status).toBe(405);
|
|
1809
|
+
});
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
describe("GET /api/audit/timeline", () => {
|
|
1813
|
+
it("returns empty array when audit.db does not exist", async () => {
|
|
1814
|
+
const res = await dispatch("/api/audit/timeline");
|
|
1815
|
+
expect(res.status).toBe(200);
|
|
1816
|
+
expect(await json(res)).toEqual([]);
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("returns events in chronological order", async () => {
|
|
1820
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1821
|
+
const res = await dispatch("/api/audit/timeline");
|
|
1822
|
+
expect(res.status).toBe(200);
|
|
1823
|
+
const body = (await json(res)) as Array<{ createdAt: string }>;
|
|
1824
|
+
expect(body.length).toBeGreaterThan(0);
|
|
1825
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
1826
|
+
const a = body[i];
|
|
1827
|
+
const b = body[i + 1];
|
|
1828
|
+
if (a && b) {
|
|
1829
|
+
expect(a.createdAt <= b.createdAt).toBe(true);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
it("defaults to 24h window when since not provided", async () => {
|
|
1835
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1836
|
+
// Events just inserted are within the 24h window
|
|
1837
|
+
const res = await dispatch("/api/audit/timeline");
|
|
1838
|
+
expect(res.status).toBe(200);
|
|
1839
|
+
const body = (await json(res)) as unknown[];
|
|
1840
|
+
expect(body.length).toBe(3);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it("accepts ?since= param override", async () => {
|
|
1844
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1845
|
+
// Future timestamp — should return no events
|
|
1846
|
+
const res = await dispatch("/api/audit/timeline", { since: "2099-01-01T00:00:00.000Z" });
|
|
1847
|
+
expect(res.status).toBe(200);
|
|
1848
|
+
const body = (await json(res)) as unknown[];
|
|
1849
|
+
expect(body.length).toBe(0);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
it("applies ?limit= param", async () => {
|
|
1853
|
+
seedAuditDb(join(legioDir, "audit.db"));
|
|
1854
|
+
const res = await dispatch("/api/audit/timeline", { limit: "2" });
|
|
1855
|
+
expect(res.status).toBe(200);
|
|
1856
|
+
const body = (await json(res)) as unknown[];
|
|
1857
|
+
expect(body.length).toBe(2);
|
|
1858
|
+
});
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
// ---------------------------------------------------------------------------
|
|
1862
|
+
// Catch-all 404
|
|
1863
|
+
// ---------------------------------------------------------------------------
|
|
1864
|
+
|
|
1865
|
+
describe("Unknown /api/* path", () => {
|
|
1866
|
+
it("returns 404", async () => {
|
|
1867
|
+
const res = await dispatch("/api/something-unknown");
|
|
1868
|
+
expect(res.status).toBe(404);
|
|
1869
|
+
const body = (await json(res)) as { error: string };
|
|
1870
|
+
expect(body.error).toBe("Not found");
|
|
1871
|
+
});
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
// ---------------------------------------------------------------------------
|
|
1875
|
+
// Setup routes
|
|
1876
|
+
// ---------------------------------------------------------------------------
|
|
1877
|
+
|
|
1878
|
+
describe("GET /api/setup/status", () => {
|
|
1879
|
+
it("returns initialized:true with projectName when config.yaml exists", async () => {
|
|
1880
|
+
// beforeEach already writes config.yaml with project name "test"
|
|
1881
|
+
const res = await dispatch("/api/setup/status");
|
|
1882
|
+
expect(res.status).toBe(200);
|
|
1883
|
+
const body = (await json(res)) as {
|
|
1884
|
+
initialized: boolean;
|
|
1885
|
+
projectName: string | null;
|
|
1886
|
+
projectRoot: string;
|
|
1887
|
+
};
|
|
1888
|
+
expect(body.initialized).toBe(true);
|
|
1889
|
+
expect(body.projectName).toBe("test");
|
|
1890
|
+
expect(body.projectRoot).toBe(projectRoot);
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
it("returns initialized:false with null projectName when config.yaml is missing", async () => {
|
|
1894
|
+
await rm(join(legioDir, "config.yaml"), { force: true });
|
|
1895
|
+
const res = await dispatch("/api/setup/status");
|
|
1896
|
+
expect(res.status).toBe(200);
|
|
1897
|
+
const body = (await json(res)) as {
|
|
1898
|
+
initialized: boolean;
|
|
1899
|
+
projectName: string | null;
|
|
1900
|
+
projectRoot: string;
|
|
1901
|
+
};
|
|
1902
|
+
expect(body.initialized).toBe(false);
|
|
1903
|
+
expect(body.projectName).toBeNull();
|
|
1904
|
+
expect(body.projectRoot).toBe(projectRoot);
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
it("returns projectRoot in response", async () => {
|
|
1908
|
+
const res = await dispatch("/api/setup/status");
|
|
1909
|
+
const body = (await json(res)) as { projectRoot: string };
|
|
1910
|
+
expect(body.projectRoot).toBe(projectRoot);
|
|
1911
|
+
});
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
describe("POST /api/setup/init", () => {
|
|
1915
|
+
it("returns success:false with error string when not a git repo", async () => {
|
|
1916
|
+
// Default temp dir is not a git repo — legio init should fail
|
|
1917
|
+
const res = await dispatchPost("/api/setup/init", {});
|
|
1918
|
+
expect(res.status).toBe(200);
|
|
1919
|
+
const body = (await json(res)) as { success: boolean; error?: string };
|
|
1920
|
+
expect(body.success).toBe(false);
|
|
1921
|
+
expect(typeof body.error).toBe("string");
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it("accepts force:true and returns failure when not a git repo", async () => {
|
|
1925
|
+
const res = await dispatchPost("/api/setup/init", { force: true });
|
|
1926
|
+
expect(res.status).toBe(200);
|
|
1927
|
+
const body = (await json(res)) as { success: boolean; error?: string };
|
|
1928
|
+
expect(body.success).toBe(false);
|
|
1929
|
+
expect(typeof body.error).toBe("string");
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it("returns success:true with message when init succeeds in a git repo", async () => {
|
|
1933
|
+
// Create a fresh temp dir with a git repo but no .legio/ for a clean init
|
|
1934
|
+
const { execSync } = await import("node:child_process");
|
|
1935
|
+
const freshDir = join(tmpdir(), `routes-init-success-${Date.now()}`);
|
|
1936
|
+
await mkdir(freshDir, { recursive: true });
|
|
1937
|
+
try {
|
|
1938
|
+
execSync("git init", { cwd: freshDir, stdio: "pipe" });
|
|
1939
|
+
execSync("git config user.email test@test.com", { cwd: freshDir, stdio: "pipe" });
|
|
1940
|
+
execSync("git config user.name Test", { cwd: freshDir, stdio: "pipe" });
|
|
1941
|
+
const req = makePostRequest("/api/setup/init", {});
|
|
1942
|
+
const res = await handleApiRequest(req, join(freshDir, ".legio"), freshDir);
|
|
1943
|
+
expect(res.status).toBe(200);
|
|
1944
|
+
const body = (await json(res)) as {
|
|
1945
|
+
success: boolean;
|
|
1946
|
+
message?: string;
|
|
1947
|
+
error?: string;
|
|
1948
|
+
};
|
|
1949
|
+
if (body.success) {
|
|
1950
|
+
// legio init succeeded — verify the success response shape
|
|
1951
|
+
expect(body.message).toBe("Project initialized successfully");
|
|
1952
|
+
} else {
|
|
1953
|
+
// legio not on PATH or init failed for env reasons — still verify error shape
|
|
1954
|
+
expect(typeof body.error).toBe("string");
|
|
1955
|
+
}
|
|
1956
|
+
} finally {
|
|
1957
|
+
await rm(freshDir, { recursive: true, force: true });
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
it("returns 405 for GET requests on /api/setup/init", async () => {
|
|
1962
|
+
const res = await dispatch("/api/setup/init");
|
|
1963
|
+
expect([404, 405]).toContain(res.status);
|
|
1964
|
+
});
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
// ---------------------------------------------------------------------------
|
|
1968
|
+
// POST /api/coordinator/start
|
|
1969
|
+
// ---------------------------------------------------------------------------
|
|
1970
|
+
|
|
1971
|
+
describe("POST /api/coordinator/start", () => {
|
|
1972
|
+
it("returns well-formed response (success or graceful error from legio)", async () => {
|
|
1973
|
+
const res = await dispatchPost("/api/coordinator/start", {});
|
|
1974
|
+
// 200 if legio succeeded, 500 if legio not available or failed
|
|
1975
|
+
expect([200, 500]).toContain(res.status);
|
|
1976
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
1977
|
+
expect(body).toBeDefined();
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
it("passes --watchdog flag when body.watchdog is true", async () => {
|
|
1981
|
+
const res = await dispatchPost("/api/coordinator/start", { watchdog: true });
|
|
1982
|
+
expect([200, 500]).toContain(res.status);
|
|
1983
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
1984
|
+
expect(body).toBeDefined();
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
it("passes --monitor flag when body.monitor is true", async () => {
|
|
1988
|
+
const res = await dispatchPost("/api/coordinator/start", { monitor: true });
|
|
1989
|
+
expect([200, 500]).toContain(res.status);
|
|
1990
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
1991
|
+
expect(body).toBeDefined();
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
it("returns 400 for non-JSON body", async () => {
|
|
1995
|
+
const res = await handleApiRequest(
|
|
1996
|
+
new Request("http://localhost/api/coordinator/start", {
|
|
1997
|
+
method: "POST",
|
|
1998
|
+
body: "not json",
|
|
1999
|
+
headers: { "Content-Type": "text/plain" },
|
|
2000
|
+
}),
|
|
2001
|
+
legioDir,
|
|
2002
|
+
projectRoot,
|
|
2003
|
+
);
|
|
2004
|
+
expect(res.status).toBe(400);
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
it("calls wsManager.broadcastEvent with coordinator_start on success", async () => {
|
|
2008
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
2009
|
+
const mockWsManager = {
|
|
2010
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
2011
|
+
events.push(event);
|
|
2012
|
+
},
|
|
2013
|
+
};
|
|
2014
|
+
|
|
2015
|
+
const res = await handleApiRequest(
|
|
2016
|
+
makePostRequest("/api/coordinator/start", {}),
|
|
2017
|
+
legioDir,
|
|
2018
|
+
projectRoot,
|
|
2019
|
+
mockWsManager,
|
|
2020
|
+
);
|
|
2021
|
+
|
|
2022
|
+
if (res.status === 200) {
|
|
2023
|
+
// legio succeeded — broadcastEvent should have been called
|
|
2024
|
+
expect(events.length).toBe(1);
|
|
2025
|
+
expect(events[0]?.type).toBe("coordinator_start");
|
|
2026
|
+
}
|
|
2027
|
+
// If status is 500 (legio failed), no event expected
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
// ---------------------------------------------------------------------------
|
|
2032
|
+
// POST /api/coordinator/stop
|
|
2033
|
+
// ---------------------------------------------------------------------------
|
|
2034
|
+
|
|
2035
|
+
describe("POST /api/coordinator/stop", () => {
|
|
2036
|
+
it("returns well-formed response (success or graceful error from legio)", async () => {
|
|
2037
|
+
const res = await dispatchPost("/api/coordinator/stop", {});
|
|
2038
|
+
expect([200, 500]).toContain(res.status);
|
|
2039
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
2040
|
+
expect(body).toBeDefined();
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2044
|
+
const res = await handleApiRequest(
|
|
2045
|
+
new Request("http://localhost/api/coordinator/stop", {
|
|
2046
|
+
method: "POST",
|
|
2047
|
+
body: "not json",
|
|
2048
|
+
headers: { "Content-Type": "text/plain" },
|
|
2049
|
+
}),
|
|
2050
|
+
legioDir,
|
|
2051
|
+
projectRoot,
|
|
2052
|
+
);
|
|
2053
|
+
expect(res.status).toBe(400);
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
it("calls wsManager.broadcastEvent with coordinator_stop on success", async () => {
|
|
2057
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
2058
|
+
const mockWsManager = {
|
|
2059
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
2060
|
+
events.push(event);
|
|
2061
|
+
},
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
const res = await handleApiRequest(
|
|
2065
|
+
makePostRequest("/api/coordinator/stop", {}),
|
|
2066
|
+
legioDir,
|
|
2067
|
+
projectRoot,
|
|
2068
|
+
mockWsManager,
|
|
2069
|
+
);
|
|
2070
|
+
|
|
2071
|
+
if (res.status === 200) {
|
|
2072
|
+
expect(events.length).toBe(1);
|
|
2073
|
+
expect(events[0]?.type).toBe("coordinator_stop");
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
// ---------------------------------------------------------------------------
|
|
2079
|
+
// POST /api/merge
|
|
2080
|
+
// ---------------------------------------------------------------------------
|
|
2081
|
+
|
|
2082
|
+
describe("POST /api/merge", () => {
|
|
2083
|
+
it("returns 400 when neither branch nor all is provided", async () => {
|
|
2084
|
+
const res = await dispatchPost("/api/merge", {});
|
|
2085
|
+
expect(res.status).toBe(400);
|
|
2086
|
+
const body = (await json(res)) as { error: string };
|
|
2087
|
+
expect(body.error).toContain("branch");
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
it("returns well-formed response for branch merge (success or error)", async () => {
|
|
2091
|
+
const res = await dispatchPost("/api/merge", { branch: "legio/some-agent/task-1" });
|
|
2092
|
+
expect([200, 500]).toContain(res.status);
|
|
2093
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
2094
|
+
expect(body).toBeDefined();
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
it("returns well-formed response for --all merge (success or error)", async () => {
|
|
2098
|
+
const res = await dispatchPost("/api/merge", { all: true });
|
|
2099
|
+
expect([200, 500]).toContain(res.status);
|
|
2100
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
2101
|
+
expect(body).toBeDefined();
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
it("passes --into and --dry-run flags when provided", async () => {
|
|
2105
|
+
const res = await dispatchPost("/api/merge", {
|
|
2106
|
+
branch: "legio/some-agent/task-1",
|
|
2107
|
+
into: "main",
|
|
2108
|
+
dryRun: true,
|
|
2109
|
+
});
|
|
2110
|
+
expect([200, 500]).toContain(res.status);
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2114
|
+
const res = await handleApiRequest(
|
|
2115
|
+
new Request("http://localhost/api/merge", {
|
|
2116
|
+
method: "POST",
|
|
2117
|
+
body: "not json",
|
|
2118
|
+
headers: { "Content-Type": "text/plain" },
|
|
2119
|
+
}),
|
|
2120
|
+
legioDir,
|
|
2121
|
+
projectRoot,
|
|
2122
|
+
);
|
|
2123
|
+
expect(res.status).toBe(400);
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
it("calls wsManager.broadcastEvent with merge_complete on success", async () => {
|
|
2127
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
2128
|
+
const mockWsManager = {
|
|
2129
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
2130
|
+
events.push(event);
|
|
2131
|
+
},
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
const res = await handleApiRequest(
|
|
2135
|
+
makePostRequest("/api/merge", { branch: "legio/some-agent/task-1" }),
|
|
2136
|
+
legioDir,
|
|
2137
|
+
projectRoot,
|
|
2138
|
+
mockWsManager,
|
|
2139
|
+
);
|
|
2140
|
+
|
|
2141
|
+
if (res.status === 200) {
|
|
2142
|
+
expect(events.length).toBe(1);
|
|
2143
|
+
expect(events[0]?.type).toBe("merge_complete");
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
// ---------------------------------------------------------------------------
|
|
2149
|
+
// POST /api/nudge
|
|
2150
|
+
// ---------------------------------------------------------------------------
|
|
2151
|
+
|
|
2152
|
+
describe("POST /api/nudge", () => {
|
|
2153
|
+
it("returns 400 when agent is missing", async () => {
|
|
2154
|
+
const res = await dispatchPost("/api/nudge", { message: "hello" });
|
|
2155
|
+
expect(res.status).toBe(400);
|
|
2156
|
+
const body = (await json(res)) as { error: string };
|
|
2157
|
+
expect(body.error).toContain("agent");
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it("returns well-formed response (success or graceful error)", async () => {
|
|
2161
|
+
const res = await dispatchPost("/api/nudge", { agent: "my-builder" });
|
|
2162
|
+
expect([200, 500]).toContain(res.status);
|
|
2163
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
2164
|
+
expect(body).toBeDefined();
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
it("passes message as positional arg when provided", async () => {
|
|
2168
|
+
const res = await dispatchPost("/api/nudge", {
|
|
2169
|
+
agent: "my-builder",
|
|
2170
|
+
message: "Please check your mail",
|
|
2171
|
+
});
|
|
2172
|
+
expect([200, 500]).toContain(res.status);
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2176
|
+
const res = await handleApiRequest(
|
|
2177
|
+
new Request("http://localhost/api/nudge", {
|
|
2178
|
+
method: "POST",
|
|
2179
|
+
body: "not json",
|
|
2180
|
+
headers: { "Content-Type": "text/plain" },
|
|
2181
|
+
}),
|
|
2182
|
+
legioDir,
|
|
2183
|
+
projectRoot,
|
|
2184
|
+
);
|
|
2185
|
+
expect(res.status).toBe(400);
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
it("calls wsManager.broadcastEvent with agent_nudge on success", async () => {
|
|
2189
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
2190
|
+
const mockWsManager = {
|
|
2191
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
2192
|
+
events.push(event);
|
|
2193
|
+
},
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2196
|
+
const res = await handleApiRequest(
|
|
2197
|
+
makePostRequest("/api/nudge", { agent: "my-builder", message: "hey" }),
|
|
2198
|
+
legioDir,
|
|
2199
|
+
projectRoot,
|
|
2200
|
+
mockWsManager,
|
|
2201
|
+
);
|
|
2202
|
+
|
|
2203
|
+
if (res.status === 200) {
|
|
2204
|
+
expect(events.length).toBe(1);
|
|
2205
|
+
expect(events[0]?.type).toBe("agent_nudge");
|
|
2206
|
+
const data = events[0]?.data as { agent: string; message: string | null };
|
|
2207
|
+
expect(data.agent).toBe("my-builder");
|
|
2208
|
+
expect(data.message).toBe("hey");
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
// ---------------------------------------------------------------------------
|
|
2214
|
+
// Ideas routes
|
|
2215
|
+
// ---------------------------------------------------------------------------
|
|
2216
|
+
|
|
2217
|
+
interface Idea {
|
|
2218
|
+
id: string;
|
|
2219
|
+
title: string;
|
|
2220
|
+
body: string;
|
|
2221
|
+
status: "active" | "dispatched" | "backlog";
|
|
2222
|
+
createdAt: string;
|
|
2223
|
+
updatedAt: string;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function makeIdea(overrides?: Partial<Idea>): Idea {
|
|
2227
|
+
return {
|
|
2228
|
+
id: "idea-aaaabbbb",
|
|
2229
|
+
title: "My first idea",
|
|
2230
|
+
body: "Some details here",
|
|
2231
|
+
status: "active",
|
|
2232
|
+
createdAt: new Date().toISOString(),
|
|
2233
|
+
updatedAt: new Date().toISOString(),
|
|
2234
|
+
...overrides,
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
async function seedIdeasFile(dir: string, ideas: Idea[]): Promise<void> {
|
|
2239
|
+
const { join: pathJoin } = await import("node:path");
|
|
2240
|
+
await writeFile(pathJoin(dir, "ideas.json"), JSON.stringify({ ideas }, null, 2));
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function makePutRequest(path: string, body: unknown): Request {
|
|
2244
|
+
return new Request(`http://localhost${path}`, {
|
|
2245
|
+
method: "PUT",
|
|
2246
|
+
headers: { "Content-Type": "application/json" },
|
|
2247
|
+
body: JSON.stringify(body),
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function makeDeleteRequest(path: string): Request {
|
|
2252
|
+
return new Request(`http://localhost${path}`, { method: "DELETE" });
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
async function dispatchPut(path: string, body: unknown): Promise<Response> {
|
|
2256
|
+
return handleApiRequest(makePutRequest(path, body), legioDir, projectRoot);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
async function dispatchDelete(path: string): Promise<Response> {
|
|
2260
|
+
return handleApiRequest(makeDeleteRequest(path), legioDir, projectRoot);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
describe("GET /api/ideas", () => {
|
|
2264
|
+
it("returns empty array when ideas.json missing", async () => {
|
|
2265
|
+
const res = await dispatch("/api/ideas");
|
|
2266
|
+
expect(res.status).toBe(200);
|
|
2267
|
+
expect(await json(res)).toEqual([]);
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
it("returns ideas from ideas.json", async () => {
|
|
2271
|
+
await seedIdeasFile(legioDir, [makeIdea()]);
|
|
2272
|
+
const res = await dispatch("/api/ideas");
|
|
2273
|
+
expect(res.status).toBe(200);
|
|
2274
|
+
const body = (await json(res)) as Array<{ id: string }>;
|
|
2275
|
+
expect(body.length).toBe(1);
|
|
2276
|
+
expect(body[0]?.id).toBe("idea-aaaabbbb");
|
|
2277
|
+
});
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
describe("POST /api/ideas", () => {
|
|
2281
|
+
it("creates a new idea with title and body", async () => {
|
|
2282
|
+
const res = await dispatchPost("/api/ideas", { title: "Test idea", body: "Details" });
|
|
2283
|
+
expect(res.status).toBe(201);
|
|
2284
|
+
const body = (await json(res)) as Idea;
|
|
2285
|
+
expect(body.title).toBe("Test idea");
|
|
2286
|
+
expect(body.body).toBe("Details");
|
|
2287
|
+
expect(body.status).toBe("active");
|
|
2288
|
+
expect(body.id).toMatch(/^idea-/);
|
|
2289
|
+
expect(typeof body.createdAt).toBe("string");
|
|
2290
|
+
expect(typeof body.updatedAt).toBe("string");
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
it("creates idea with title only (body defaults to empty string)", async () => {
|
|
2294
|
+
const res = await dispatchPost("/api/ideas", { title: "Title only" });
|
|
2295
|
+
expect(res.status).toBe(201);
|
|
2296
|
+
const body = (await json(res)) as Idea;
|
|
2297
|
+
expect(body.title).toBe("Title only");
|
|
2298
|
+
expect(body.body).toBe("");
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
it("returns 400 if title is missing", async () => {
|
|
2302
|
+
const res = await dispatchPost("/api/ideas", { body: "No title" });
|
|
2303
|
+
expect(res.status).toBe(400);
|
|
2304
|
+
const body = (await json(res)) as { error: string };
|
|
2305
|
+
expect(body.error).toContain("title");
|
|
2306
|
+
});
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
describe("PUT /api/ideas/:id", () => {
|
|
2310
|
+
it("updates title and body", async () => {
|
|
2311
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2312
|
+
const res = await dispatchPut("/api/ideas/idea-aaaabbbb", {
|
|
2313
|
+
title: "Updated title",
|
|
2314
|
+
body: "Updated body",
|
|
2315
|
+
});
|
|
2316
|
+
expect(res.status).toBe(200);
|
|
2317
|
+
const body = (await json(res)) as Idea;
|
|
2318
|
+
expect(body.title).toBe("Updated title");
|
|
2319
|
+
expect(body.body).toBe("Updated body");
|
|
2320
|
+
// Verify on disk
|
|
2321
|
+
const updated = JSON.parse(readFileSync(join(legioDir, "ideas.json"), "utf-8")) as {
|
|
2322
|
+
ideas: Idea[];
|
|
2323
|
+
};
|
|
2324
|
+
expect(updated.ideas[0]?.title).toBe("Updated title");
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
it("returns 404 for unknown id", async () => {
|
|
2328
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2329
|
+
const res = await dispatchPut("/api/ideas/idea-unknown", { title: "x" });
|
|
2330
|
+
expect(res.status).toBe(404);
|
|
2331
|
+
const body = (await json(res)) as { error: string };
|
|
2332
|
+
expect(body.error).toContain("idea-unknown");
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
it("returns 404 when ideas.json missing", async () => {
|
|
2336
|
+
const res = await dispatchPut("/api/ideas/idea-aaaabbbb", { title: "x" });
|
|
2337
|
+
expect(res.status).toBe(404);
|
|
2338
|
+
});
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
describe("DELETE /api/ideas/:id", () => {
|
|
2342
|
+
it("deletes an idea", async () => {
|
|
2343
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2344
|
+
const res = await dispatchDelete("/api/ideas/idea-aaaabbbb");
|
|
2345
|
+
expect(res.status).toBe(200);
|
|
2346
|
+
const body = (await json(res)) as { success: boolean };
|
|
2347
|
+
expect(body.success).toBe(true);
|
|
2348
|
+
// Verify removed on disk
|
|
2349
|
+
const updated = JSON.parse(readFileSync(join(legioDir, "ideas.json"), "utf-8")) as {
|
|
2350
|
+
ideas: Idea[];
|
|
2351
|
+
};
|
|
2352
|
+
expect(updated.ideas.length).toBe(0);
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
it("returns 404 for unknown id", async () => {
|
|
2356
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2357
|
+
const res = await dispatchDelete("/api/ideas/idea-unknown");
|
|
2358
|
+
expect(res.status).toBe(404);
|
|
2359
|
+
const body = (await json(res)) as { error: string };
|
|
2360
|
+
expect(body.error).toContain("idea-unknown");
|
|
2361
|
+
});
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
describe("POST /api/ideas/:id/dispatch", () => {
|
|
2365
|
+
it("dispatches idea and returns messageId", async () => {
|
|
2366
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2367
|
+
const res = await dispatchPost("/api/ideas/idea-aaaabbbb/dispatch", {});
|
|
2368
|
+
expect(res.status).toBe(200);
|
|
2369
|
+
const body = (await json(res)) as { idea: Idea; messageId: string };
|
|
2370
|
+
expect(body.idea.status).toBe("dispatched");
|
|
2371
|
+
expect(typeof body.messageId).toBe("string");
|
|
2372
|
+
expect(body.messageId).toMatch(/^idea-dispatch-/);
|
|
2373
|
+
// Verify status on disk
|
|
2374
|
+
const updated = JSON.parse(readFileSync(join(legioDir, "ideas.json"), "utf-8")) as {
|
|
2375
|
+
ideas: Idea[];
|
|
2376
|
+
};
|
|
2377
|
+
expect(updated.ideas[0]?.status).toBe("dispatched");
|
|
2378
|
+
});
|
|
2379
|
+
|
|
2380
|
+
it("returns 404 for unknown id", async () => {
|
|
2381
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2382
|
+
const res = await dispatchPost("/api/ideas/idea-unknown/dispatch", {});
|
|
2383
|
+
expect(res.status).toBe(404);
|
|
2384
|
+
const body = (await json(res)) as { error: string };
|
|
2385
|
+
expect(body.error).toContain("idea-unknown");
|
|
2386
|
+
});
|
|
2387
|
+
});
|
|
2388
|
+
|
|
2389
|
+
describe("POST /api/ideas/:id/backlog", () => {
|
|
2390
|
+
it("adds idea to backlog and returns issueId", async () => {
|
|
2391
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2392
|
+
const res = await dispatchPost("/api/ideas/idea-aaaabbbb/backlog", {});
|
|
2393
|
+
expect(res.status).toBe(200);
|
|
2394
|
+
const body = (await json(res)) as { idea: Idea; issueId: string };
|
|
2395
|
+
expect(body.idea.status).toBe("backlog");
|
|
2396
|
+
expect(body.issueId).toBe("bead-test-001");
|
|
2397
|
+
// Verify status on disk
|
|
2398
|
+
const updated = JSON.parse(readFileSync(join(legioDir, "ideas.json"), "utf-8")) as {
|
|
2399
|
+
ideas: Idea[];
|
|
2400
|
+
};
|
|
2401
|
+
expect(updated.ideas[0]?.status).toBe("backlog");
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
it("returns 404 for unknown id", async () => {
|
|
2405
|
+
await seedIdeasFile(legioDir, [makeIdea({ id: "idea-aaaabbbb" })]);
|
|
2406
|
+
const res = await dispatchPost("/api/ideas/idea-unknown/backlog", {});
|
|
2407
|
+
expect(res.status).toBe(404);
|
|
2408
|
+
const body = (await json(res)) as { error: string };
|
|
2409
|
+
expect(body.error).toContain("idea-unknown");
|
|
2410
|
+
});
|
|
2411
|
+
});
|
|
2412
|
+
|
|
2413
|
+
// ---------------------------------------------------------------------------
|
|
2414
|
+
// GET /api/gateway/status
|
|
2415
|
+
// ---------------------------------------------------------------------------
|
|
2416
|
+
|
|
2417
|
+
describe("GET /api/gateway/status", () => {
|
|
2418
|
+
it("returns stopped when no sessions.db exists", async () => {
|
|
2419
|
+
const res = await dispatch("/api/gateway/status");
|
|
2420
|
+
expect(res.status).toBe(200);
|
|
2421
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
2422
|
+
expect(body.running).toBe(false);
|
|
2423
|
+
expect(body.tmuxSession).toBeUndefined();
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
it("returns running when gateway session exists with working state", async () => {
|
|
2427
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2428
|
+
const store = createSessionStore(dbPath);
|
|
2429
|
+
const now = new Date().toISOString();
|
|
2430
|
+
store.upsert({
|
|
2431
|
+
id: "sess-gw-001",
|
|
2432
|
+
agentName: "gateway",
|
|
2433
|
+
capability: "coordinator",
|
|
2434
|
+
worktreePath: "/tmp/wt/gateway",
|
|
2435
|
+
branchName: "main",
|
|
2436
|
+
beadId: "gateway-task",
|
|
2437
|
+
tmuxSession: "legio-test-gateway",
|
|
2438
|
+
state: "working",
|
|
2439
|
+
pid: 99999,
|
|
2440
|
+
parentAgent: null,
|
|
2441
|
+
depth: 0,
|
|
2442
|
+
runId: "run-001",
|
|
2443
|
+
startedAt: now,
|
|
2444
|
+
lastActivity: now,
|
|
2445
|
+
escalationLevel: 0,
|
|
2446
|
+
stalledSince: null,
|
|
2447
|
+
});
|
|
2448
|
+
store.close();
|
|
2449
|
+
|
|
2450
|
+
const res = await dispatch("/api/gateway/status");
|
|
2451
|
+
expect(res.status).toBe(200);
|
|
2452
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
2453
|
+
expect(body.running).toBe(true);
|
|
2454
|
+
expect(body.tmuxSession).toBe("legio-test-gateway");
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
it("returns stopped when gateway session is in zombie state", async () => {
|
|
2458
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2459
|
+
const store = createSessionStore(dbPath);
|
|
2460
|
+
const now = new Date().toISOString();
|
|
2461
|
+
store.upsert({
|
|
2462
|
+
id: "sess-gw-002",
|
|
2463
|
+
agentName: "gateway",
|
|
2464
|
+
capability: "coordinator",
|
|
2465
|
+
worktreePath: "/tmp/wt/gateway",
|
|
2466
|
+
branchName: "main",
|
|
2467
|
+
beadId: "gateway-task",
|
|
2468
|
+
tmuxSession: "legio-test-gateway",
|
|
2469
|
+
state: "zombie",
|
|
2470
|
+
pid: null,
|
|
2471
|
+
parentAgent: null,
|
|
2472
|
+
depth: 0,
|
|
2473
|
+
runId: "run-001",
|
|
2474
|
+
startedAt: now,
|
|
2475
|
+
lastActivity: now,
|
|
2476
|
+
escalationLevel: 0,
|
|
2477
|
+
stalledSince: null,
|
|
2478
|
+
});
|
|
2479
|
+
store.close();
|
|
2480
|
+
|
|
2481
|
+
const res = await dispatch("/api/gateway/status");
|
|
2482
|
+
expect(res.status).toBe(200);
|
|
2483
|
+
const body = (await json(res)) as { running: boolean; tmuxSession?: string };
|
|
2484
|
+
expect(body.running).toBe(false);
|
|
2485
|
+
});
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
// ---------------------------------------------------------------------------
|
|
2489
|
+
// POST /api/gateway/start
|
|
2490
|
+
// ---------------------------------------------------------------------------
|
|
2491
|
+
|
|
2492
|
+
describe("POST /api/gateway/start", () => {
|
|
2493
|
+
it("returns well-formed response (success or graceful error from legio)", async () => {
|
|
2494
|
+
const res = await dispatchPost("/api/gateway/start", {});
|
|
2495
|
+
expect([200, 500]).toContain(res.status);
|
|
2496
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
2497
|
+
expect(body).toBeDefined();
|
|
2498
|
+
});
|
|
2499
|
+
|
|
2500
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2501
|
+
const res = await handleApiRequest(
|
|
2502
|
+
new Request("http://localhost/api/gateway/start", {
|
|
2503
|
+
method: "POST",
|
|
2504
|
+
body: "not json",
|
|
2505
|
+
headers: { "Content-Type": "text/plain" },
|
|
2506
|
+
}),
|
|
2507
|
+
legioDir,
|
|
2508
|
+
projectRoot,
|
|
2509
|
+
);
|
|
2510
|
+
expect(res.status).toBe(400);
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
it("calls wsManager.broadcastEvent with gateway_start on success", async () => {
|
|
2514
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
2515
|
+
const mockWsManager = {
|
|
2516
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
2517
|
+
events.push(event);
|
|
2518
|
+
},
|
|
2519
|
+
};
|
|
2520
|
+
|
|
2521
|
+
const res = await handleApiRequest(
|
|
2522
|
+
makePostRequest("/api/gateway/start", {}),
|
|
2523
|
+
legioDir,
|
|
2524
|
+
projectRoot,
|
|
2525
|
+
mockWsManager,
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
if (res.status === 200) {
|
|
2529
|
+
expect(events.length).toBe(1);
|
|
2530
|
+
expect(events[0]?.type).toBe("gateway_start");
|
|
2531
|
+
}
|
|
2532
|
+
});
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
// ---------------------------------------------------------------------------
|
|
2536
|
+
// POST /api/gateway/stop
|
|
2537
|
+
// ---------------------------------------------------------------------------
|
|
2538
|
+
|
|
2539
|
+
describe("POST /api/gateway/stop", () => {
|
|
2540
|
+
it("returns well-formed response (success or graceful error from legio)", async () => {
|
|
2541
|
+
const res = await dispatchPost("/api/gateway/stop", {});
|
|
2542
|
+
expect([200, 500]).toContain(res.status);
|
|
2543
|
+
const body = (await json(res)) as Record<string, unknown>;
|
|
2544
|
+
expect(body).toBeDefined();
|
|
2545
|
+
});
|
|
2546
|
+
|
|
2547
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2548
|
+
const res = await handleApiRequest(
|
|
2549
|
+
new Request("http://localhost/api/gateway/stop", {
|
|
2550
|
+
method: "POST",
|
|
2551
|
+
body: "not json",
|
|
2552
|
+
headers: { "Content-Type": "text/plain" },
|
|
2553
|
+
}),
|
|
2554
|
+
legioDir,
|
|
2555
|
+
projectRoot,
|
|
2556
|
+
);
|
|
2557
|
+
expect(res.status).toBe(400);
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
it("calls wsManager.broadcastEvent with gateway_stop on success", async () => {
|
|
2561
|
+
const events: Array<{ type: string; data?: unknown }> = [];
|
|
2562
|
+
const mockWsManager = {
|
|
2563
|
+
broadcastEvent(event: { type: string; data?: unknown }) {
|
|
2564
|
+
events.push(event);
|
|
2565
|
+
},
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
const res = await handleApiRequest(
|
|
2569
|
+
makePostRequest("/api/gateway/stop", {}),
|
|
2570
|
+
legioDir,
|
|
2571
|
+
projectRoot,
|
|
2572
|
+
mockWsManager,
|
|
2573
|
+
);
|
|
2574
|
+
|
|
2575
|
+
if (res.status === 200) {
|
|
2576
|
+
expect(events.length).toBe(1);
|
|
2577
|
+
expect(events[0]?.type).toBe("gateway_stop");
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
// ---------------------------------------------------------------------------
|
|
2583
|
+
// POST /api/gateway/chat
|
|
2584
|
+
// ---------------------------------------------------------------------------
|
|
2585
|
+
|
|
2586
|
+
describe("POST /api/gateway/chat", () => {
|
|
2587
|
+
it("returns 400 when text field is missing", async () => {
|
|
2588
|
+
const res = await dispatchPost("/api/gateway/chat", {});
|
|
2589
|
+
expect(res.status).toBe(400);
|
|
2590
|
+
const body = (await json(res)) as { error: string };
|
|
2591
|
+
expect(body.error).toContain("text");
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
it("returns 400 when text field is empty string", async () => {
|
|
2595
|
+
const res = await dispatchPost("/api/gateway/chat", { text: " " });
|
|
2596
|
+
expect(res.status).toBe(400);
|
|
2597
|
+
const body = (await json(res)) as { error: string };
|
|
2598
|
+
expect(body.error).toContain("text");
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2602
|
+
const res = await handleApiRequest(
|
|
2603
|
+
new Request("http://localhost/api/gateway/chat", {
|
|
2604
|
+
method: "POST",
|
|
2605
|
+
body: "not json",
|
|
2606
|
+
headers: { "Content-Type": "text/plain" },
|
|
2607
|
+
}),
|
|
2608
|
+
legioDir,
|
|
2609
|
+
projectRoot,
|
|
2610
|
+
);
|
|
2611
|
+
expect(res.status).toBe(400);
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
it("returns 404 when gateway is not running (no sessions.db)", async () => {
|
|
2615
|
+
const res = await dispatchPost("/api/gateway/chat", { text: "hello" });
|
|
2616
|
+
expect(res.status).toBe(404);
|
|
2617
|
+
const body = (await json(res)) as { error: string };
|
|
2618
|
+
expect(body.error).toContain("not running");
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
it("returns 404 when gateway session is in zombie state", async () => {
|
|
2622
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2623
|
+
const store = createSessionStore(dbPath);
|
|
2624
|
+
const now = new Date().toISOString();
|
|
2625
|
+
store.upsert({
|
|
2626
|
+
id: "sess-gw-chat-001",
|
|
2627
|
+
agentName: "gateway",
|
|
2628
|
+
capability: "coordinator",
|
|
2629
|
+
worktreePath: "/tmp/wt/gateway",
|
|
2630
|
+
branchName: "main",
|
|
2631
|
+
beadId: "gateway-task",
|
|
2632
|
+
tmuxSession: "legio-test-gateway",
|
|
2633
|
+
state: "zombie",
|
|
2634
|
+
pid: null,
|
|
2635
|
+
parentAgent: null,
|
|
2636
|
+
depth: 0,
|
|
2637
|
+
runId: "run-001",
|
|
2638
|
+
startedAt: now,
|
|
2639
|
+
lastActivity: now,
|
|
2640
|
+
escalationLevel: 0,
|
|
2641
|
+
stalledSince: null,
|
|
2642
|
+
});
|
|
2643
|
+
store.close();
|
|
2644
|
+
|
|
2645
|
+
const res = await dispatchPost("/api/gateway/chat", { text: "hello" });
|
|
2646
|
+
expect(res.status).toBe(404);
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
it("returns 404 when gateway session is in DB but tmux session is not alive", async () => {
|
|
2650
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2651
|
+
const store = createSessionStore(dbPath);
|
|
2652
|
+
const now = new Date().toISOString();
|
|
2653
|
+
store.upsert({
|
|
2654
|
+
id: "sess-gw-stale-001",
|
|
2655
|
+
agentName: "gateway",
|
|
2656
|
+
capability: "coordinator",
|
|
2657
|
+
worktreePath: "/tmp/wt/gateway",
|
|
2658
|
+
branchName: "main",
|
|
2659
|
+
beadId: "gateway-task",
|
|
2660
|
+
tmuxSession: "legio-test-gateway-stale",
|
|
2661
|
+
state: "working",
|
|
2662
|
+
pid: 99999,
|
|
2663
|
+
parentAgent: null,
|
|
2664
|
+
depth: 0,
|
|
2665
|
+
runId: "run-001",
|
|
2666
|
+
startedAt: now,
|
|
2667
|
+
lastActivity: now,
|
|
2668
|
+
escalationLevel: 0,
|
|
2669
|
+
stalledSince: null,
|
|
2670
|
+
});
|
|
2671
|
+
store.close();
|
|
2672
|
+
|
|
2673
|
+
mockIsSessionAlive.mockResolvedValueOnce(false);
|
|
2674
|
+
const res = await dispatchPost("/api/gateway/chat", { text: "hello" });
|
|
2675
|
+
expect(res.status).toBe(404);
|
|
2676
|
+
const body = (await json(res)) as { error: string };
|
|
2677
|
+
expect(body.error).toContain("not alive");
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
it("persists message to mail.db and returns it with 201", async () => {
|
|
2681
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2682
|
+
const store = createSessionStore(dbPath);
|
|
2683
|
+
const now = new Date().toISOString();
|
|
2684
|
+
store.upsert({
|
|
2685
|
+
id: "sess-gw-persist-001",
|
|
2686
|
+
agentName: "gateway",
|
|
2687
|
+
capability: "coordinator",
|
|
2688
|
+
worktreePath: "/tmp/wt/gateway",
|
|
2689
|
+
branchName: "main",
|
|
2690
|
+
beadId: "gateway-task",
|
|
2691
|
+
tmuxSession: "legio-test-gateway",
|
|
2692
|
+
state: "working",
|
|
2693
|
+
pid: 12345,
|
|
2694
|
+
parentAgent: null,
|
|
2695
|
+
depth: 0,
|
|
2696
|
+
runId: "run-001",
|
|
2697
|
+
startedAt: now,
|
|
2698
|
+
lastActivity: now,
|
|
2699
|
+
escalationLevel: 0,
|
|
2700
|
+
stalledSince: null,
|
|
2701
|
+
});
|
|
2702
|
+
store.close();
|
|
2703
|
+
|
|
2704
|
+
const res = await dispatchPost("/api/gateway/chat", { text: "Hello gateway" });
|
|
2705
|
+
expect(res.status).toBe(201);
|
|
2706
|
+
const body = (await json(res)) as {
|
|
2707
|
+
id: string;
|
|
2708
|
+
from: string;
|
|
2709
|
+
to: string;
|
|
2710
|
+
subject: string;
|
|
2711
|
+
body: string;
|
|
2712
|
+
type: string;
|
|
2713
|
+
audience: string;
|
|
2714
|
+
priority: string;
|
|
2715
|
+
};
|
|
2716
|
+
expect(body.from).toBe("human");
|
|
2717
|
+
expect(body.to).toBe("gateway");
|
|
2718
|
+
expect(body.subject).toBe("chat");
|
|
2719
|
+
expect(body.body).toBe("Hello gateway");
|
|
2720
|
+
expect(body.type).toBe("status");
|
|
2721
|
+
expect(body.audience).toBe("human");
|
|
2722
|
+
expect(body.priority).toBe("normal");
|
|
2723
|
+
expect(typeof body.id).toBe("string");
|
|
2724
|
+
expect(body.id.length).toBeGreaterThan(0);
|
|
2725
|
+
|
|
2726
|
+
// Verify message persisted in mail.db
|
|
2727
|
+
const mailStore = createMailStore(join(legioDir, "mail.db"));
|
|
2728
|
+
const messages = mailStore.getAll({ from: "human", to: "gateway", audience: "human" });
|
|
2729
|
+
mailStore.close();
|
|
2730
|
+
expect(messages.length).toBe(1);
|
|
2731
|
+
expect(messages[0]?.body).toBe("Hello gateway");
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
it("still sends keys to tmux after persisting", async () => {
|
|
2735
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2736
|
+
const store = createSessionStore(dbPath);
|
|
2737
|
+
const now = new Date().toISOString();
|
|
2738
|
+
store.upsert({
|
|
2739
|
+
id: "sess-gw-keys-001",
|
|
2740
|
+
agentName: "gateway",
|
|
2741
|
+
capability: "coordinator",
|
|
2742
|
+
worktreePath: "/tmp/wt/gateway",
|
|
2743
|
+
branchName: "main",
|
|
2744
|
+
beadId: "gateway-task",
|
|
2745
|
+
tmuxSession: "legio-test-gateway",
|
|
2746
|
+
state: "working",
|
|
2747
|
+
pid: 12345,
|
|
2748
|
+
parentAgent: null,
|
|
2749
|
+
depth: 0,
|
|
2750
|
+
runId: "run-001",
|
|
2751
|
+
startedAt: now,
|
|
2752
|
+
lastActivity: now,
|
|
2753
|
+
escalationLevel: 0,
|
|
2754
|
+
stalledSince: null,
|
|
2755
|
+
});
|
|
2756
|
+
store.close();
|
|
2757
|
+
|
|
2758
|
+
mockSendKeys.mockClear();
|
|
2759
|
+
const res = await dispatchPost("/api/gateway/chat", { text: "send this" });
|
|
2760
|
+
expect(res.status).toBe(201);
|
|
2761
|
+
expect(mockSendKeys).toHaveBeenCalledWith("legio-test-gateway", "send this");
|
|
2762
|
+
});
|
|
2763
|
+
});
|
|
2764
|
+
|
|
2765
|
+
// ---------------------------------------------------------------------------
|
|
2766
|
+
// GET /api/gateway/chat/history
|
|
2767
|
+
// ---------------------------------------------------------------------------
|
|
2768
|
+
|
|
2769
|
+
describe("GET /api/gateway/chat/history", () => {
|
|
2770
|
+
it("returns empty array when mail.db does not exist", async () => {
|
|
2771
|
+
const res = await dispatch("/api/gateway/chat/history");
|
|
2772
|
+
expect(res.status).toBe(200);
|
|
2773
|
+
expect(await json(res)).toEqual([]);
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
it("returns bidirectional messages in chronological order", async () => {
|
|
2777
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
2778
|
+
const store = createMailStore(mailDbPath);
|
|
2779
|
+
store.insert({
|
|
2780
|
+
id: "gw-hist-001",
|
|
2781
|
+
from: "human",
|
|
2782
|
+
to: "gateway",
|
|
2783
|
+
subject: "chat",
|
|
2784
|
+
body: "Human question",
|
|
2785
|
+
type: "status",
|
|
2786
|
+
priority: "normal",
|
|
2787
|
+
threadId: null,
|
|
2788
|
+
audience: "human",
|
|
2789
|
+
});
|
|
2790
|
+
store.insert({
|
|
2791
|
+
id: "gw-hist-002",
|
|
2792
|
+
from: "gateway",
|
|
2793
|
+
to: "human",
|
|
2794
|
+
subject: "chat",
|
|
2795
|
+
body: "Gateway answer",
|
|
2796
|
+
type: "result",
|
|
2797
|
+
priority: "normal",
|
|
2798
|
+
threadId: null,
|
|
2799
|
+
audience: "human",
|
|
2800
|
+
});
|
|
2801
|
+
store.close();
|
|
2802
|
+
|
|
2803
|
+
const res = await dispatch("/api/gateway/chat/history");
|
|
2804
|
+
expect(res.status).toBe(200);
|
|
2805
|
+
const body = (await json(res)) as Array<{ body: string; from: string }>;
|
|
2806
|
+
expect(body.length).toBe(2);
|
|
2807
|
+
const bodies = body.map((m) => m.body);
|
|
2808
|
+
expect(bodies).toContain("Human question");
|
|
2809
|
+
expect(bodies).toContain("Gateway answer");
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
it("filters out non-human-audience messages", async () => {
|
|
2813
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
2814
|
+
const store = createMailStore(mailDbPath);
|
|
2815
|
+
store.insert({
|
|
2816
|
+
id: "gw-agent-msg-001",
|
|
2817
|
+
from: "worker",
|
|
2818
|
+
to: "gateway",
|
|
2819
|
+
subject: "status",
|
|
2820
|
+
body: "Agent message",
|
|
2821
|
+
type: "status",
|
|
2822
|
+
priority: "normal",
|
|
2823
|
+
threadId: null,
|
|
2824
|
+
audience: "agent",
|
|
2825
|
+
});
|
|
2826
|
+
store.insert({
|
|
2827
|
+
id: "gw-human-msg-001",
|
|
2828
|
+
from: "human",
|
|
2829
|
+
to: "gateway",
|
|
2830
|
+
subject: "chat",
|
|
2831
|
+
body: "Human message",
|
|
2832
|
+
type: "status",
|
|
2833
|
+
priority: "normal",
|
|
2834
|
+
threadId: null,
|
|
2835
|
+
audience: "human",
|
|
2836
|
+
});
|
|
2837
|
+
store.insert({
|
|
2838
|
+
id: "gw-agent-resp-001",
|
|
2839
|
+
from: "gateway",
|
|
2840
|
+
to: "human",
|
|
2841
|
+
subject: "response",
|
|
2842
|
+
body: "Agent-only gateway response",
|
|
2843
|
+
type: "status",
|
|
2844
|
+
priority: "normal",
|
|
2845
|
+
threadId: null,
|
|
2846
|
+
audience: "agent",
|
|
2847
|
+
});
|
|
2848
|
+
store.close();
|
|
2849
|
+
|
|
2850
|
+
const res = await dispatch("/api/gateway/chat/history");
|
|
2851
|
+
expect(res.status).toBe(200);
|
|
2852
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
2853
|
+
expect(body.length).toBe(1);
|
|
2854
|
+
expect(body[0]?.body).toBe("Human message");
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
it("respects ?limit= query param", async () => {
|
|
2858
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
2859
|
+
const store = createMailStore(mailDbPath);
|
|
2860
|
+
for (let i = 0; i < 5; i++) {
|
|
2861
|
+
store.insert({
|
|
2862
|
+
id: `gw-limit-${String(i).padStart(3, "0")}`,
|
|
2863
|
+
from: "human",
|
|
2864
|
+
to: "gateway",
|
|
2865
|
+
subject: "chat",
|
|
2866
|
+
body: `Message ${i}`,
|
|
2867
|
+
type: "status",
|
|
2868
|
+
priority: "normal",
|
|
2869
|
+
threadId: null,
|
|
2870
|
+
audience: "human",
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
store.close();
|
|
2874
|
+
|
|
2875
|
+
const res = await dispatch("/api/gateway/chat/history", { limit: "3" });
|
|
2876
|
+
expect(res.status).toBe(200);
|
|
2877
|
+
const body = (await json(res)) as unknown[];
|
|
2878
|
+
expect(body.length).toBe(3);
|
|
2879
|
+
});
|
|
2880
|
+
|
|
2881
|
+
it("excludes messages between unrelated agent pairs", async () => {
|
|
2882
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
2883
|
+
const store = createMailStore(mailDbPath);
|
|
2884
|
+
// Human to gateway - should appear
|
|
2885
|
+
store.insert({
|
|
2886
|
+
id: "gw-related-001",
|
|
2887
|
+
from: "human",
|
|
2888
|
+
to: "gateway",
|
|
2889
|
+
subject: "chat",
|
|
2890
|
+
body: "To gateway",
|
|
2891
|
+
type: "status",
|
|
2892
|
+
priority: "normal",
|
|
2893
|
+
threadId: null,
|
|
2894
|
+
audience: "human",
|
|
2895
|
+
});
|
|
2896
|
+
// Worker to builder - unrelated pair, should not appear
|
|
2897
|
+
store.insert({
|
|
2898
|
+
id: "gw-unrelated-001",
|
|
2899
|
+
from: "worker",
|
|
2900
|
+
to: "builder",
|
|
2901
|
+
subject: "status",
|
|
2902
|
+
body: "Worker to builder",
|
|
2903
|
+
type: "status",
|
|
2904
|
+
priority: "normal",
|
|
2905
|
+
threadId: null,
|
|
2906
|
+
audience: "human",
|
|
2907
|
+
});
|
|
2908
|
+
store.close();
|
|
2909
|
+
|
|
2910
|
+
const res = await dispatch("/api/gateway/chat/history");
|
|
2911
|
+
expect(res.status).toBe(200);
|
|
2912
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
2913
|
+
expect(body.length).toBe(1);
|
|
2914
|
+
expect(body[0]?.body).toBe("To gateway");
|
|
2915
|
+
});
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
// ---------------------------------------------------------------------------
|
|
2919
|
+
// POST /api/coordinator/chat
|
|
2920
|
+
// ---------------------------------------------------------------------------
|
|
2921
|
+
|
|
2922
|
+
describe("POST /api/coordinator/chat", () => {
|
|
2923
|
+
it("returns 400 when text field is missing", async () => {
|
|
2924
|
+
const res = await dispatchPost("/api/coordinator/chat", {});
|
|
2925
|
+
expect(res.status).toBe(400);
|
|
2926
|
+
const body = (await json(res)) as { error: string };
|
|
2927
|
+
expect(body.error).toContain("text");
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
it("returns 400 when text field is empty string", async () => {
|
|
2931
|
+
const res = await dispatchPost("/api/coordinator/chat", { text: " " });
|
|
2932
|
+
expect(res.status).toBe(400);
|
|
2933
|
+
const body = (await json(res)) as { error: string };
|
|
2934
|
+
expect(body.error).toContain("text");
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
it("returns 400 for non-JSON body", async () => {
|
|
2938
|
+
const res = await handleApiRequest(
|
|
2939
|
+
new Request("http://localhost/api/coordinator/chat", {
|
|
2940
|
+
method: "POST",
|
|
2941
|
+
body: "not json",
|
|
2942
|
+
headers: { "Content-Type": "text/plain" },
|
|
2943
|
+
}),
|
|
2944
|
+
legioDir,
|
|
2945
|
+
projectRoot,
|
|
2946
|
+
);
|
|
2947
|
+
expect(res.status).toBe(400);
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
it("returns 404 when coordinator is not running (no sessions.db)", async () => {
|
|
2951
|
+
const res = await dispatchPost("/api/coordinator/chat", { text: "hello" });
|
|
2952
|
+
expect(res.status).toBe(404);
|
|
2953
|
+
const body = (await json(res)) as { error: string };
|
|
2954
|
+
expect(body.error).toContain("coordinator");
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
it("persists message to mail.db and returns it with correct fields", async () => {
|
|
2958
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
2959
|
+
const store = createSessionStore(dbPath);
|
|
2960
|
+
const now = new Date().toISOString();
|
|
2961
|
+
store.upsert({
|
|
2962
|
+
id: "sess-coord-chat-001",
|
|
2963
|
+
agentName: "coordinator",
|
|
2964
|
+
capability: "coordinator",
|
|
2965
|
+
worktreePath: "/tmp/wt/coordinator",
|
|
2966
|
+
branchName: "main",
|
|
2967
|
+
beadId: "coord-task",
|
|
2968
|
+
tmuxSession: "legio-test-coordinator",
|
|
2969
|
+
state: "working",
|
|
2970
|
+
pid: 12345,
|
|
2971
|
+
parentAgent: null,
|
|
2972
|
+
depth: 0,
|
|
2973
|
+
runId: "run-001",
|
|
2974
|
+
startedAt: now,
|
|
2975
|
+
lastActivity: now,
|
|
2976
|
+
escalationLevel: 0,
|
|
2977
|
+
stalledSince: null,
|
|
2978
|
+
});
|
|
2979
|
+
store.close();
|
|
2980
|
+
|
|
2981
|
+
const res = await dispatchPost("/api/coordinator/chat", { text: "Hello coordinator" });
|
|
2982
|
+
expect(res.status).toBe(201);
|
|
2983
|
+
const body = (await json(res)) as {
|
|
2984
|
+
id: string;
|
|
2985
|
+
from: string;
|
|
2986
|
+
to: string;
|
|
2987
|
+
subject: string;
|
|
2988
|
+
body: string;
|
|
2989
|
+
type: string;
|
|
2990
|
+
audience: string;
|
|
2991
|
+
priority: string;
|
|
2992
|
+
};
|
|
2993
|
+
expect(body.from).toBe("human");
|
|
2994
|
+
expect(body.to).toBe("coordinator");
|
|
2995
|
+
expect(body.subject).toBe("chat");
|
|
2996
|
+
expect(body.body).toBe("Hello coordinator");
|
|
2997
|
+
expect(body.type).toBe("status");
|
|
2998
|
+
expect(body.audience).toBe("human");
|
|
2999
|
+
expect(body.priority).toBe("normal");
|
|
3000
|
+
expect(typeof body.id).toBe("string");
|
|
3001
|
+
expect(body.id.length).toBeGreaterThan(0);
|
|
3002
|
+
|
|
3003
|
+
// Verify message persisted in mail.db
|
|
3004
|
+
const mailStore = createMailStore(join(legioDir, "mail.db"));
|
|
3005
|
+
const messages = mailStore.getAll({ from: "human", to: "coordinator", audience: "human" });
|
|
3006
|
+
mailStore.close();
|
|
3007
|
+
expect(messages.length).toBe(1);
|
|
3008
|
+
expect(messages[0]?.body).toBe("Hello coordinator");
|
|
3009
|
+
});
|
|
3010
|
+
});
|
|
3011
|
+
|
|
3012
|
+
// ---------------------------------------------------------------------------
|
|
3013
|
+
// GET /api/coordinator/chat/history
|
|
3014
|
+
// ---------------------------------------------------------------------------
|
|
3015
|
+
|
|
3016
|
+
describe("GET /api/coordinator/chat/history", () => {
|
|
3017
|
+
it("returns empty array when mail.db does not exist", async () => {
|
|
3018
|
+
const res = await dispatch("/api/coordinator/chat/history");
|
|
3019
|
+
expect(res.status).toBe(200);
|
|
3020
|
+
expect(await json(res)).toEqual([]);
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
it("returns messages sent to coordinator in chronological order", async () => {
|
|
3024
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3025
|
+
const store = createMailStore(mailDbPath);
|
|
3026
|
+
store.insert({
|
|
3027
|
+
id: "chat-msg-001",
|
|
3028
|
+
from: "human",
|
|
3029
|
+
to: "coordinator",
|
|
3030
|
+
subject: "chat",
|
|
3031
|
+
body: "First message",
|
|
3032
|
+
type: "status",
|
|
3033
|
+
priority: "normal",
|
|
3034
|
+
threadId: null,
|
|
3035
|
+
audience: "human",
|
|
3036
|
+
});
|
|
3037
|
+
store.insert({
|
|
3038
|
+
id: "chat-msg-002",
|
|
3039
|
+
from: "human",
|
|
3040
|
+
to: "coordinator",
|
|
3041
|
+
subject: "chat",
|
|
3042
|
+
body: "Second message",
|
|
3043
|
+
type: "status",
|
|
3044
|
+
priority: "normal",
|
|
3045
|
+
threadId: null,
|
|
3046
|
+
audience: "human",
|
|
3047
|
+
});
|
|
3048
|
+
store.insert({
|
|
3049
|
+
id: "chat-msg-003",
|
|
3050
|
+
from: "coordinator",
|
|
3051
|
+
to: "human",
|
|
3052
|
+
subject: "chat",
|
|
3053
|
+
body: "Coordinator response",
|
|
3054
|
+
type: "status",
|
|
3055
|
+
priority: "normal",
|
|
3056
|
+
threadId: null,
|
|
3057
|
+
audience: "human",
|
|
3058
|
+
});
|
|
3059
|
+
store.close();
|
|
3060
|
+
|
|
3061
|
+
const res = await dispatch("/api/coordinator/chat/history");
|
|
3062
|
+
expect(res.status).toBe(200);
|
|
3063
|
+
const body = (await json(res)) as Array<{ body: string; from: string; to: string }>;
|
|
3064
|
+
expect(body.length).toBe(3);
|
|
3065
|
+
// All three messages present (human->coordinator and coordinator->human)
|
|
3066
|
+
const bodies = body.map((m) => m.body);
|
|
3067
|
+
expect(bodies).toContain("First message");
|
|
3068
|
+
expect(bodies).toContain("Second message");
|
|
3069
|
+
expect(bodies).toContain("Coordinator response");
|
|
3070
|
+
});
|
|
3071
|
+
|
|
3072
|
+
it("does not include non-human-audience messages", async () => {
|
|
3073
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3074
|
+
const store = createMailStore(mailDbPath);
|
|
3075
|
+
store.insert({
|
|
3076
|
+
id: "agent-msg-001",
|
|
3077
|
+
from: "worker",
|
|
3078
|
+
to: "coordinator",
|
|
3079
|
+
subject: "status",
|
|
3080
|
+
body: "Agent message",
|
|
3081
|
+
type: "status",
|
|
3082
|
+
priority: "normal",
|
|
3083
|
+
threadId: null,
|
|
3084
|
+
audience: "agent",
|
|
3085
|
+
});
|
|
3086
|
+
store.insert({
|
|
3087
|
+
id: "human-msg-001",
|
|
3088
|
+
from: "human",
|
|
3089
|
+
to: "coordinator",
|
|
3090
|
+
subject: "chat",
|
|
3091
|
+
body: "Human message",
|
|
3092
|
+
type: "status",
|
|
3093
|
+
priority: "normal",
|
|
3094
|
+
threadId: null,
|
|
3095
|
+
audience: "human",
|
|
3096
|
+
});
|
|
3097
|
+
store.insert({
|
|
3098
|
+
id: "coord-agent-msg-001",
|
|
3099
|
+
from: "coordinator",
|
|
3100
|
+
to: "human",
|
|
3101
|
+
subject: "response",
|
|
3102
|
+
body: "Agent-only coordinator response",
|
|
3103
|
+
type: "status",
|
|
3104
|
+
priority: "normal",
|
|
3105
|
+
threadId: null,
|
|
3106
|
+
audience: "agent",
|
|
3107
|
+
});
|
|
3108
|
+
store.close();
|
|
3109
|
+
|
|
3110
|
+
const res = await dispatch("/api/coordinator/chat/history");
|
|
3111
|
+
expect(res.status).toBe(200);
|
|
3112
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3113
|
+
expect(body.length).toBe(1);
|
|
3114
|
+
expect(body[0]?.body).toBe("Human message");
|
|
3115
|
+
});
|
|
3116
|
+
|
|
3117
|
+
it("respects ?limit= query param", async () => {
|
|
3118
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3119
|
+
const store = createMailStore(mailDbPath);
|
|
3120
|
+
for (let i = 0; i < 5; i++) {
|
|
3121
|
+
store.insert({
|
|
3122
|
+
id: `limit-msg-${String(i).padStart(3, "0")}`,
|
|
3123
|
+
from: "human",
|
|
3124
|
+
to: "coordinator",
|
|
3125
|
+
subject: "chat",
|
|
3126
|
+
body: `Message ${i}`,
|
|
3127
|
+
type: "status",
|
|
3128
|
+
priority: "normal",
|
|
3129
|
+
threadId: null,
|
|
3130
|
+
audience: "human",
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
store.close();
|
|
3134
|
+
|
|
3135
|
+
const res = await dispatch("/api/coordinator/chat/history", { limit: "3" });
|
|
3136
|
+
expect(res.status).toBe(200);
|
|
3137
|
+
const body = (await json(res)) as unknown[];
|
|
3138
|
+
expect(body.length).toBe(3);
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
it("returns bidirectional messages (human-to-coordinator and coordinator-to-human)", async () => {
|
|
3142
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3143
|
+
const store = createMailStore(mailDbPath);
|
|
3144
|
+
store.insert({
|
|
3145
|
+
id: "bidir-coord-001",
|
|
3146
|
+
from: "human",
|
|
3147
|
+
to: "coordinator",
|
|
3148
|
+
subject: "chat",
|
|
3149
|
+
body: "Human question",
|
|
3150
|
+
type: "status",
|
|
3151
|
+
priority: "normal",
|
|
3152
|
+
threadId: null,
|
|
3153
|
+
audience: "human",
|
|
3154
|
+
});
|
|
3155
|
+
store.insert({
|
|
3156
|
+
id: "bidir-coord-002",
|
|
3157
|
+
from: "coordinator",
|
|
3158
|
+
to: "human",
|
|
3159
|
+
subject: "chat",
|
|
3160
|
+
body: "Coordinator answer",
|
|
3161
|
+
type: "result",
|
|
3162
|
+
priority: "normal",
|
|
3163
|
+
threadId: null,
|
|
3164
|
+
audience: "human",
|
|
3165
|
+
});
|
|
3166
|
+
store.close();
|
|
3167
|
+
|
|
3168
|
+
const res = await dispatch("/api/coordinator/chat/history");
|
|
3169
|
+
expect(res.status).toBe(200);
|
|
3170
|
+
const body = (await json(res)) as Array<{ body: string; from: string }>;
|
|
3171
|
+
expect(body.length).toBe(2);
|
|
3172
|
+
const bodies = body.map((m) => m.body);
|
|
3173
|
+
expect(bodies).toContain("Human question");
|
|
3174
|
+
expect(bodies).toContain("Coordinator answer");
|
|
3175
|
+
});
|
|
3176
|
+
|
|
3177
|
+
it("excludes messages between unrelated agent pairs", async () => {
|
|
3178
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3179
|
+
const store = createMailStore(mailDbPath);
|
|
3180
|
+
// Human to coordinator - should appear
|
|
3181
|
+
store.insert({
|
|
3182
|
+
id: "coord-related-001",
|
|
3183
|
+
from: "human",
|
|
3184
|
+
to: "coordinator",
|
|
3185
|
+
subject: "chat",
|
|
3186
|
+
body: "To coordinator",
|
|
3187
|
+
type: "status",
|
|
3188
|
+
priority: "normal",
|
|
3189
|
+
threadId: null,
|
|
3190
|
+
audience: "human",
|
|
3191
|
+
});
|
|
3192
|
+
// Worker to builder - unrelated pair, should not appear
|
|
3193
|
+
store.insert({
|
|
3194
|
+
id: "coord-unrelated-001",
|
|
3195
|
+
from: "worker",
|
|
3196
|
+
to: "builder",
|
|
3197
|
+
subject: "status",
|
|
3198
|
+
body: "Worker to builder",
|
|
3199
|
+
type: "status",
|
|
3200
|
+
priority: "normal",
|
|
3201
|
+
threadId: null,
|
|
3202
|
+
audience: "human",
|
|
3203
|
+
});
|
|
3204
|
+
// Builder to worker - unrelated pair, should not appear
|
|
3205
|
+
store.insert({
|
|
3206
|
+
id: "coord-unrelated-002",
|
|
3207
|
+
from: "builder",
|
|
3208
|
+
to: "worker",
|
|
3209
|
+
subject: "status",
|
|
3210
|
+
body: "Builder to worker",
|
|
3211
|
+
type: "status",
|
|
3212
|
+
priority: "normal",
|
|
3213
|
+
threadId: null,
|
|
3214
|
+
audience: "human",
|
|
3215
|
+
});
|
|
3216
|
+
store.close();
|
|
3217
|
+
|
|
3218
|
+
const res = await dispatch("/api/coordinator/chat/history");
|
|
3219
|
+
expect(res.status).toBe(200);
|
|
3220
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3221
|
+
expect(body.length).toBe(1);
|
|
3222
|
+
expect(body[0]?.body).toBe("To coordinator");
|
|
3223
|
+
});
|
|
3224
|
+
});
|
|
3225
|
+
|
|
3226
|
+
// ---------------------------------------------------------------------------
|
|
3227
|
+
// POST /api/agents/:name/chat
|
|
3228
|
+
// ---------------------------------------------------------------------------
|
|
3229
|
+
|
|
3230
|
+
describe("POST /api/agents/:name/chat", () => {
|
|
3231
|
+
it("returns 400 when text field is missing", async () => {
|
|
3232
|
+
const res = await dispatchPost("/api/agents/scout-1/chat", {});
|
|
3233
|
+
expect(res.status).toBe(400);
|
|
3234
|
+
const body = (await json(res)) as { error: string };
|
|
3235
|
+
expect(body.error).toContain("text");
|
|
3236
|
+
});
|
|
3237
|
+
|
|
3238
|
+
it("returns 400 when text field is empty string", async () => {
|
|
3239
|
+
const res = await dispatchPost("/api/agents/scout-1/chat", { text: " " });
|
|
3240
|
+
expect(res.status).toBe(400);
|
|
3241
|
+
const body = (await json(res)) as { error: string };
|
|
3242
|
+
expect(body.error).toContain("text");
|
|
3243
|
+
});
|
|
3244
|
+
|
|
3245
|
+
it("returns 400 for non-JSON body", async () => {
|
|
3246
|
+
const res = await handleApiRequest(
|
|
3247
|
+
new Request("http://localhost/api/agents/scout-1/chat", {
|
|
3248
|
+
method: "POST",
|
|
3249
|
+
body: "not json",
|
|
3250
|
+
headers: { "Content-Type": "text/plain" },
|
|
3251
|
+
}),
|
|
3252
|
+
legioDir,
|
|
3253
|
+
projectRoot,
|
|
3254
|
+
);
|
|
3255
|
+
expect(res.status).toBe(400);
|
|
3256
|
+
});
|
|
3257
|
+
|
|
3258
|
+
it("returns 404 when agent is not running (no sessions.db)", async () => {
|
|
3259
|
+
const res = await dispatchPost("/api/agents/scout-1/chat", { text: "hello" });
|
|
3260
|
+
expect(res.status).toBe(404);
|
|
3261
|
+
const body = (await json(res)) as { error: string };
|
|
3262
|
+
expect(body.error).toContain("scout-1");
|
|
3263
|
+
});
|
|
3264
|
+
|
|
3265
|
+
it("persists message to mail.db with agent-specific to field", async () => {
|
|
3266
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
3267
|
+
const store = createSessionStore(dbPath);
|
|
3268
|
+
const now = new Date().toISOString();
|
|
3269
|
+
store.upsert({
|
|
3270
|
+
id: "sess-agent-chat-001",
|
|
3271
|
+
agentName: "scout-1",
|
|
3272
|
+
capability: "scout",
|
|
3273
|
+
worktreePath: "/tmp/wt/scout-1",
|
|
3274
|
+
branchName: "legio/scout-1/task-1",
|
|
3275
|
+
beadId: "task-1",
|
|
3276
|
+
tmuxSession: "legio-test-scout-1",
|
|
3277
|
+
state: "working",
|
|
3278
|
+
pid: 12345,
|
|
3279
|
+
parentAgent: null,
|
|
3280
|
+
depth: 1,
|
|
3281
|
+
runId: "run-001",
|
|
3282
|
+
startedAt: now,
|
|
3283
|
+
lastActivity: now,
|
|
3284
|
+
escalationLevel: 0,
|
|
3285
|
+
stalledSince: null,
|
|
3286
|
+
});
|
|
3287
|
+
store.close();
|
|
3288
|
+
|
|
3289
|
+
const res = await dispatchPost("/api/agents/scout-1/chat", { text: "Hello scout" });
|
|
3290
|
+
expect(res.status).toBe(201);
|
|
3291
|
+
const body = (await json(res)) as {
|
|
3292
|
+
from: string;
|
|
3293
|
+
to: string;
|
|
3294
|
+
body: string;
|
|
3295
|
+
audience: string;
|
|
3296
|
+
};
|
|
3297
|
+
expect(body.from).toBe("human");
|
|
3298
|
+
expect(body.to).toBe("scout-1");
|
|
3299
|
+
expect(body.body).toBe("Hello scout");
|
|
3300
|
+
expect(body.audience).toBe("human");
|
|
3301
|
+
|
|
3302
|
+
// Verify persisted in mail.db
|
|
3303
|
+
const mailStore = createMailStore(join(legioDir, "mail.db"));
|
|
3304
|
+
const messages = mailStore.getAll({ from: "human", to: "scout-1", audience: "human" });
|
|
3305
|
+
mailStore.close();
|
|
3306
|
+
expect(messages.length).toBe(1);
|
|
3307
|
+
expect(messages[0]?.body).toBe("Hello scout");
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
it("returns 404 when agent session is in DB but tmux session is not alive", async () => {
|
|
3311
|
+
const dbPath = join(legioDir, "sessions.db");
|
|
3312
|
+
const store = createSessionStore(dbPath);
|
|
3313
|
+
const now = new Date().toISOString();
|
|
3314
|
+
store.upsert({
|
|
3315
|
+
id: "sess-agent-stale-001",
|
|
3316
|
+
agentName: "builder-2",
|
|
3317
|
+
capability: "builder",
|
|
3318
|
+
worktreePath: "/tmp/wt/builder-2",
|
|
3319
|
+
branchName: "legio/builder-2/task-2",
|
|
3320
|
+
beadId: "task-2",
|
|
3321
|
+
tmuxSession: "legio-test-builder-2-stale",
|
|
3322
|
+
state: "working",
|
|
3323
|
+
pid: 99999,
|
|
3324
|
+
parentAgent: null,
|
|
3325
|
+
depth: 2,
|
|
3326
|
+
runId: "run-001",
|
|
3327
|
+
startedAt: now,
|
|
3328
|
+
lastActivity: now,
|
|
3329
|
+
escalationLevel: 0,
|
|
3330
|
+
stalledSince: null,
|
|
3331
|
+
});
|
|
3332
|
+
store.close();
|
|
3333
|
+
|
|
3334
|
+
mockIsSessionAlive.mockResolvedValueOnce(false);
|
|
3335
|
+
const res = await dispatchPost("/api/agents/builder-2/chat", { text: "hello" });
|
|
3336
|
+
expect(res.status).toBe(404);
|
|
3337
|
+
const body = (await json(res)) as { error: string };
|
|
3338
|
+
expect(body.error).toContain("not alive");
|
|
3339
|
+
});
|
|
3340
|
+
});
|
|
3341
|
+
|
|
3342
|
+
// ---------------------------------------------------------------------------
|
|
3343
|
+
// GET /api/agents/:name/chat/history
|
|
3344
|
+
// ---------------------------------------------------------------------------
|
|
3345
|
+
|
|
3346
|
+
describe("GET /api/agents/:name/chat/history", () => {
|
|
3347
|
+
it("returns empty array when mail.db does not exist", async () => {
|
|
3348
|
+
const res = await dispatch("/api/agents/scout-1/chat/history");
|
|
3349
|
+
expect(res.status).toBe(200);
|
|
3350
|
+
expect(await json(res)).toEqual([]);
|
|
3351
|
+
});
|
|
3352
|
+
|
|
3353
|
+
it("returns messages sent to the agent in chronological order", async () => {
|
|
3354
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3355
|
+
const store = createMailStore(mailDbPath);
|
|
3356
|
+
store.insert({
|
|
3357
|
+
id: "agent-chat-001",
|
|
3358
|
+
from: "human",
|
|
3359
|
+
to: "scout-1",
|
|
3360
|
+
subject: "chat",
|
|
3361
|
+
body: "First to scout",
|
|
3362
|
+
type: "status",
|
|
3363
|
+
priority: "normal",
|
|
3364
|
+
threadId: null,
|
|
3365
|
+
audience: "human",
|
|
3366
|
+
});
|
|
3367
|
+
store.insert({
|
|
3368
|
+
id: "agent-chat-002",
|
|
3369
|
+
from: "human",
|
|
3370
|
+
to: "scout-1",
|
|
3371
|
+
subject: "chat",
|
|
3372
|
+
body: "Second to scout",
|
|
3373
|
+
type: "status",
|
|
3374
|
+
priority: "normal",
|
|
3375
|
+
threadId: null,
|
|
3376
|
+
audience: "human",
|
|
3377
|
+
});
|
|
3378
|
+
// Message to a different agent — should not appear
|
|
3379
|
+
store.insert({
|
|
3380
|
+
id: "agent-chat-003",
|
|
3381
|
+
from: "human",
|
|
3382
|
+
to: "builder-1",
|
|
3383
|
+
subject: "chat",
|
|
3384
|
+
body: "To builder",
|
|
3385
|
+
type: "status",
|
|
3386
|
+
priority: "normal",
|
|
3387
|
+
threadId: null,
|
|
3388
|
+
audience: "human",
|
|
3389
|
+
});
|
|
3390
|
+
// Scout response back to human — should appear
|
|
3391
|
+
store.insert({
|
|
3392
|
+
id: "agent-chat-004",
|
|
3393
|
+
from: "scout-1",
|
|
3394
|
+
to: "human",
|
|
3395
|
+
subject: "chat",
|
|
3396
|
+
body: "Scout response",
|
|
3397
|
+
type: "status",
|
|
3398
|
+
priority: "normal",
|
|
3399
|
+
threadId: null,
|
|
3400
|
+
audience: "human",
|
|
3401
|
+
});
|
|
3402
|
+
store.close();
|
|
3403
|
+
|
|
3404
|
+
const res = await dispatch("/api/agents/scout-1/chat/history");
|
|
3405
|
+
expect(res.status).toBe(200);
|
|
3406
|
+
const body = (await json(res)) as Array<{ body: string; to: string }>;
|
|
3407
|
+
expect(body.length).toBe(3);
|
|
3408
|
+
// All three messages present (human->scout-1 and scout-1->human)
|
|
3409
|
+
const bodies = body.map((m) => m.body);
|
|
3410
|
+
expect(bodies).toContain("First to scout");
|
|
3411
|
+
expect(bodies).toContain("Second to scout");
|
|
3412
|
+
expect(bodies).toContain("Scout response");
|
|
3413
|
+
});
|
|
3414
|
+
|
|
3415
|
+
it("does not include messages with non-human audience", async () => {
|
|
3416
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3417
|
+
const store = createMailStore(mailDbPath);
|
|
3418
|
+
store.insert({
|
|
3419
|
+
id: "agent-both-001",
|
|
3420
|
+
from: "human",
|
|
3421
|
+
to: "scout-1",
|
|
3422
|
+
subject: "chat",
|
|
3423
|
+
body: "Agent audience message",
|
|
3424
|
+
type: "status",
|
|
3425
|
+
priority: "normal",
|
|
3426
|
+
threadId: null,
|
|
3427
|
+
audience: "agent",
|
|
3428
|
+
});
|
|
3429
|
+
store.insert({
|
|
3430
|
+
id: "agent-human-001",
|
|
3431
|
+
from: "human",
|
|
3432
|
+
to: "scout-1",
|
|
3433
|
+
subject: "chat",
|
|
3434
|
+
body: "Human audience message",
|
|
3435
|
+
type: "status",
|
|
3436
|
+
priority: "normal",
|
|
3437
|
+
threadId: null,
|
|
3438
|
+
audience: "human",
|
|
3439
|
+
});
|
|
3440
|
+
store.insert({
|
|
3441
|
+
id: "scout-agent-msg-001",
|
|
3442
|
+
from: "scout-1",
|
|
3443
|
+
to: "human",
|
|
3444
|
+
subject: "response",
|
|
3445
|
+
body: "Agent-only scout response",
|
|
3446
|
+
type: "status",
|
|
3447
|
+
priority: "normal",
|
|
3448
|
+
threadId: null,
|
|
3449
|
+
audience: "agent",
|
|
3450
|
+
});
|
|
3451
|
+
store.close();
|
|
3452
|
+
|
|
3453
|
+
const res = await dispatch("/api/agents/scout-1/chat/history");
|
|
3454
|
+
expect(res.status).toBe(200);
|
|
3455
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3456
|
+
expect(body.length).toBe(1);
|
|
3457
|
+
expect(body[0]?.body).toBe("Human audience message");
|
|
3458
|
+
});
|
|
3459
|
+
|
|
3460
|
+
it("respects ?limit= query param", async () => {
|
|
3461
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3462
|
+
const store = createMailStore(mailDbPath);
|
|
3463
|
+
for (let i = 0; i < 5; i++) {
|
|
3464
|
+
store.insert({
|
|
3465
|
+
id: `scout-limit-msg-${String(i).padStart(3, "0")}`,
|
|
3466
|
+
from: "human",
|
|
3467
|
+
to: "scout-1",
|
|
3468
|
+
subject: "chat",
|
|
3469
|
+
body: `Scout message ${i}`,
|
|
3470
|
+
type: "status",
|
|
3471
|
+
priority: "normal",
|
|
3472
|
+
threadId: null,
|
|
3473
|
+
audience: "human",
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
store.close();
|
|
3477
|
+
|
|
3478
|
+
const res = await dispatch("/api/agents/scout-1/chat/history", { limit: "2" });
|
|
3479
|
+
expect(res.status).toBe(200);
|
|
3480
|
+
const body = (await json(res)) as unknown[];
|
|
3481
|
+
expect(body.length).toBe(2);
|
|
3482
|
+
});
|
|
3483
|
+
|
|
3484
|
+
it("returns bidirectional messages (human-to-agent and agent-to-human)", async () => {
|
|
3485
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3486
|
+
const store = createMailStore(mailDbPath);
|
|
3487
|
+
store.insert({
|
|
3488
|
+
id: "bidir-agent-001",
|
|
3489
|
+
from: "human",
|
|
3490
|
+
to: "scout-1",
|
|
3491
|
+
subject: "chat",
|
|
3492
|
+
body: "Human question to scout",
|
|
3493
|
+
type: "status",
|
|
3494
|
+
priority: "normal",
|
|
3495
|
+
threadId: null,
|
|
3496
|
+
audience: "human",
|
|
3497
|
+
});
|
|
3498
|
+
store.insert({
|
|
3499
|
+
id: "bidir-agent-002",
|
|
3500
|
+
from: "scout-1",
|
|
3501
|
+
to: "human",
|
|
3502
|
+
subject: "chat",
|
|
3503
|
+
body: "Scout answer",
|
|
3504
|
+
type: "result",
|
|
3505
|
+
priority: "normal",
|
|
3506
|
+
threadId: null,
|
|
3507
|
+
audience: "human",
|
|
3508
|
+
});
|
|
3509
|
+
store.close();
|
|
3510
|
+
|
|
3511
|
+
const res = await dispatch("/api/agents/scout-1/chat/history");
|
|
3512
|
+
expect(res.status).toBe(200);
|
|
3513
|
+
const body = (await json(res)) as Array<{ body: string; from: string }>;
|
|
3514
|
+
expect(body.length).toBe(2);
|
|
3515
|
+
const bodies = body.map((m) => m.body);
|
|
3516
|
+
expect(bodies).toContain("Human question to scout");
|
|
3517
|
+
expect(bodies).toContain("Scout answer");
|
|
3518
|
+
});
|
|
3519
|
+
|
|
3520
|
+
it("excludes messages between unrelated agent pairs", async () => {
|
|
3521
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3522
|
+
const store = createMailStore(mailDbPath);
|
|
3523
|
+
// Human to scout-1 - should appear
|
|
3524
|
+
store.insert({
|
|
3525
|
+
id: "scout-related-001",
|
|
3526
|
+
from: "human",
|
|
3527
|
+
to: "scout-1",
|
|
3528
|
+
subject: "chat",
|
|
3529
|
+
body: "To scout",
|
|
3530
|
+
type: "status",
|
|
3531
|
+
priority: "normal",
|
|
3532
|
+
threadId: null,
|
|
3533
|
+
audience: "human",
|
|
3534
|
+
});
|
|
3535
|
+
// Human to builder-1 - different agent, should not appear
|
|
3536
|
+
store.insert({
|
|
3537
|
+
id: "scout-unrelated-001",
|
|
3538
|
+
from: "human",
|
|
3539
|
+
to: "builder-1",
|
|
3540
|
+
subject: "chat",
|
|
3541
|
+
body: "To builder",
|
|
3542
|
+
type: "status",
|
|
3543
|
+
priority: "normal",
|
|
3544
|
+
threadId: null,
|
|
3545
|
+
audience: "human",
|
|
3546
|
+
});
|
|
3547
|
+
// builder-1 to human - different agent, should not appear
|
|
3548
|
+
store.insert({
|
|
3549
|
+
id: "scout-unrelated-002",
|
|
3550
|
+
from: "builder-1",
|
|
3551
|
+
to: "human",
|
|
3552
|
+
subject: "chat",
|
|
3553
|
+
body: "Builder response",
|
|
3554
|
+
type: "status",
|
|
3555
|
+
priority: "normal",
|
|
3556
|
+
threadId: null,
|
|
3557
|
+
audience: "human",
|
|
3558
|
+
});
|
|
3559
|
+
store.close();
|
|
3560
|
+
|
|
3561
|
+
const res = await dispatch("/api/agents/scout-1/chat/history");
|
|
3562
|
+
expect(res.status).toBe(200);
|
|
3563
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3564
|
+
expect(body.length).toBe(1);
|
|
3565
|
+
expect(body[0]?.body).toBe("To scout");
|
|
3566
|
+
});
|
|
3567
|
+
});
|
|
3568
|
+
|
|
3569
|
+
// ---------------------------------------------------------------------------
|
|
3570
|
+
// GET /api/chat/unified/history
|
|
3571
|
+
// ---------------------------------------------------------------------------
|
|
3572
|
+
|
|
3573
|
+
describe("GET /api/chat/unified/history", () => {
|
|
3574
|
+
it("returns empty array when mail.db does not exist", async () => {
|
|
3575
|
+
const res = await dispatch("/api/chat/unified/history");
|
|
3576
|
+
expect(res.status).toBe(200);
|
|
3577
|
+
expect(await json(res)).toEqual([]);
|
|
3578
|
+
});
|
|
3579
|
+
|
|
3580
|
+
it("returns all human-audience messages from all agents in chronological order", async () => {
|
|
3581
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3582
|
+
const store = createMailStore(mailDbPath);
|
|
3583
|
+
store.insert({
|
|
3584
|
+
id: "unified-001",
|
|
3585
|
+
from: "human",
|
|
3586
|
+
to: "coordinator",
|
|
3587
|
+
subject: "chat",
|
|
3588
|
+
body: "Hello coordinator",
|
|
3589
|
+
type: "status",
|
|
3590
|
+
priority: "normal",
|
|
3591
|
+
threadId: null,
|
|
3592
|
+
audience: "human",
|
|
3593
|
+
});
|
|
3594
|
+
store.insert({
|
|
3595
|
+
id: "unified-002",
|
|
3596
|
+
from: "coordinator",
|
|
3597
|
+
to: "human",
|
|
3598
|
+
subject: "chat",
|
|
3599
|
+
body: "Coordinator reply",
|
|
3600
|
+
type: "status",
|
|
3601
|
+
priority: "normal",
|
|
3602
|
+
threadId: null,
|
|
3603
|
+
audience: "human",
|
|
3604
|
+
});
|
|
3605
|
+
store.insert({
|
|
3606
|
+
id: "unified-003",
|
|
3607
|
+
from: "human",
|
|
3608
|
+
to: "scout-1",
|
|
3609
|
+
subject: "chat",
|
|
3610
|
+
body: "Hello scout",
|
|
3611
|
+
type: "status",
|
|
3612
|
+
priority: "normal",
|
|
3613
|
+
threadId: null,
|
|
3614
|
+
audience: "human",
|
|
3615
|
+
});
|
|
3616
|
+
store.insert({
|
|
3617
|
+
id: "unified-004",
|
|
3618
|
+
from: "scout-1",
|
|
3619
|
+
to: "human",
|
|
3620
|
+
subject: "chat",
|
|
3621
|
+
body: "Scout reply",
|
|
3622
|
+
type: "status",
|
|
3623
|
+
priority: "normal",
|
|
3624
|
+
threadId: null,
|
|
3625
|
+
audience: "human",
|
|
3626
|
+
});
|
|
3627
|
+
store.close();
|
|
3628
|
+
|
|
3629
|
+
const res = await dispatch("/api/chat/unified/history");
|
|
3630
|
+
expect(res.status).toBe(200);
|
|
3631
|
+
const body = (await json(res)) as Array<{ body: string; from: string }>;
|
|
3632
|
+
expect(body.length).toBe(4);
|
|
3633
|
+
const bodies = body.map((m) => m.body);
|
|
3634
|
+
expect(bodies).toContain("Hello coordinator");
|
|
3635
|
+
expect(bodies).toContain("Coordinator reply");
|
|
3636
|
+
expect(bodies).toContain("Hello scout");
|
|
3637
|
+
expect(bodies).toContain("Scout reply");
|
|
3638
|
+
});
|
|
3639
|
+
|
|
3640
|
+
it("excludes agent-audience messages", async () => {
|
|
3641
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3642
|
+
const store = createMailStore(mailDbPath);
|
|
3643
|
+
store.insert({
|
|
3644
|
+
id: "unified-visible-001",
|
|
3645
|
+
from: "human",
|
|
3646
|
+
to: "coordinator",
|
|
3647
|
+
subject: "chat",
|
|
3648
|
+
body: "Human message",
|
|
3649
|
+
type: "status",
|
|
3650
|
+
priority: "normal",
|
|
3651
|
+
threadId: null,
|
|
3652
|
+
audience: "human",
|
|
3653
|
+
});
|
|
3654
|
+
store.insert({
|
|
3655
|
+
id: "unified-hidden-001",
|
|
3656
|
+
from: "worker",
|
|
3657
|
+
to: "coordinator",
|
|
3658
|
+
subject: "status",
|
|
3659
|
+
body: "Agent-only message",
|
|
3660
|
+
type: "status",
|
|
3661
|
+
priority: "normal",
|
|
3662
|
+
threadId: null,
|
|
3663
|
+
audience: "agent",
|
|
3664
|
+
});
|
|
3665
|
+
store.close();
|
|
3666
|
+
|
|
3667
|
+
const res = await dispatch("/api/chat/unified/history");
|
|
3668
|
+
expect(res.status).toBe(200);
|
|
3669
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3670
|
+
expect(body.length).toBe(1);
|
|
3671
|
+
expect(body[0]?.body).toBe("Human message");
|
|
3672
|
+
});
|
|
3673
|
+
|
|
3674
|
+
it("includes audience=both messages", async () => {
|
|
3675
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3676
|
+
const store = createMailStore(mailDbPath);
|
|
3677
|
+
store.insert({
|
|
3678
|
+
id: "unified-both-001",
|
|
3679
|
+
from: "coordinator",
|
|
3680
|
+
to: "human",
|
|
3681
|
+
subject: "update",
|
|
3682
|
+
body: "Broadcast update",
|
|
3683
|
+
type: "status",
|
|
3684
|
+
priority: "normal",
|
|
3685
|
+
threadId: null,
|
|
3686
|
+
audience: "both",
|
|
3687
|
+
});
|
|
3688
|
+
store.close();
|
|
3689
|
+
|
|
3690
|
+
const res = await dispatch("/api/chat/unified/history");
|
|
3691
|
+
expect(res.status).toBe(200);
|
|
3692
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3693
|
+
expect(body.length).toBe(1);
|
|
3694
|
+
expect(body[0]?.body).toBe("Broadcast update");
|
|
3695
|
+
});
|
|
3696
|
+
|
|
3697
|
+
it("deduplicates messages that appear in both audience queries", async () => {
|
|
3698
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3699
|
+
const store = createMailStore(mailDbPath);
|
|
3700
|
+
store.insert({
|
|
3701
|
+
id: "unified-both-dedup",
|
|
3702
|
+
from: "coordinator",
|
|
3703
|
+
to: "human",
|
|
3704
|
+
subject: "chat",
|
|
3705
|
+
body: "Should appear once",
|
|
3706
|
+
type: "status",
|
|
3707
|
+
priority: "normal",
|
|
3708
|
+
threadId: null,
|
|
3709
|
+
audience: "both",
|
|
3710
|
+
});
|
|
3711
|
+
store.close();
|
|
3712
|
+
|
|
3713
|
+
const res = await dispatch("/api/chat/unified/history");
|
|
3714
|
+
expect(res.status).toBe(200);
|
|
3715
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3716
|
+
expect(body.length).toBe(1);
|
|
3717
|
+
});
|
|
3718
|
+
|
|
3719
|
+
it("respects limit parameter", async () => {
|
|
3720
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3721
|
+
const store = createMailStore(mailDbPath);
|
|
3722
|
+
for (let i = 1; i <= 5; i++) {
|
|
3723
|
+
store.insert({
|
|
3724
|
+
id: `unified-limit-00${i}`,
|
|
3725
|
+
from: "human",
|
|
3726
|
+
to: "coordinator",
|
|
3727
|
+
subject: "chat",
|
|
3728
|
+
body: `Message ${i}`,
|
|
3729
|
+
type: "status",
|
|
3730
|
+
priority: "normal",
|
|
3731
|
+
threadId: null,
|
|
3732
|
+
audience: "human",
|
|
3733
|
+
});
|
|
3734
|
+
}
|
|
3735
|
+
store.close();
|
|
3736
|
+
|
|
3737
|
+
const res = await dispatch("/api/chat/unified/history", { limit: "3" });
|
|
3738
|
+
expect(res.status).toBe(200);
|
|
3739
|
+
const body = (await json(res)) as Array<{ body: string }>;
|
|
3740
|
+
expect(body.length).toBe(3);
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
it("includes messages where from=human even without explicit audience filtering by from/to", async () => {
|
|
3744
|
+
const mailDbPath = join(legioDir, "mail.db");
|
|
3745
|
+
const store = createMailStore(mailDbPath);
|
|
3746
|
+
// Message from human to a non-standard agent
|
|
3747
|
+
store.insert({
|
|
3748
|
+
id: "unified-bidir-001",
|
|
3749
|
+
from: "human",
|
|
3750
|
+
to: "lead-1",
|
|
3751
|
+
subject: "chat",
|
|
3752
|
+
body: "Hello lead",
|
|
3753
|
+
type: "status",
|
|
3754
|
+
priority: "normal",
|
|
3755
|
+
threadId: null,
|
|
3756
|
+
audience: "human",
|
|
3757
|
+
});
|
|
3758
|
+
// Response from agent to human
|
|
3759
|
+
store.insert({
|
|
3760
|
+
id: "unified-bidir-002",
|
|
3761
|
+
from: "lead-1",
|
|
3762
|
+
to: "human",
|
|
3763
|
+
subject: "chat",
|
|
3764
|
+
body: "Lead reply",
|
|
3765
|
+
type: "status",
|
|
3766
|
+
priority: "normal",
|
|
3767
|
+
threadId: null,
|
|
3768
|
+
audience: "human",
|
|
3769
|
+
});
|
|
3770
|
+
// Agent-only message (should be excluded)
|
|
3771
|
+
store.insert({
|
|
3772
|
+
id: "unified-bidir-003",
|
|
3773
|
+
from: "lead-1",
|
|
3774
|
+
to: "coordinator",
|
|
3775
|
+
subject: "status",
|
|
3776
|
+
body: "Internal message",
|
|
3777
|
+
type: "status",
|
|
3778
|
+
priority: "normal",
|
|
3779
|
+
threadId: null,
|
|
3780
|
+
audience: "agent",
|
|
3781
|
+
});
|
|
3782
|
+
store.close();
|
|
3783
|
+
|
|
3784
|
+
const res = await dispatch("/api/chat/unified/history");
|
|
3785
|
+
expect(res.status).toBe(200);
|
|
3786
|
+
const body = (await json(res)) as Array<{ body: string; from: string; to: string }>;
|
|
3787
|
+
expect(body.length).toBe(2);
|
|
3788
|
+
expect(body.map((m) => m.body)).toContain("Hello lead");
|
|
3789
|
+
expect(body.map((m) => m.body)).toContain("Lead reply");
|
|
3790
|
+
});
|
|
3791
|
+
});
|
|
3792
|
+
|
|
3793
|
+
// ---------------------------------------------------------------------------
|
|
3794
|
+
// POST /api/chat/transcript-sync
|
|
3795
|
+
// ---------------------------------------------------------------------------
|
|
3796
|
+
|
|
3797
|
+
describe("POST /api/chat/transcript-sync", () => {
|
|
3798
|
+
it("returns 400 when agent field is missing", async () => {
|
|
3799
|
+
const res = await dispatchPost("/api/chat/transcript-sync", {});
|
|
3800
|
+
expect(res.status).toBe(400);
|
|
3801
|
+
});
|
|
3802
|
+
|
|
3803
|
+
it("returns 404 when sessions.db does not exist", async () => {
|
|
3804
|
+
const res = await dispatchPost("/api/chat/transcript-sync", { agent: "coordinator" });
|
|
3805
|
+
expect(res.status).toBe(404);
|
|
3806
|
+
});
|
|
3807
|
+
|
|
3808
|
+
it("returns 404 when no active session for agent", async () => {
|
|
3809
|
+
// Create empty sessions.db
|
|
3810
|
+
const sessStore = createSessionStore(join(legioDir, "sessions.db"));
|
|
3811
|
+
sessStore.close();
|
|
3812
|
+
|
|
3813
|
+
const res = await dispatchPost("/api/chat/transcript-sync", { agent: "nonexistent" });
|
|
3814
|
+
expect(res.status).toBe(404);
|
|
3815
|
+
});
|
|
3816
|
+
});
|