@os-eco/overstory-cli 0.6.1
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/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -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 +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,1454 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ValidationError } from "../errors.ts";
|
|
6
|
+
import { createEventStore } from "../events/store.ts";
|
|
7
|
+
import { createMailClient } from "../mail/client.ts";
|
|
8
|
+
import { createMailStore } from "../mail/store.ts";
|
|
9
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
10
|
+
import type { MulchClient } from "../mulch/client.ts";
|
|
11
|
+
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
12
|
+
import type { AgentSession, MulchLearnResult, StoredEvent } from "../types.ts";
|
|
13
|
+
import { autoRecordExpertise, logCommand } from "./log.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tests for `overstory log` command.
|
|
17
|
+
*
|
|
18
|
+
* Uses real filesystem (temp dirs) and real bun:sqlite to test logging behavior.
|
|
19
|
+
* Captures process.stdout.write to verify help text output.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
describe("logCommand", () => {
|
|
23
|
+
let chunks: string[];
|
|
24
|
+
let originalWrite: typeof process.stdout.write;
|
|
25
|
+
let tempDir: string;
|
|
26
|
+
let originalCwd: string;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
// Spy on stdout
|
|
30
|
+
chunks = [];
|
|
31
|
+
originalWrite = process.stdout.write;
|
|
32
|
+
process.stdout.write = ((chunk: string) => {
|
|
33
|
+
chunks.push(chunk);
|
|
34
|
+
return true;
|
|
35
|
+
}) as typeof process.stdout.write;
|
|
36
|
+
|
|
37
|
+
// Create temp dir with .overstory/config.yaml structure
|
|
38
|
+
tempDir = await mkdtemp(join(tmpdir(), "log-test-"));
|
|
39
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
40
|
+
await Bun.write(
|
|
41
|
+
join(overstoryDir, "config.yaml"),
|
|
42
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Change to temp dir so loadConfig() works
|
|
46
|
+
originalCwd = process.cwd();
|
|
47
|
+
process.chdir(tempDir);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
process.stdout.write = originalWrite;
|
|
52
|
+
process.chdir(originalCwd);
|
|
53
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function output(): string {
|
|
57
|
+
return chunks.join("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fake MulchClient for testing autoRecordExpertise.
|
|
62
|
+
* Only learn() and record() are implemented — other methods are stubs.
|
|
63
|
+
* Justified: we are testing orchestration logic, not the mulch CLI itself.
|
|
64
|
+
*/
|
|
65
|
+
function createFakeMulchClient(
|
|
66
|
+
learnResult: MulchLearnResult,
|
|
67
|
+
opts?: { recordShouldFail?: boolean },
|
|
68
|
+
): {
|
|
69
|
+
client: MulchClient;
|
|
70
|
+
recordCalls: Array<{ domain: string; options: Record<string, unknown> }>;
|
|
71
|
+
} {
|
|
72
|
+
const recordCalls: Array<{ domain: string; options: Record<string, unknown> }> = [];
|
|
73
|
+
const client = {
|
|
74
|
+
async learn() {
|
|
75
|
+
return learnResult;
|
|
76
|
+
},
|
|
77
|
+
async record(domain: string, options: Record<string, unknown>) {
|
|
78
|
+
if (opts?.recordShouldFail) {
|
|
79
|
+
throw new Error("mulch record failed");
|
|
80
|
+
}
|
|
81
|
+
recordCalls.push({ domain, options });
|
|
82
|
+
},
|
|
83
|
+
} as unknown as MulchClient;
|
|
84
|
+
return { client, recordCalls };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("--help flag shows help text", async () => {
|
|
88
|
+
await logCommand(["--help"]);
|
|
89
|
+
const out = output();
|
|
90
|
+
|
|
91
|
+
expect(out).toContain("overstory log");
|
|
92
|
+
expect(out).toContain("tool-start");
|
|
93
|
+
expect(out).toContain("tool-end");
|
|
94
|
+
expect(out).toContain("session-end");
|
|
95
|
+
expect(out).toContain("--agent");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("-h flag shows help text", async () => {
|
|
99
|
+
await logCommand(["-h"]);
|
|
100
|
+
const out = output();
|
|
101
|
+
|
|
102
|
+
expect(out).toContain("overstory log");
|
|
103
|
+
expect(out).toContain("tool-start");
|
|
104
|
+
expect(out).toContain("tool-end");
|
|
105
|
+
expect(out).toContain("session-end");
|
|
106
|
+
expect(out).toContain("--agent");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("missing event with only flags throws ValidationError", async () => {
|
|
110
|
+
// The code finds first non-flag arg. Passing only flags should trigger "Event is required"
|
|
111
|
+
// Note: the implementation checks for undefined event
|
|
112
|
+
await expect(async () => {
|
|
113
|
+
await logCommand([]);
|
|
114
|
+
}).toThrow(ValidationError);
|
|
115
|
+
|
|
116
|
+
await expect(async () => {
|
|
117
|
+
await logCommand([]);
|
|
118
|
+
}).toThrow("Event is required");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("invalid event name throws ValidationError", async () => {
|
|
122
|
+
expect(async () => {
|
|
123
|
+
await logCommand(["invalid-event", "--agent", "test-agent"]);
|
|
124
|
+
}).toThrow(ValidationError);
|
|
125
|
+
|
|
126
|
+
expect(async () => {
|
|
127
|
+
await logCommand(["invalid-event", "--agent", "test-agent"]);
|
|
128
|
+
}).toThrow("Invalid event");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("missing --agent flag throws ValidationError", async () => {
|
|
132
|
+
expect(async () => {
|
|
133
|
+
await logCommand(["tool-start"]);
|
|
134
|
+
}).toThrow(ValidationError);
|
|
135
|
+
|
|
136
|
+
expect(async () => {
|
|
137
|
+
await logCommand(["tool-start"]);
|
|
138
|
+
}).toThrow("--agent is required");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("tool-start creates log directory structure", async () => {
|
|
142
|
+
await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Read"]);
|
|
143
|
+
|
|
144
|
+
const logsDir = join(tempDir, ".overstory", "logs", "test-builder");
|
|
145
|
+
const contents = await readdir(logsDir);
|
|
146
|
+
|
|
147
|
+
// Should have at least .current-session marker and a session directory
|
|
148
|
+
expect(contents).toContain(".current-session");
|
|
149
|
+
expect(contents.length).toBeGreaterThanOrEqual(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("tool-start creates session directory and .current-session marker", async () => {
|
|
153
|
+
await logCommand(["tool-start", "--agent", "test-scout", "--tool-name", "Grep"]);
|
|
154
|
+
|
|
155
|
+
const logsDir = join(tempDir, ".overstory", "logs", "test-scout");
|
|
156
|
+
const markerPath = join(logsDir, ".current-session");
|
|
157
|
+
const markerFile = Bun.file(markerPath);
|
|
158
|
+
|
|
159
|
+
expect(await markerFile.exists()).toBe(true);
|
|
160
|
+
|
|
161
|
+
const sessionDir = (await markerFile.text()).trim();
|
|
162
|
+
expect(sessionDir).toBeTruthy();
|
|
163
|
+
expect(sessionDir).toContain(logsDir);
|
|
164
|
+
|
|
165
|
+
// Session directory should exist
|
|
166
|
+
const dirStat = await stat(sessionDir);
|
|
167
|
+
expect(dirStat.isDirectory()).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("tool-start creates log files in session directory", async () => {
|
|
171
|
+
await logCommand(["tool-start", "--agent", "test-builder", "--tool-name", "Write"]);
|
|
172
|
+
|
|
173
|
+
// Wait for async file writes to complete
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
175
|
+
|
|
176
|
+
const logsDir = join(tempDir, ".overstory", "logs", "test-builder");
|
|
177
|
+
const markerPath = join(logsDir, ".current-session");
|
|
178
|
+
const sessionDir = (await Bun.file(markerPath).text()).trim();
|
|
179
|
+
|
|
180
|
+
// Check for events.ndjson file
|
|
181
|
+
const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
|
|
182
|
+
expect(await eventsFile.exists()).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("tool-end uses the same session directory as tool-start", async () => {
|
|
186
|
+
await logCommand(["tool-start", "--agent", "test-agent", "--tool-name", "Edit"]);
|
|
187
|
+
|
|
188
|
+
const logsDir = join(tempDir, ".overstory", "logs", "test-agent");
|
|
189
|
+
const markerPath = join(logsDir, ".current-session");
|
|
190
|
+
const sessionDirAfterStart = (await Bun.file(markerPath).text()).trim();
|
|
191
|
+
|
|
192
|
+
await logCommand(["tool-end", "--agent", "test-agent", "--tool-name", "Edit"]);
|
|
193
|
+
|
|
194
|
+
const sessionDirAfterEnd = (await Bun.file(markerPath).text()).trim();
|
|
195
|
+
expect(sessionDirAfterEnd).toBe(sessionDirAfterStart);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("tool-end writes to the same session directory", async () => {
|
|
199
|
+
await logCommand(["tool-start", "--agent", "test-worker", "--tool-name", "Bash"]);
|
|
200
|
+
await logCommand(["tool-end", "--agent", "test-worker", "--tool-name", "Bash"]);
|
|
201
|
+
|
|
202
|
+
// Wait for async file writes to complete
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
204
|
+
|
|
205
|
+
const logsDir = join(tempDir, ".overstory", "logs", "test-worker");
|
|
206
|
+
const markerPath = join(logsDir, ".current-session");
|
|
207
|
+
const sessionDir = (await Bun.file(markerPath).text()).trim();
|
|
208
|
+
|
|
209
|
+
// Events file should contain both tool-start and tool-end events
|
|
210
|
+
const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
|
|
211
|
+
const eventsContent = await eventsFile.text();
|
|
212
|
+
|
|
213
|
+
expect(eventsContent).toContain("tool.start");
|
|
214
|
+
expect(eventsContent).toContain("tool.end");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("session-end transitions agent state to completed in sessions.db", async () => {
|
|
218
|
+
// Create sessions.db with a test agent
|
|
219
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
220
|
+
const session: AgentSession = {
|
|
221
|
+
id: "session-001",
|
|
222
|
+
agentName: "test-agent",
|
|
223
|
+
capability: "builder",
|
|
224
|
+
worktreePath: "/tmp/test",
|
|
225
|
+
branchName: "test-branch",
|
|
226
|
+
beadId: "bead-001",
|
|
227
|
+
tmuxSession: "test-tmux",
|
|
228
|
+
state: "working",
|
|
229
|
+
pid: 12345,
|
|
230
|
+
parentAgent: null,
|
|
231
|
+
depth: 0,
|
|
232
|
+
runId: null,
|
|
233
|
+
startedAt: new Date().toISOString(),
|
|
234
|
+
lastActivity: new Date().toISOString(),
|
|
235
|
+
escalationLevel: 0,
|
|
236
|
+
stalledSince: null,
|
|
237
|
+
};
|
|
238
|
+
const store = createSessionStore(dbPath);
|
|
239
|
+
store.upsert(session);
|
|
240
|
+
store.close();
|
|
241
|
+
|
|
242
|
+
await logCommand(["session-end", "--agent", "test-agent"]);
|
|
243
|
+
|
|
244
|
+
// Read sessions.db and verify state changed to completed
|
|
245
|
+
const readStore = createSessionStore(dbPath);
|
|
246
|
+
const updatedSession = readStore.getByName("test-agent");
|
|
247
|
+
readStore.close();
|
|
248
|
+
|
|
249
|
+
expect(updatedSession).toBeDefined();
|
|
250
|
+
expect(updatedSession?.state).toBe("completed");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("session-end clears the .current-session marker", async () => {
|
|
254
|
+
// First create a session with tool-start
|
|
255
|
+
await logCommand(["tool-start", "--agent", "test-cleanup", "--tool-name", "Read"]);
|
|
256
|
+
|
|
257
|
+
const logsDir = join(tempDir, ".overstory", "logs", "test-cleanup");
|
|
258
|
+
const markerPath = join(logsDir, ".current-session");
|
|
259
|
+
|
|
260
|
+
// Verify marker exists before session-end
|
|
261
|
+
let markerFile = Bun.file(markerPath);
|
|
262
|
+
expect(await markerFile.exists()).toBe(true);
|
|
263
|
+
|
|
264
|
+
// Now end the session
|
|
265
|
+
await logCommand(["session-end", "--agent", "test-cleanup"]);
|
|
266
|
+
|
|
267
|
+
// Marker should be removed - need to create a new Bun.file reference
|
|
268
|
+
markerFile = Bun.file(markerPath);
|
|
269
|
+
expect(await markerFile.exists()).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("session-end records metrics when agent session exists in sessions.db", async () => {
|
|
273
|
+
// Create sessions.db with a test agent
|
|
274
|
+
const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
|
|
275
|
+
const session: AgentSession = {
|
|
276
|
+
id: "session-002",
|
|
277
|
+
agentName: "metrics-agent",
|
|
278
|
+
capability: "scout",
|
|
279
|
+
worktreePath: "/tmp/metrics",
|
|
280
|
+
branchName: "metrics-branch",
|
|
281
|
+
beadId: "bead-002",
|
|
282
|
+
tmuxSession: "metrics-tmux",
|
|
283
|
+
state: "working",
|
|
284
|
+
pid: 54321,
|
|
285
|
+
parentAgent: "parent-agent",
|
|
286
|
+
depth: 1,
|
|
287
|
+
runId: null,
|
|
288
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(), // 1 minute ago
|
|
289
|
+
lastActivity: new Date().toISOString(),
|
|
290
|
+
escalationLevel: 0,
|
|
291
|
+
stalledSince: null,
|
|
292
|
+
};
|
|
293
|
+
const sessStore = createSessionStore(sessionsDbPath);
|
|
294
|
+
sessStore.upsert(session);
|
|
295
|
+
sessStore.close();
|
|
296
|
+
|
|
297
|
+
await logCommand(["session-end", "--agent", "metrics-agent"]);
|
|
298
|
+
|
|
299
|
+
// Verify metrics.db was created and has the session record
|
|
300
|
+
const metricsDbPath = join(tempDir, ".overstory", "metrics.db");
|
|
301
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
302
|
+
const metrics = metricsStore.getRecentSessions(1);
|
|
303
|
+
metricsStore.close();
|
|
304
|
+
|
|
305
|
+
expect(metrics).toHaveLength(1);
|
|
306
|
+
expect(metrics[0]?.agentName).toBe("metrics-agent");
|
|
307
|
+
expect(metrics[0]?.beadId).toBe("bead-002");
|
|
308
|
+
expect(metrics[0]?.capability).toBe("scout");
|
|
309
|
+
expect(metrics[0]?.parentAgent).toBe("parent-agent");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("session-end does NOT transition coordinator to completed (persistent agent)", async () => {
|
|
313
|
+
// Create sessions.db with a coordinator agent
|
|
314
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
315
|
+
const session: AgentSession = {
|
|
316
|
+
id: "session-coord",
|
|
317
|
+
agentName: "coordinator",
|
|
318
|
+
capability: "coordinator",
|
|
319
|
+
worktreePath: tempDir,
|
|
320
|
+
branchName: "main",
|
|
321
|
+
beadId: "",
|
|
322
|
+
tmuxSession: "overstory-coordinator",
|
|
323
|
+
state: "working",
|
|
324
|
+
pid: 11111,
|
|
325
|
+
parentAgent: null,
|
|
326
|
+
depth: 0,
|
|
327
|
+
runId: null,
|
|
328
|
+
startedAt: new Date().toISOString(),
|
|
329
|
+
lastActivity: new Date(Date.now() - 60_000).toISOString(),
|
|
330
|
+
escalationLevel: 0,
|
|
331
|
+
stalledSince: null,
|
|
332
|
+
};
|
|
333
|
+
const store = createSessionStore(dbPath);
|
|
334
|
+
store.upsert(session);
|
|
335
|
+
store.close();
|
|
336
|
+
|
|
337
|
+
await logCommand(["session-end", "--agent", "coordinator"]);
|
|
338
|
+
|
|
339
|
+
// Coordinator should remain 'working', not transition to 'completed'
|
|
340
|
+
const readStore = createSessionStore(dbPath);
|
|
341
|
+
const updatedSession = readStore.getByName("coordinator");
|
|
342
|
+
readStore.close();
|
|
343
|
+
|
|
344
|
+
expect(updatedSession).toBeDefined();
|
|
345
|
+
expect(updatedSession?.state).toBe("working");
|
|
346
|
+
// But lastActivity should be updated
|
|
347
|
+
expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
|
|
348
|
+
new Date(session.lastActivity).getTime(),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("session-end does NOT transition monitor to completed (persistent agent)", async () => {
|
|
353
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
354
|
+
const session: AgentSession = {
|
|
355
|
+
id: "session-mon",
|
|
356
|
+
agentName: "monitor",
|
|
357
|
+
capability: "monitor",
|
|
358
|
+
worktreePath: tempDir,
|
|
359
|
+
branchName: "main",
|
|
360
|
+
beadId: "",
|
|
361
|
+
tmuxSession: "overstory-monitor",
|
|
362
|
+
state: "working",
|
|
363
|
+
pid: 22222,
|
|
364
|
+
parentAgent: null,
|
|
365
|
+
depth: 0,
|
|
366
|
+
runId: null,
|
|
367
|
+
startedAt: new Date().toISOString(),
|
|
368
|
+
lastActivity: new Date(Date.now() - 60_000).toISOString(),
|
|
369
|
+
escalationLevel: 0,
|
|
370
|
+
stalledSince: null,
|
|
371
|
+
};
|
|
372
|
+
const store = createSessionStore(dbPath);
|
|
373
|
+
store.upsert(session);
|
|
374
|
+
store.close();
|
|
375
|
+
|
|
376
|
+
await logCommand(["session-end", "--agent", "monitor"]);
|
|
377
|
+
|
|
378
|
+
const readStore = createSessionStore(dbPath);
|
|
379
|
+
const updatedSession = readStore.getByName("monitor");
|
|
380
|
+
readStore.close();
|
|
381
|
+
|
|
382
|
+
expect(updatedSession).toBeDefined();
|
|
383
|
+
expect(updatedSession?.state).toBe("working");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("session-end coordinator run completion", () => {
|
|
387
|
+
test("session-end auto-completes the active run for coordinator agent", async () => {
|
|
388
|
+
// Create sessions.db with coordinator and a run
|
|
389
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
390
|
+
const sessionStoreLocal = createSessionStore(dbPath);
|
|
391
|
+
sessionStoreLocal.upsert({
|
|
392
|
+
id: "session-coord-run",
|
|
393
|
+
agentName: "coordinator",
|
|
394
|
+
capability: "coordinator",
|
|
395
|
+
worktreePath: tempDir,
|
|
396
|
+
branchName: "main",
|
|
397
|
+
beadId: "",
|
|
398
|
+
tmuxSession: "overstory-coordinator",
|
|
399
|
+
state: "working",
|
|
400
|
+
pid: 11111,
|
|
401
|
+
parentAgent: null,
|
|
402
|
+
depth: 0,
|
|
403
|
+
runId: "run-test-001",
|
|
404
|
+
startedAt: new Date().toISOString(),
|
|
405
|
+
lastActivity: new Date().toISOString(),
|
|
406
|
+
escalationLevel: 0,
|
|
407
|
+
stalledSince: null,
|
|
408
|
+
});
|
|
409
|
+
sessionStoreLocal.close();
|
|
410
|
+
|
|
411
|
+
// Create the run
|
|
412
|
+
const runStore = createRunStore(dbPath);
|
|
413
|
+
runStore.createRun({
|
|
414
|
+
id: "run-test-001",
|
|
415
|
+
startedAt: new Date().toISOString(),
|
|
416
|
+
coordinatorSessionId: "session-coord-run",
|
|
417
|
+
status: "active",
|
|
418
|
+
});
|
|
419
|
+
runStore.close();
|
|
420
|
+
|
|
421
|
+
// Write current-run.txt
|
|
422
|
+
const currentRunPath = join(tempDir, ".overstory", "current-run.txt");
|
|
423
|
+
await Bun.write(currentRunPath, "run-test-001");
|
|
424
|
+
|
|
425
|
+
// Verify current-run.txt exists before test
|
|
426
|
+
expect(await Bun.file(currentRunPath).exists()).toBe(true);
|
|
427
|
+
|
|
428
|
+
// Call session-end
|
|
429
|
+
await logCommand(["session-end", "--agent", "coordinator"]);
|
|
430
|
+
|
|
431
|
+
// Verify: run status is "completed" in RunStore
|
|
432
|
+
const runStoreRead = createRunStore(dbPath);
|
|
433
|
+
const run = runStoreRead.getRun("run-test-001");
|
|
434
|
+
runStoreRead.close();
|
|
435
|
+
|
|
436
|
+
expect(run).toBeDefined();
|
|
437
|
+
expect(run?.status).toBe("completed");
|
|
438
|
+
expect(run?.completedAt).toBeTruthy();
|
|
439
|
+
|
|
440
|
+
// Verify: current-run.txt is deleted (create fresh file reference)
|
|
441
|
+
expect(await Bun.file(currentRunPath).exists()).toBe(false);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("session-end does not fail when no active run for coordinator", async () => {
|
|
445
|
+
// Create a coordinator session but no current-run.txt
|
|
446
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
447
|
+
const sessionStoreLocal = createSessionStore(dbPath);
|
|
448
|
+
sessionStoreLocal.upsert({
|
|
449
|
+
id: "session-coord-no-run",
|
|
450
|
+
agentName: "coordinator-no-run",
|
|
451
|
+
capability: "coordinator",
|
|
452
|
+
worktreePath: tempDir,
|
|
453
|
+
branchName: "main",
|
|
454
|
+
beadId: "",
|
|
455
|
+
tmuxSession: "overstory-coordinator-no-run",
|
|
456
|
+
state: "working",
|
|
457
|
+
pid: 11112,
|
|
458
|
+
parentAgent: null,
|
|
459
|
+
depth: 0,
|
|
460
|
+
runId: null,
|
|
461
|
+
startedAt: new Date().toISOString(),
|
|
462
|
+
lastActivity: new Date().toISOString(),
|
|
463
|
+
escalationLevel: 0,
|
|
464
|
+
stalledSince: null,
|
|
465
|
+
});
|
|
466
|
+
sessionStoreLocal.close();
|
|
467
|
+
|
|
468
|
+
// Call session-end (should not throw)
|
|
469
|
+
await expect(async () => {
|
|
470
|
+
await logCommand(["session-end", "--agent", "coordinator-no-run"]);
|
|
471
|
+
}).not.toThrow();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("session-end does not complete run for non-coordinator agents", async () => {
|
|
475
|
+
// Create a builder session, create a run, write current-run.txt
|
|
476
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
477
|
+
const sessionStoreLocal = createSessionStore(dbPath);
|
|
478
|
+
sessionStoreLocal.upsert({
|
|
479
|
+
id: "session-builder-run",
|
|
480
|
+
agentName: "test-builder",
|
|
481
|
+
capability: "builder",
|
|
482
|
+
worktreePath: tempDir,
|
|
483
|
+
branchName: "builder-branch",
|
|
484
|
+
beadId: "bead-builder-001",
|
|
485
|
+
tmuxSession: "overstory-builder",
|
|
486
|
+
state: "working",
|
|
487
|
+
pid: 11113,
|
|
488
|
+
parentAgent: null,
|
|
489
|
+
depth: 2,
|
|
490
|
+
runId: "run-test-002",
|
|
491
|
+
startedAt: new Date().toISOString(),
|
|
492
|
+
lastActivity: new Date().toISOString(),
|
|
493
|
+
escalationLevel: 0,
|
|
494
|
+
stalledSince: null,
|
|
495
|
+
});
|
|
496
|
+
sessionStoreLocal.close();
|
|
497
|
+
|
|
498
|
+
// Create the run
|
|
499
|
+
const runStore = createRunStore(dbPath);
|
|
500
|
+
runStore.createRun({
|
|
501
|
+
id: "run-test-002",
|
|
502
|
+
startedAt: new Date().toISOString(),
|
|
503
|
+
coordinatorSessionId: "session-coord-run",
|
|
504
|
+
status: "active",
|
|
505
|
+
});
|
|
506
|
+
runStore.close();
|
|
507
|
+
|
|
508
|
+
// Write current-run.txt
|
|
509
|
+
await Bun.write(join(tempDir, ".overstory", "current-run.txt"), "run-test-002");
|
|
510
|
+
|
|
511
|
+
// Call session-end for builder
|
|
512
|
+
await logCommand(["session-end", "--agent", "test-builder"]);
|
|
513
|
+
|
|
514
|
+
// Verify: run status remains "active"
|
|
515
|
+
const runStoreRead = createRunStore(dbPath);
|
|
516
|
+
const run = runStoreRead.getRun("run-test-002");
|
|
517
|
+
runStoreRead.close();
|
|
518
|
+
|
|
519
|
+
expect(run).toBeDefined();
|
|
520
|
+
expect(run?.status).toBe("active");
|
|
521
|
+
expect(run?.completedAt).toBeNull();
|
|
522
|
+
|
|
523
|
+
// Verify: current-run.txt still exists
|
|
524
|
+
const currentRunFile = Bun.file(join(tempDir, ".overstory", "current-run.txt"));
|
|
525
|
+
expect(await currentRunFile.exists()).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("session-end handles already-completed run gracefully", async () => {
|
|
529
|
+
// Create a coordinator session, create a run that is already completed
|
|
530
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
531
|
+
const sessionStoreLocal = createSessionStore(dbPath);
|
|
532
|
+
sessionStoreLocal.upsert({
|
|
533
|
+
id: "session-coord-completed",
|
|
534
|
+
agentName: "coordinator-completed",
|
|
535
|
+
capability: "coordinator",
|
|
536
|
+
worktreePath: tempDir,
|
|
537
|
+
branchName: "main",
|
|
538
|
+
beadId: "",
|
|
539
|
+
tmuxSession: "overstory-coordinator-completed",
|
|
540
|
+
state: "working",
|
|
541
|
+
pid: 11114,
|
|
542
|
+
parentAgent: null,
|
|
543
|
+
depth: 0,
|
|
544
|
+
runId: "run-test-003",
|
|
545
|
+
startedAt: new Date().toISOString(),
|
|
546
|
+
lastActivity: new Date().toISOString(),
|
|
547
|
+
escalationLevel: 0,
|
|
548
|
+
stalledSince: null,
|
|
549
|
+
});
|
|
550
|
+
sessionStoreLocal.close();
|
|
551
|
+
|
|
552
|
+
// Create the run already completed
|
|
553
|
+
const runStore = createRunStore(dbPath);
|
|
554
|
+
runStore.createRun({
|
|
555
|
+
id: "run-test-003",
|
|
556
|
+
startedAt: new Date().toISOString(),
|
|
557
|
+
coordinatorSessionId: "session-coord-completed",
|
|
558
|
+
status: "active",
|
|
559
|
+
});
|
|
560
|
+
// Complete it immediately
|
|
561
|
+
runStore.completeRun("run-test-003", "completed");
|
|
562
|
+
runStore.close();
|
|
563
|
+
|
|
564
|
+
// Write current-run.txt
|
|
565
|
+
await Bun.write(join(tempDir, ".overstory", "current-run.txt"), "run-test-003");
|
|
566
|
+
|
|
567
|
+
// Call session-end (should not throw — completeRun is idempotent)
|
|
568
|
+
await expect(async () => {
|
|
569
|
+
await logCommand(["session-end", "--agent", "coordinator-completed"]);
|
|
570
|
+
}).not.toThrow();
|
|
571
|
+
|
|
572
|
+
// Verify: run is still completed
|
|
573
|
+
const runStoreRead = createRunStore(dbPath);
|
|
574
|
+
const run = runStoreRead.getRun("run-test-003");
|
|
575
|
+
runStoreRead.close();
|
|
576
|
+
|
|
577
|
+
expect(run).toBeDefined();
|
|
578
|
+
expect(run?.status).toBe("completed");
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("session-end writes pending-nudge marker for coordinator when lead completes", async () => {
|
|
583
|
+
// Create sessions.db with a lead agent
|
|
584
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
585
|
+
const session: AgentSession = {
|
|
586
|
+
id: "session-lead",
|
|
587
|
+
agentName: "lead-alpha",
|
|
588
|
+
capability: "lead",
|
|
589
|
+
worktreePath: tempDir,
|
|
590
|
+
branchName: "lead-alpha-branch",
|
|
591
|
+
beadId: "bead-lead-001",
|
|
592
|
+
tmuxSession: "overstory-lead-alpha",
|
|
593
|
+
state: "working",
|
|
594
|
+
pid: 33333,
|
|
595
|
+
parentAgent: null,
|
|
596
|
+
depth: 0,
|
|
597
|
+
runId: null,
|
|
598
|
+
startedAt: new Date().toISOString(),
|
|
599
|
+
lastActivity: new Date().toISOString(),
|
|
600
|
+
escalationLevel: 0,
|
|
601
|
+
stalledSince: null,
|
|
602
|
+
};
|
|
603
|
+
const store = createSessionStore(dbPath);
|
|
604
|
+
store.upsert(session);
|
|
605
|
+
store.close();
|
|
606
|
+
|
|
607
|
+
await logCommand(["session-end", "--agent", "lead-alpha"]);
|
|
608
|
+
|
|
609
|
+
// Verify the pending-nudge marker was written for the coordinator
|
|
610
|
+
const markerPath = join(tempDir, ".overstory", "pending-nudges", "coordinator.json");
|
|
611
|
+
const markerFile = Bun.file(markerPath);
|
|
612
|
+
expect(await markerFile.exists()).toBe(true);
|
|
613
|
+
|
|
614
|
+
const marker = JSON.parse(await markerFile.text());
|
|
615
|
+
expect(marker.from).toBe("lead-alpha");
|
|
616
|
+
expect(marker.reason).toBe("lead_completed");
|
|
617
|
+
expect(marker.subject).toContain("lead-alpha");
|
|
618
|
+
expect(marker.messageId).toContain("auto-nudge-lead-alpha-");
|
|
619
|
+
expect(marker.createdAt).toBeDefined();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("session-end does NOT write pending-nudge marker for non-lead agents", async () => {
|
|
623
|
+
// Create sessions.db with a builder agent (not a lead)
|
|
624
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
625
|
+
const session: AgentSession = {
|
|
626
|
+
id: "session-builder",
|
|
627
|
+
agentName: "builder-beta",
|
|
628
|
+
capability: "builder",
|
|
629
|
+
worktreePath: tempDir,
|
|
630
|
+
branchName: "builder-beta-branch",
|
|
631
|
+
beadId: "bead-builder-001",
|
|
632
|
+
tmuxSession: "overstory-builder-beta",
|
|
633
|
+
state: "working",
|
|
634
|
+
pid: 44444,
|
|
635
|
+
parentAgent: null,
|
|
636
|
+
depth: 0,
|
|
637
|
+
runId: null,
|
|
638
|
+
startedAt: new Date().toISOString(),
|
|
639
|
+
lastActivity: new Date().toISOString(),
|
|
640
|
+
escalationLevel: 0,
|
|
641
|
+
stalledSince: null,
|
|
642
|
+
};
|
|
643
|
+
const store = createSessionStore(dbPath);
|
|
644
|
+
store.upsert(session);
|
|
645
|
+
store.close();
|
|
646
|
+
|
|
647
|
+
await logCommand(["session-end", "--agent", "builder-beta"]);
|
|
648
|
+
|
|
649
|
+
// Verify no pending-nudge marker was written
|
|
650
|
+
const markerPath = join(tempDir, ".overstory", "pending-nudges", "coordinator.json");
|
|
651
|
+
const markerFile = Bun.file(markerPath);
|
|
652
|
+
expect(await markerFile.exists()).toBe(false);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("session-end does not crash when sessions.db does not exist", async () => {
|
|
656
|
+
// No sessions.db file exists
|
|
657
|
+
// session-end should complete without throwing
|
|
658
|
+
await expect(
|
|
659
|
+
logCommand(["session-end", "--agent", "nonexistent-agent"]),
|
|
660
|
+
).resolves.toBeUndefined();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("tool-start updates lastActivity timestamp in sessions.db", async () => {
|
|
664
|
+
// Create sessions.db with a test agent
|
|
665
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
666
|
+
const oldTimestamp = new Date(Date.now() - 120_000).toISOString(); // 2 minutes ago
|
|
667
|
+
const session: AgentSession = {
|
|
668
|
+
id: "session-003",
|
|
669
|
+
agentName: "activity-agent",
|
|
670
|
+
capability: "builder",
|
|
671
|
+
worktreePath: "/tmp/activity",
|
|
672
|
+
branchName: "activity-branch",
|
|
673
|
+
beadId: "bead-003",
|
|
674
|
+
tmuxSession: "activity-tmux",
|
|
675
|
+
state: "working",
|
|
676
|
+
pid: 99999,
|
|
677
|
+
parentAgent: null,
|
|
678
|
+
depth: 0,
|
|
679
|
+
runId: null,
|
|
680
|
+
startedAt: oldTimestamp,
|
|
681
|
+
lastActivity: oldTimestamp,
|
|
682
|
+
escalationLevel: 0,
|
|
683
|
+
stalledSince: null,
|
|
684
|
+
};
|
|
685
|
+
const store = createSessionStore(dbPath);
|
|
686
|
+
store.upsert(session);
|
|
687
|
+
store.close();
|
|
688
|
+
|
|
689
|
+
await logCommand(["tool-start", "--agent", "activity-agent", "--tool-name", "Glob"]);
|
|
690
|
+
|
|
691
|
+
// Read sessions.db and verify lastActivity was updated
|
|
692
|
+
const readStore = createSessionStore(dbPath);
|
|
693
|
+
const updatedSession = readStore.getByName("activity-agent");
|
|
694
|
+
readStore.close();
|
|
695
|
+
|
|
696
|
+
expect(updatedSession).toBeDefined();
|
|
697
|
+
expect(updatedSession?.lastActivity).not.toBe(oldTimestamp);
|
|
698
|
+
expect(new Date(updatedSession?.lastActivity ?? "").getTime()).toBeGreaterThan(
|
|
699
|
+
new Date(oldTimestamp).getTime(),
|
|
700
|
+
);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("tool-start transitions state from booting to working", async () => {
|
|
704
|
+
// Create sessions.db with agent in 'booting' state
|
|
705
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
706
|
+
const session: AgentSession = {
|
|
707
|
+
id: "session-004",
|
|
708
|
+
agentName: "booting-agent",
|
|
709
|
+
capability: "builder",
|
|
710
|
+
worktreePath: "/tmp/booting",
|
|
711
|
+
branchName: "booting-branch",
|
|
712
|
+
beadId: "bead-004",
|
|
713
|
+
tmuxSession: "booting-tmux",
|
|
714
|
+
state: "booting",
|
|
715
|
+
pid: 11111,
|
|
716
|
+
parentAgent: null,
|
|
717
|
+
depth: 0,
|
|
718
|
+
runId: null,
|
|
719
|
+
startedAt: new Date().toISOString(),
|
|
720
|
+
lastActivity: new Date().toISOString(),
|
|
721
|
+
escalationLevel: 0,
|
|
722
|
+
stalledSince: null,
|
|
723
|
+
};
|
|
724
|
+
const store = createSessionStore(dbPath);
|
|
725
|
+
store.upsert(session);
|
|
726
|
+
store.close();
|
|
727
|
+
|
|
728
|
+
await logCommand(["tool-start", "--agent", "booting-agent", "--tool-name", "Read"]);
|
|
729
|
+
|
|
730
|
+
// Read sessions.db and verify state changed to working
|
|
731
|
+
const readStore = createSessionStore(dbPath);
|
|
732
|
+
const updatedSession = readStore.getByName("booting-agent");
|
|
733
|
+
readStore.close();
|
|
734
|
+
|
|
735
|
+
expect(updatedSession).toBeDefined();
|
|
736
|
+
expect(updatedSession?.state).toBe("working");
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("tool-start defaults to unknown when --tool-name not provided", async () => {
|
|
740
|
+
// Should not throw when --tool-name is missing
|
|
741
|
+
await expect(
|
|
742
|
+
logCommand(["tool-start", "--agent", "default-tool-agent"]),
|
|
743
|
+
).resolves.toBeUndefined();
|
|
744
|
+
|
|
745
|
+
// Verify log was created
|
|
746
|
+
const logsDir = join(tempDir, ".overstory", "logs", "default-tool-agent");
|
|
747
|
+
const markerPath = join(logsDir, ".current-session");
|
|
748
|
+
const markerFile = Bun.file(markerPath);
|
|
749
|
+
|
|
750
|
+
expect(await markerFile.exists()).toBe(true);
|
|
751
|
+
|
|
752
|
+
// Wait for async file writes to complete (logger uses fire-and-forget appendFile)
|
|
753
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
754
|
+
|
|
755
|
+
const sessionDir = (await markerFile.text()).trim();
|
|
756
|
+
const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
|
|
757
|
+
const eventsContent = await eventsFile.text();
|
|
758
|
+
|
|
759
|
+
// Should contain "unknown" as the tool name
|
|
760
|
+
expect(eventsContent).toContain("unknown");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("tool-end defaults to unknown when --tool-name not provided", async () => {
|
|
764
|
+
await logCommand(["tool-start", "--agent", "default-end-agent"]);
|
|
765
|
+
|
|
766
|
+
// tool-end without --tool-name should not throw
|
|
767
|
+
await expect(logCommand(["tool-end", "--agent", "default-end-agent"])).resolves.toBeUndefined();
|
|
768
|
+
|
|
769
|
+
// Wait for async file writes to complete
|
|
770
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
771
|
+
|
|
772
|
+
const logsDir = join(tempDir, ".overstory", "logs", "default-end-agent");
|
|
773
|
+
const markerPath = join(logsDir, ".current-session");
|
|
774
|
+
const sessionDir = (await Bun.file(markerPath).text()).trim();
|
|
775
|
+
const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
|
|
776
|
+
const eventsContent = await eventsFile.text();
|
|
777
|
+
|
|
778
|
+
expect(eventsContent).toContain("unknown");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("--help includes --stdin option in output", async () => {
|
|
782
|
+
await logCommand(["--help"]);
|
|
783
|
+
const out = output();
|
|
784
|
+
|
|
785
|
+
expect(out).toContain("--stdin");
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("session-end does not crash when mulch learn/record fails", async () => {
|
|
789
|
+
// Create sessions.db with a builder agent (non-persistent)
|
|
790
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
791
|
+
const session: AgentSession = {
|
|
792
|
+
id: "session-mulch-fail",
|
|
793
|
+
agentName: "mulch-fail-agent",
|
|
794
|
+
capability: "builder",
|
|
795
|
+
worktreePath: tempDir,
|
|
796
|
+
branchName: "mulch-fail-branch",
|
|
797
|
+
beadId: "bead-mulch-001",
|
|
798
|
+
tmuxSession: "overstory-mulch-fail",
|
|
799
|
+
state: "working",
|
|
800
|
+
pid: 55555,
|
|
801
|
+
parentAgent: "parent-agent",
|
|
802
|
+
depth: 1,
|
|
803
|
+
runId: null,
|
|
804
|
+
startedAt: new Date().toISOString(),
|
|
805
|
+
lastActivity: new Date().toISOString(),
|
|
806
|
+
escalationLevel: 0,
|
|
807
|
+
stalledSince: null,
|
|
808
|
+
};
|
|
809
|
+
const store = createSessionStore(dbPath);
|
|
810
|
+
store.upsert(session);
|
|
811
|
+
store.close();
|
|
812
|
+
|
|
813
|
+
// session-end should complete without throwing even if mulch learn/record fails
|
|
814
|
+
await expect(
|
|
815
|
+
logCommand(["session-end", "--agent", "mulch-fail-agent"]),
|
|
816
|
+
).resolves.toBeUndefined();
|
|
817
|
+
|
|
818
|
+
// Verify state transitioned to completed
|
|
819
|
+
const readStore = createSessionStore(dbPath);
|
|
820
|
+
const updatedSession = readStore.getByName("mulch-fail-agent");
|
|
821
|
+
readStore.close();
|
|
822
|
+
|
|
823
|
+
expect(updatedSession).toBeDefined();
|
|
824
|
+
expect(updatedSession?.state).toBe("completed");
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("session-end skips mulch auto-record for coordinator (persistent agent)", async () => {
|
|
828
|
+
// Create sessions.db with a coordinator agent
|
|
829
|
+
const dbPath = join(tempDir, ".overstory", "sessions.db");
|
|
830
|
+
const session: AgentSession = {
|
|
831
|
+
id: "session-coord-mulch",
|
|
832
|
+
agentName: "coordinator-mulch",
|
|
833
|
+
capability: "coordinator",
|
|
834
|
+
worktreePath: tempDir,
|
|
835
|
+
branchName: "main",
|
|
836
|
+
beadId: "",
|
|
837
|
+
tmuxSession: "overstory-coordinator-mulch",
|
|
838
|
+
state: "working",
|
|
839
|
+
pid: 66666,
|
|
840
|
+
parentAgent: null,
|
|
841
|
+
depth: 0,
|
|
842
|
+
runId: null,
|
|
843
|
+
startedAt: new Date().toISOString(),
|
|
844
|
+
lastActivity: new Date().toISOString(),
|
|
845
|
+
escalationLevel: 0,
|
|
846
|
+
stalledSince: null,
|
|
847
|
+
};
|
|
848
|
+
const store = createSessionStore(dbPath);
|
|
849
|
+
store.upsert(session);
|
|
850
|
+
store.close();
|
|
851
|
+
|
|
852
|
+
await logCommand(["session-end", "--agent", "coordinator-mulch"]);
|
|
853
|
+
|
|
854
|
+
// Verify no mail.db was created (mulch auto-record was skipped)
|
|
855
|
+
const mailDbPath = join(tempDir, ".overstory", "mail.db");
|
|
856
|
+
const mailDbFile = Bun.file(mailDbPath);
|
|
857
|
+
expect(await mailDbFile.exists()).toBe(false);
|
|
858
|
+
|
|
859
|
+
// Coordinator should remain working (persistent agent)
|
|
860
|
+
const readStore = createSessionStore(dbPath);
|
|
861
|
+
const updatedSession = readStore.getByName("coordinator-mulch");
|
|
862
|
+
readStore.close();
|
|
863
|
+
|
|
864
|
+
expect(updatedSession).toBeDefined();
|
|
865
|
+
expect(updatedSession?.state).toBe("working");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("autoRecordExpertise calls record for each suggested domain", async () => {
|
|
869
|
+
const learnResult: MulchLearnResult = {
|
|
870
|
+
success: true,
|
|
871
|
+
command: "mulch learn",
|
|
872
|
+
changedFiles: ["src/foo.ts", "src/bar.ts"],
|
|
873
|
+
suggestedDomains: ["typescript", "cli"],
|
|
874
|
+
unmatchedFiles: [],
|
|
875
|
+
};
|
|
876
|
+
const { client, recordCalls } = createFakeMulchClient(learnResult);
|
|
877
|
+
const mailDbPath = join(tempDir, ".overstory", "auto-record-mail.db");
|
|
878
|
+
|
|
879
|
+
const result = await autoRecordExpertise({
|
|
880
|
+
mulchClient: client,
|
|
881
|
+
agentName: "test-builder",
|
|
882
|
+
capability: "builder",
|
|
883
|
+
beadId: "bead-123",
|
|
884
|
+
mailDbPath,
|
|
885
|
+
parentAgent: "parent-lead",
|
|
886
|
+
projectRoot: tempDir,
|
|
887
|
+
sessionStartedAt: new Date().toISOString(),
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
expect(result).toEqual(["typescript", "cli"]);
|
|
891
|
+
expect(recordCalls).toHaveLength(2);
|
|
892
|
+
expect(recordCalls[0]?.domain).toBe("typescript");
|
|
893
|
+
expect(recordCalls[0]?.options).toMatchObject({
|
|
894
|
+
type: "reference",
|
|
895
|
+
tags: ["auto-session-end", "builder"],
|
|
896
|
+
evidenceBead: "bead-123",
|
|
897
|
+
});
|
|
898
|
+
expect(recordCalls[1]?.domain).toBe("cli");
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("autoRecordExpertise sends mail with auto-recorded subject", async () => {
|
|
902
|
+
const learnResult: MulchLearnResult = {
|
|
903
|
+
success: true,
|
|
904
|
+
command: "mulch learn",
|
|
905
|
+
changedFiles: ["src/foo.ts"],
|
|
906
|
+
suggestedDomains: ["typescript"],
|
|
907
|
+
unmatchedFiles: [],
|
|
908
|
+
};
|
|
909
|
+
const { client } = createFakeMulchClient(learnResult);
|
|
910
|
+
const mailDbPath = join(tempDir, ".overstory", "auto-record-mail2.db");
|
|
911
|
+
|
|
912
|
+
await autoRecordExpertise({
|
|
913
|
+
mulchClient: client,
|
|
914
|
+
agentName: "test-builder",
|
|
915
|
+
capability: "builder",
|
|
916
|
+
beadId: "bead-456",
|
|
917
|
+
mailDbPath,
|
|
918
|
+
parentAgent: "parent-lead",
|
|
919
|
+
projectRoot: tempDir,
|
|
920
|
+
sessionStartedAt: new Date().toISOString(),
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
const mailStore = createMailStore(mailDbPath);
|
|
924
|
+
const mailClient = createMailClient(mailStore);
|
|
925
|
+
const messages = mailClient.list({ to: "parent-lead" });
|
|
926
|
+
mailClient.close();
|
|
927
|
+
|
|
928
|
+
expect(messages).toHaveLength(1);
|
|
929
|
+
expect(messages[0]?.subject).toBe("mulch: auto-recorded insights in typescript");
|
|
930
|
+
expect(messages[0]?.body).toContain("Auto-recorded expertise in: typescript");
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("autoRecordExpertise continues when individual record calls fail", async () => {
|
|
934
|
+
const learnResult: MulchLearnResult = {
|
|
935
|
+
success: true,
|
|
936
|
+
command: "mulch learn",
|
|
937
|
+
changedFiles: ["src/foo.ts"],
|
|
938
|
+
suggestedDomains: ["typescript", "cli"],
|
|
939
|
+
unmatchedFiles: [],
|
|
940
|
+
};
|
|
941
|
+
const { client } = createFakeMulchClient(learnResult, { recordShouldFail: true });
|
|
942
|
+
const mailDbPath = join(tempDir, ".overstory", "auto-record-fail.db");
|
|
943
|
+
|
|
944
|
+
const result = await autoRecordExpertise({
|
|
945
|
+
mulchClient: client,
|
|
946
|
+
agentName: "test-builder",
|
|
947
|
+
capability: "builder",
|
|
948
|
+
beadId: null,
|
|
949
|
+
mailDbPath,
|
|
950
|
+
parentAgent: null,
|
|
951
|
+
projectRoot: tempDir,
|
|
952
|
+
sessionStartedAt: new Date().toISOString(),
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// All records failed, so no domains recorded and no mail sent
|
|
956
|
+
expect(result).toEqual([]);
|
|
957
|
+
const mailFile = Bun.file(mailDbPath);
|
|
958
|
+
expect(await mailFile.exists()).toBe(false);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test("autoRecordExpertise returns empty when no domains suggested", async () => {
|
|
962
|
+
const learnResult: MulchLearnResult = {
|
|
963
|
+
success: true,
|
|
964
|
+
command: "mulch learn",
|
|
965
|
+
changedFiles: ["src/foo.ts"],
|
|
966
|
+
suggestedDomains: [],
|
|
967
|
+
unmatchedFiles: [],
|
|
968
|
+
};
|
|
969
|
+
const { client, recordCalls } = createFakeMulchClient(learnResult);
|
|
970
|
+
const mailDbPath = join(tempDir, ".overstory", "auto-record-empty.db");
|
|
971
|
+
|
|
972
|
+
const result = await autoRecordExpertise({
|
|
973
|
+
mulchClient: client,
|
|
974
|
+
agentName: "test-builder",
|
|
975
|
+
capability: "builder",
|
|
976
|
+
beadId: null,
|
|
977
|
+
mailDbPath,
|
|
978
|
+
parentAgent: null,
|
|
979
|
+
projectRoot: tempDir,
|
|
980
|
+
sessionStartedAt: new Date().toISOString(),
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
expect(result).toEqual([]);
|
|
984
|
+
expect(recordCalls).toHaveLength(0);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("autoRecordExpertise records pattern insights when EventStore has tool data", async () => {
|
|
988
|
+
const learnResult: MulchLearnResult = {
|
|
989
|
+
success: true,
|
|
990
|
+
command: "mulch learn",
|
|
991
|
+
changedFiles: ["src/mail/store.ts"],
|
|
992
|
+
suggestedDomains: ["messaging"],
|
|
993
|
+
unmatchedFiles: [],
|
|
994
|
+
};
|
|
995
|
+
const { client, recordCalls } = createFakeMulchClient(learnResult);
|
|
996
|
+
const mailDbPath = join(tempDir, ".overstory", "insight-analysis-mail.db");
|
|
997
|
+
|
|
998
|
+
// Create EventStore with test data
|
|
999
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1000
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1001
|
+
|
|
1002
|
+
const sessionStartedAt = new Date(Date.now() - 60_000).toISOString(); // 1 minute ago
|
|
1003
|
+
|
|
1004
|
+
// Insert tool events: 15 tool calls total (10+ triggers workflow insight)
|
|
1005
|
+
// Read-heavy: 12 Read, 3 Edit → should classify as read-heavy
|
|
1006
|
+
for (let i = 0; i < 12; i++) {
|
|
1007
|
+
eventStore.insert({
|
|
1008
|
+
runId: null,
|
|
1009
|
+
agentName: "insight-agent",
|
|
1010
|
+
sessionId: "sess-insight",
|
|
1011
|
+
eventType: "tool_start",
|
|
1012
|
+
toolName: "Read",
|
|
1013
|
+
toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
|
|
1014
|
+
toolDurationMs: null,
|
|
1015
|
+
level: "info",
|
|
1016
|
+
data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Add 4 edits to same file → hot file
|
|
1021
|
+
for (let i = 0; i < 4; i++) {
|
|
1022
|
+
eventStore.insert({
|
|
1023
|
+
runId: null,
|
|
1024
|
+
agentName: "insight-agent",
|
|
1025
|
+
sessionId: "sess-insight",
|
|
1026
|
+
eventType: "tool_start",
|
|
1027
|
+
toolName: "Edit",
|
|
1028
|
+
toolArgs: JSON.stringify({ file_path: "src/mail/store.ts" }),
|
|
1029
|
+
toolDurationMs: null,
|
|
1030
|
+
level: "info",
|
|
1031
|
+
data: JSON.stringify({ summary: "edit: src/mail/store.ts" }),
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Add 1 error event → error pattern
|
|
1036
|
+
eventStore.insert({
|
|
1037
|
+
runId: null,
|
|
1038
|
+
agentName: "insight-agent",
|
|
1039
|
+
sessionId: "sess-insight",
|
|
1040
|
+
eventType: "tool_start",
|
|
1041
|
+
toolName: "Bash",
|
|
1042
|
+
toolArgs: JSON.stringify({ command: "bun test" }),
|
|
1043
|
+
toolDurationMs: null,
|
|
1044
|
+
level: "error",
|
|
1045
|
+
data: "Test failed",
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
eventStore.close();
|
|
1049
|
+
|
|
1050
|
+
// Run autoRecordExpertise
|
|
1051
|
+
const result = await autoRecordExpertise({
|
|
1052
|
+
mulchClient: client,
|
|
1053
|
+
agentName: "insight-agent",
|
|
1054
|
+
capability: "builder",
|
|
1055
|
+
beadId: "bead-insight",
|
|
1056
|
+
mailDbPath,
|
|
1057
|
+
parentAgent: "parent-agent",
|
|
1058
|
+
projectRoot: tempDir,
|
|
1059
|
+
sessionStartedAt,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// Verify reference + insights were recorded
|
|
1063
|
+
expect(recordCalls.length).toBeGreaterThanOrEqual(2); // At least reference + 1 insight
|
|
1064
|
+
|
|
1065
|
+
// Verify reference entry
|
|
1066
|
+
const referenceCall = recordCalls.find((c) => c.options.type === "reference");
|
|
1067
|
+
expect(referenceCall).toBeDefined();
|
|
1068
|
+
expect(referenceCall?.domain).toBe("messaging");
|
|
1069
|
+
|
|
1070
|
+
// Verify pattern insights
|
|
1071
|
+
const patternCalls = recordCalls.filter((c) => c.options.type === "pattern");
|
|
1072
|
+
expect(patternCalls.length).toBeGreaterThanOrEqual(2);
|
|
1073
|
+
|
|
1074
|
+
// Verify workflow insight
|
|
1075
|
+
const workflowInsight = patternCalls.find((c) => {
|
|
1076
|
+
const desc = c.options.description;
|
|
1077
|
+
return typeof desc === "string" && desc.includes("read-heavy workflow");
|
|
1078
|
+
});
|
|
1079
|
+
expect(workflowInsight).toBeDefined();
|
|
1080
|
+
|
|
1081
|
+
// Verify hot file insight
|
|
1082
|
+
const hotFileInsight = patternCalls.find((c) => {
|
|
1083
|
+
const desc = c.options.description;
|
|
1084
|
+
return (
|
|
1085
|
+
typeof desc === "string" && desc.includes("src/mail/store.ts") && desc.includes("4 edits")
|
|
1086
|
+
);
|
|
1087
|
+
});
|
|
1088
|
+
expect(hotFileInsight).toBeDefined();
|
|
1089
|
+
expect(hotFileInsight?.domain).toBe("messaging"); // Inferred from src/mail/
|
|
1090
|
+
|
|
1091
|
+
// Verify failure insight
|
|
1092
|
+
const failureCall = recordCalls.find((c) => c.options.type === "failure");
|
|
1093
|
+
expect(failureCall).toBeDefined();
|
|
1094
|
+
|
|
1095
|
+
// Verify recorded domains includes unique domains from insights
|
|
1096
|
+
expect(result).toContain("messaging");
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test("autoRecordExpertise includes insight summary in notification mail", async () => {
|
|
1100
|
+
const learnResult: MulchLearnResult = {
|
|
1101
|
+
success: true,
|
|
1102
|
+
command: "mulch learn",
|
|
1103
|
+
changedFiles: ["src/config.ts"],
|
|
1104
|
+
suggestedDomains: ["typescript"],
|
|
1105
|
+
unmatchedFiles: [],
|
|
1106
|
+
};
|
|
1107
|
+
const { client } = createFakeMulchClient(learnResult);
|
|
1108
|
+
const mailDbPath = join(tempDir, ".overstory", "insight-mail-summary.db");
|
|
1109
|
+
|
|
1110
|
+
// Create EventStore with 10+ tool calls to trigger workflow insight
|
|
1111
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1112
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1113
|
+
const sessionStartedAt = new Date(Date.now() - 60_000).toISOString();
|
|
1114
|
+
|
|
1115
|
+
for (let i = 0; i < 10; i++) {
|
|
1116
|
+
eventStore.insert({
|
|
1117
|
+
runId: null,
|
|
1118
|
+
agentName: "mail-insight-agent",
|
|
1119
|
+
sessionId: "sess-mail",
|
|
1120
|
+
eventType: "tool_start",
|
|
1121
|
+
toolName: "Read",
|
|
1122
|
+
toolArgs: JSON.stringify({ file_path: `/src/file${i}.ts` }),
|
|
1123
|
+
toolDurationMs: null,
|
|
1124
|
+
level: "info",
|
|
1125
|
+
data: JSON.stringify({ summary: `read: /src/file${i}.ts` }),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
eventStore.close();
|
|
1130
|
+
|
|
1131
|
+
await autoRecordExpertise({
|
|
1132
|
+
mulchClient: client,
|
|
1133
|
+
agentName: "mail-insight-agent",
|
|
1134
|
+
capability: "scout",
|
|
1135
|
+
beadId: "bead-mail",
|
|
1136
|
+
mailDbPath,
|
|
1137
|
+
parentAgent: "parent-agent",
|
|
1138
|
+
projectRoot: tempDir,
|
|
1139
|
+
sessionStartedAt,
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Verify mail was sent with insight summary
|
|
1143
|
+
const mailStore = createMailStore(mailDbPath);
|
|
1144
|
+
const mailClient = createMailClient(mailStore);
|
|
1145
|
+
const messages = mailClient.list({ to: "parent-agent" });
|
|
1146
|
+
mailClient.close();
|
|
1147
|
+
|
|
1148
|
+
expect(messages).toHaveLength(1);
|
|
1149
|
+
const mail = messages[0];
|
|
1150
|
+
expect(mail?.body).toContain("Auto-insights:");
|
|
1151
|
+
expect(mail?.body).toContain("10 tool calls");
|
|
1152
|
+
expect(mail?.body).toContain("pattern"); // At least 1 pattern insight
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Tests for `overstory log` with --stdin flag.
|
|
1158
|
+
*
|
|
1159
|
+
* Uses Bun.spawn to invoke the log command as a subprocess with piped stdin,
|
|
1160
|
+
* because Bun.stdin.stream() cannot be injected in-process.
|
|
1161
|
+
* Real filesystem + real SQLite for EventStore verification.
|
|
1162
|
+
*/
|
|
1163
|
+
describe("logCommand --stdin integration", () => {
|
|
1164
|
+
let tempDir: string;
|
|
1165
|
+
|
|
1166
|
+
beforeEach(async () => {
|
|
1167
|
+
tempDir = await mkdtemp(join(tmpdir(), "log-stdin-test-"));
|
|
1168
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
1169
|
+
await Bun.write(
|
|
1170
|
+
join(overstoryDir, "config.yaml"),
|
|
1171
|
+
`project:\n name: test\n root: ${tempDir}\n canonicalBranch: main\n`,
|
|
1172
|
+
);
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
afterEach(async () => {
|
|
1176
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Helper: run `overstory log` as a subprocess with stdin piped.
|
|
1181
|
+
* Uses bun to run the CLI entry point directly.
|
|
1182
|
+
*/
|
|
1183
|
+
async function runLogWithStdin(
|
|
1184
|
+
event: string,
|
|
1185
|
+
agentName: string,
|
|
1186
|
+
stdinJson: Record<string, unknown>,
|
|
1187
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
1188
|
+
// Inline script that calls logCommand with --stdin and reads from stdin
|
|
1189
|
+
const scriptPath = join(tempDir, "_run-log.ts");
|
|
1190
|
+
const scriptContent = `
|
|
1191
|
+
import { logCommand } from "${join(import.meta.dir, "log.ts").replace(/\\/g, "/")}";
|
|
1192
|
+
const args = process.argv.slice(2);
|
|
1193
|
+
try {
|
|
1194
|
+
await logCommand(args);
|
|
1195
|
+
} catch (e) {
|
|
1196
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
`;
|
|
1200
|
+
await Bun.write(scriptPath, scriptContent);
|
|
1201
|
+
|
|
1202
|
+
const proc = Bun.spawn(["bun", "run", scriptPath, event, "--agent", agentName, "--stdin"], {
|
|
1203
|
+
cwd: tempDir,
|
|
1204
|
+
stdin: "pipe",
|
|
1205
|
+
stdout: "pipe",
|
|
1206
|
+
stderr: "pipe",
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// Write the JSON payload to stdin and close
|
|
1210
|
+
proc.stdin.write(JSON.stringify(stdinJson));
|
|
1211
|
+
proc.stdin.end();
|
|
1212
|
+
|
|
1213
|
+
const exitCode = await proc.exited;
|
|
1214
|
+
const stdout = await new Response(proc.stdout).text();
|
|
1215
|
+
const stderr = await new Response(proc.stderr).text();
|
|
1216
|
+
|
|
1217
|
+
return { exitCode, stdout, stderr };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
test("tool-start with --stdin writes to EventStore", async () => {
|
|
1221
|
+
const payload = {
|
|
1222
|
+
tool_name: "Read",
|
|
1223
|
+
tool_input: { file_path: "/src/index.ts" },
|
|
1224
|
+
session_id: "sess-test-001",
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
const result = await runLogWithStdin("tool-start", "stdin-builder", payload);
|
|
1228
|
+
expect(result.exitCode).toBe(0);
|
|
1229
|
+
|
|
1230
|
+
// Verify EventStore has the event
|
|
1231
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1232
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1233
|
+
const events = eventStore.getByAgent("stdin-builder");
|
|
1234
|
+
eventStore.close();
|
|
1235
|
+
|
|
1236
|
+
expect(events).toHaveLength(1);
|
|
1237
|
+
const event = events[0] as StoredEvent;
|
|
1238
|
+
expect(event.eventType).toBe("tool_start");
|
|
1239
|
+
expect(event.toolName).toBe("Read");
|
|
1240
|
+
expect(event.sessionId).toBe("sess-test-001");
|
|
1241
|
+
expect(event.agentName).toBe("stdin-builder");
|
|
1242
|
+
|
|
1243
|
+
// Verify filtered tool args were stored
|
|
1244
|
+
const toolArgs = JSON.parse(event.toolArgs ?? "{}");
|
|
1245
|
+
expect(toolArgs.file_path).toBe("/src/index.ts");
|
|
1246
|
+
|
|
1247
|
+
// Verify summary in data
|
|
1248
|
+
const data = JSON.parse(event.data ?? "{}");
|
|
1249
|
+
expect(data.summary).toBe("read: /src/index.ts");
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test("tool-end with --stdin writes to EventStore and correlates with tool-start", async () => {
|
|
1253
|
+
// First create a tool-start event
|
|
1254
|
+
const startPayload = {
|
|
1255
|
+
tool_name: "Bash",
|
|
1256
|
+
tool_input: { command: "bun test" },
|
|
1257
|
+
session_id: "sess-test-002",
|
|
1258
|
+
};
|
|
1259
|
+
const startResult = await runLogWithStdin("tool-start", "correlate-agent", startPayload);
|
|
1260
|
+
expect(startResult.exitCode).toBe(0);
|
|
1261
|
+
|
|
1262
|
+
// Small delay to ensure measurable duration
|
|
1263
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1264
|
+
|
|
1265
|
+
// Now send tool-end
|
|
1266
|
+
const endPayload = {
|
|
1267
|
+
tool_name: "Bash",
|
|
1268
|
+
tool_input: { command: "bun test" },
|
|
1269
|
+
session_id: "sess-test-002",
|
|
1270
|
+
};
|
|
1271
|
+
const endResult = await runLogWithStdin("tool-end", "correlate-agent", endPayload);
|
|
1272
|
+
expect(endResult.exitCode).toBe(0);
|
|
1273
|
+
|
|
1274
|
+
// Verify EventStore has both events
|
|
1275
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1276
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1277
|
+
const events = eventStore.getByAgent("correlate-agent");
|
|
1278
|
+
eventStore.close();
|
|
1279
|
+
|
|
1280
|
+
expect(events).toHaveLength(2);
|
|
1281
|
+
|
|
1282
|
+
const startEvent = events.find((e) => e.eventType === "tool_start");
|
|
1283
|
+
const endEvent = events.find((e) => e.eventType === "tool_end");
|
|
1284
|
+
expect(startEvent).toBeDefined();
|
|
1285
|
+
expect(endEvent).toBeDefined();
|
|
1286
|
+
|
|
1287
|
+
// The start event should have tool_duration_ms set by correlateToolEnd()
|
|
1288
|
+
// (value may be affected by SQLite timestamp vs Date.now() timezone behavior,
|
|
1289
|
+
// so we only assert it was populated — not the exact value)
|
|
1290
|
+
expect(startEvent?.toolDurationMs).not.toBeNull();
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("tool-start with --stdin filters large tool_input", async () => {
|
|
1294
|
+
const payload = {
|
|
1295
|
+
tool_name: "Write",
|
|
1296
|
+
tool_input: {
|
|
1297
|
+
file_path: "/src/new-file.ts",
|
|
1298
|
+
content: "x".repeat(50_000), // 50KB of content — should be dropped
|
|
1299
|
+
},
|
|
1300
|
+
session_id: "sess-test-003",
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
const result = await runLogWithStdin("tool-start", "filter-agent", payload);
|
|
1304
|
+
expect(result.exitCode).toBe(0);
|
|
1305
|
+
|
|
1306
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1307
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1308
|
+
const events = eventStore.getByAgent("filter-agent");
|
|
1309
|
+
eventStore.close();
|
|
1310
|
+
|
|
1311
|
+
expect(events).toHaveLength(1);
|
|
1312
|
+
const event = events[0] as StoredEvent;
|
|
1313
|
+
|
|
1314
|
+
// The Write filter keeps file_path but drops content
|
|
1315
|
+
const toolArgs = JSON.parse(event.toolArgs ?? "{}");
|
|
1316
|
+
expect(toolArgs.file_path).toBe("/src/new-file.ts");
|
|
1317
|
+
expect(toolArgs).not.toHaveProperty("content");
|
|
1318
|
+
|
|
1319
|
+
// Verify summary
|
|
1320
|
+
const data = JSON.parse(event.data ?? "{}");
|
|
1321
|
+
expect(data.summary).toBe("write: /src/new-file.ts");
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
test("session-end with --stdin writes to EventStore with transcript_path", async () => {
|
|
1325
|
+
const payload = {
|
|
1326
|
+
session_id: "sess-test-004",
|
|
1327
|
+
transcript_path: "/tmp/transcript.jsonl",
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
const result = await runLogWithStdin("session-end", "session-end-agent", payload);
|
|
1331
|
+
expect(result.exitCode).toBe(0);
|
|
1332
|
+
|
|
1333
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1334
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1335
|
+
const events = eventStore.getByAgent("session-end-agent");
|
|
1336
|
+
eventStore.close();
|
|
1337
|
+
|
|
1338
|
+
expect(events).toHaveLength(1);
|
|
1339
|
+
const event = events[0] as StoredEvent;
|
|
1340
|
+
expect(event.eventType).toBe("session_end");
|
|
1341
|
+
expect(event.sessionId).toBe("sess-test-004");
|
|
1342
|
+
|
|
1343
|
+
// Verify transcript path stored in data
|
|
1344
|
+
const data = JSON.parse(event.data ?? "{}");
|
|
1345
|
+
expect(data.transcriptPath).toBe("/tmp/transcript.jsonl");
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
test("tool-start with --stdin still writes to legacy log files", async () => {
|
|
1349
|
+
const payload = {
|
|
1350
|
+
tool_name: "Grep",
|
|
1351
|
+
tool_input: { pattern: "TODO", path: "/src" },
|
|
1352
|
+
session_id: "sess-test-005",
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
const result = await runLogWithStdin("tool-start", "legacy-compat-agent", payload);
|
|
1356
|
+
expect(result.exitCode).toBe(0);
|
|
1357
|
+
|
|
1358
|
+
// Wait for async file writes to complete
|
|
1359
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1360
|
+
|
|
1361
|
+
// Verify legacy log files exist
|
|
1362
|
+
const logsDir = join(tempDir, ".overstory", "logs", "legacy-compat-agent");
|
|
1363
|
+
const markerPath = join(logsDir, ".current-session");
|
|
1364
|
+
const markerFile = Bun.file(markerPath);
|
|
1365
|
+
expect(await markerFile.exists()).toBe(true);
|
|
1366
|
+
|
|
1367
|
+
const sessionDir = (await markerFile.text()).trim();
|
|
1368
|
+
const eventsFile = Bun.file(join(sessionDir, "events.ndjson"));
|
|
1369
|
+
expect(await eventsFile.exists()).toBe(true);
|
|
1370
|
+
|
|
1371
|
+
const eventsContent = await eventsFile.text();
|
|
1372
|
+
expect(eventsContent).toContain("tool.start");
|
|
1373
|
+
expect(eventsContent).toContain("Grep");
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
test("tool-start with --stdin handles empty stdin gracefully", async () => {
|
|
1377
|
+
// Send empty JSON object — should still work (falls back to "unknown" tool name)
|
|
1378
|
+
const scriptPath = join(tempDir, "_run-log-empty.ts");
|
|
1379
|
+
const scriptContent = `
|
|
1380
|
+
import { logCommand } from "${join(import.meta.dir, "log.ts").replace(/\\/g, "/")}";
|
|
1381
|
+
try {
|
|
1382
|
+
await logCommand(["tool-start", "--agent", "empty-stdin-agent", "--stdin"]);
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
1385
|
+
process.exit(1);
|
|
1386
|
+
}
|
|
1387
|
+
`;
|
|
1388
|
+
await Bun.write(scriptPath, scriptContent);
|
|
1389
|
+
|
|
1390
|
+
const proc = Bun.spawn(["bun", "run", scriptPath], {
|
|
1391
|
+
cwd: tempDir,
|
|
1392
|
+
stdin: "pipe",
|
|
1393
|
+
stdout: "pipe",
|
|
1394
|
+
stderr: "pipe",
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// Write empty string and close immediately
|
|
1398
|
+
proc.stdin.end();
|
|
1399
|
+
|
|
1400
|
+
const exitCode = await proc.exited;
|
|
1401
|
+
expect(exitCode).toBe(0);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
test("tool-start with --stdin and unknown tool name uses fallback filter", async () => {
|
|
1405
|
+
const payload = {
|
|
1406
|
+
tool_name: "SomeCustomTool",
|
|
1407
|
+
tool_input: { custom_key: "custom_value" },
|
|
1408
|
+
session_id: "sess-test-006",
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
const result = await runLogWithStdin("tool-start", "custom-tool-agent", payload);
|
|
1412
|
+
expect(result.exitCode).toBe(0);
|
|
1413
|
+
|
|
1414
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1415
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1416
|
+
const events = eventStore.getByAgent("custom-tool-agent");
|
|
1417
|
+
eventStore.close();
|
|
1418
|
+
|
|
1419
|
+
expect(events).toHaveLength(1);
|
|
1420
|
+
const event = events[0] as StoredEvent;
|
|
1421
|
+
expect(event.toolName).toBe("SomeCustomTool");
|
|
1422
|
+
|
|
1423
|
+
// Unknown tools get empty args from filterToolArgs
|
|
1424
|
+
const toolArgs = JSON.parse(event.toolArgs ?? "{}");
|
|
1425
|
+
expect(toolArgs).toEqual({});
|
|
1426
|
+
|
|
1427
|
+
const data = JSON.parse(event.data ?? "{}");
|
|
1428
|
+
expect(data.summary).toBe("SomeCustomTool");
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
test("tool-end with --stdin handles large payloads (>64KB)", async () => {
|
|
1432
|
+
const payload = {
|
|
1433
|
+
tool_name: "Bash",
|
|
1434
|
+
tool_input: { command: "cat /some/file" },
|
|
1435
|
+
tool_result: "x".repeat(100_000), // 100KB payload
|
|
1436
|
+
session_id: "sess-large-payload",
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
const result = await runLogWithStdin("tool-end", "large-payload-agent", payload);
|
|
1440
|
+
expect(result.exitCode).toBe(0);
|
|
1441
|
+
|
|
1442
|
+
// Verify EventStore received the event with correct tool name
|
|
1443
|
+
const eventsDbPath = join(tempDir, ".overstory", "events.db");
|
|
1444
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
1445
|
+
const events = eventStore.getByAgent("large-payload-agent");
|
|
1446
|
+
eventStore.close();
|
|
1447
|
+
|
|
1448
|
+
expect(events).toHaveLength(1);
|
|
1449
|
+
const event = events[0] as StoredEvent;
|
|
1450
|
+
expect(event.eventType).toBe("tool_end");
|
|
1451
|
+
expect(event.toolName).toBe("Bash");
|
|
1452
|
+
// tool_result is not stored in EventStore (filtered out), but tool_name was parsed correctly
|
|
1453
|
+
});
|
|
1454
|
+
});
|