chapterhouse 0.3.23 → 0.3.25
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/dist/api/server.js +52 -15
- package/dist/api/server.test.js +67 -94
- package/dist/api/worker-events-sse.integration.test.js +241 -1
- package/dist/copilot/orchestrator.js +2 -0
- package/dist/copilot/task-event-log.js +6 -1
- package/dist/copilot/task-event-log.test.js +45 -0
- package/dist/copilot/tools.js +39 -4
- package/dist/store/db.js +53 -7
- package/dist/store/db.test.js +57 -0
- package/dist/wiki/project-registry.js +74 -61
- package/dist/wiki/project-registry.test.js +96 -48
- package/package.json +1 -1
- package/web/dist/assets/{index-BK-hInnO.js → index-BRPJa1DK.js} +83 -83
- package/web/dist/assets/{index-BK-hInnO.js.map → index-BRPJa1DK.js.map} +1 -1
- package/web/dist/assets/index-DhY5yWmC.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D__tBB0X.css +0 -10
package/dist/api/server.js
CHANGED
|
@@ -383,14 +383,34 @@ app.get("/api/workers/:taskId/events", (req, res) => {
|
|
|
383
383
|
? parseInt(rawLastId.trim(), 10)
|
|
384
384
|
: undefined;
|
|
385
385
|
const sendEvent = (event) => {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
386
|
+
let payload;
|
|
387
|
+
if (event.kind === "output_delta") {
|
|
388
|
+
payload = {
|
|
389
|
+
type: "output_delta",
|
|
390
|
+
taskId: event.taskId,
|
|
391
|
+
seq: event.seq,
|
|
392
|
+
text: event.text ?? "",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
else if (event.kind === "task_status") {
|
|
396
|
+
payload = {
|
|
397
|
+
type: "task_status",
|
|
398
|
+
taskId: event.taskId,
|
|
399
|
+
seq: event.seq,
|
|
400
|
+
status: event.status ?? "running",
|
|
401
|
+
summary: event.summary,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
payload = {
|
|
406
|
+
taskId: event.taskId,
|
|
407
|
+
seq: event.seq,
|
|
408
|
+
ts: event.ts,
|
|
409
|
+
kind: event.kind,
|
|
410
|
+
toolName: event.toolName,
|
|
411
|
+
summary: event.summary,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
394
414
|
res.write(`id: ${event.seq}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
395
415
|
};
|
|
396
416
|
let replayHighSeq = lastSeq;
|
|
@@ -427,24 +447,41 @@ app.get("/api/workers/:taskId/events", (req, res) => {
|
|
|
427
447
|
const heartbeat = setInterval(() => {
|
|
428
448
|
res.write(`: keep-alive\n\n`);
|
|
429
449
|
}, 15_000);
|
|
430
|
-
|
|
450
|
+
let cleaned = false;
|
|
451
|
+
let unsubscribeTaskLog;
|
|
452
|
+
let unsubscribeDestroyed;
|
|
453
|
+
let unsubscribeError;
|
|
454
|
+
const cleanup = () => {
|
|
455
|
+
if (cleaned)
|
|
456
|
+
return;
|
|
457
|
+
cleaned = true;
|
|
458
|
+
clearInterval(heartbeat);
|
|
459
|
+
unsubscribeTaskLog?.();
|
|
460
|
+
unsubscribeDestroyed?.();
|
|
461
|
+
unsubscribeError?.();
|
|
462
|
+
};
|
|
463
|
+
unsubscribeTaskLog = subscribeTaskLog(taskId, (event) => {
|
|
431
464
|
sendEvent(event);
|
|
465
|
+
// Close SSE when a terminal task_status event arrives
|
|
466
|
+
if (event.kind === "task_status" && event.status && TERMINAL_TASK_STATUSES.has(event.status)) {
|
|
467
|
+
cleanup();
|
|
468
|
+
res.end();
|
|
469
|
+
}
|
|
432
470
|
});
|
|
433
|
-
|
|
471
|
+
unsubscribeDestroyed = agentEventBus.subscribe("session:destroyed", (event) => {
|
|
434
472
|
if (event.sessionId === taskId && isTerminal()) {
|
|
473
|
+
cleanup();
|
|
435
474
|
res.end();
|
|
436
475
|
}
|
|
437
476
|
});
|
|
438
|
-
|
|
477
|
+
unsubscribeError = agentEventBus.subscribe("session:error", (event) => {
|
|
439
478
|
if (event.sessionId === taskId && isTerminal()) {
|
|
479
|
+
cleanup();
|
|
440
480
|
res.end();
|
|
441
481
|
}
|
|
442
482
|
});
|
|
443
483
|
req.on("close", () => {
|
|
444
|
-
|
|
445
|
-
unsubscribeTaskLog();
|
|
446
|
-
unsubscribeDestroyed();
|
|
447
|
-
unsubscribeError();
|
|
484
|
+
cleanup();
|
|
448
485
|
});
|
|
449
486
|
});
|
|
450
487
|
// ---------------------------------------------------------------------------
|
package/dist/api/server.test.js
CHANGED
|
@@ -68,6 +68,47 @@ test("formats named SSE status events", async () => {
|
|
|
68
68
|
assert.equal(sse.formatSseEvent("status", { status: "dreaming", message: "Consolidating memories..." }), 'event: status\ndata: {"status":"dreaming","message":"Consolidating memories..."}\n\n');
|
|
69
69
|
});
|
|
70
70
|
const repoRoot = process.cwd();
|
|
71
|
+
function getProjectDbPath(testRoot) {
|
|
72
|
+
return join(testRoot, ".chapterhouse", "chapterhouse.db");
|
|
73
|
+
}
|
|
74
|
+
function seedProjectRegistry(testRoot, registry) {
|
|
75
|
+
mkdirSync(join(testRoot, ".chapterhouse"), { recursive: true });
|
|
76
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
77
|
+
try {
|
|
78
|
+
db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
80
|
+
slug TEXT PRIMARY KEY,
|
|
81
|
+
cwd TEXT NOT NULL,
|
|
82
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
83
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
84
|
+
)
|
|
85
|
+
`);
|
|
86
|
+
db.prepare("DELETE FROM projects").run();
|
|
87
|
+
const insert = db.prepare(`
|
|
88
|
+
INSERT INTO projects (slug, cwd, created_at, updated_at)
|
|
89
|
+
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
90
|
+
`);
|
|
91
|
+
for (const [slug, cwd] of Object.entries(registry).sort(([left], [right]) => left.localeCompare(right))) {
|
|
92
|
+
insert.run(slug, cwd);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function readProjectRegistryRows(testRoot) {
|
|
100
|
+
const dbPath = getProjectDbPath(testRoot);
|
|
101
|
+
if (!existsSync(dbPath)) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const db = new Database(dbPath, { readonly: true });
|
|
105
|
+
try {
|
|
106
|
+
return db.prepare("SELECT slug, cwd FROM projects ORDER BY slug").all();
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
71
112
|
async function getFreePort() {
|
|
72
113
|
const server = createServer();
|
|
73
114
|
await new Promise((resolve) => {
|
|
@@ -402,19 +443,10 @@ test("server projects route returns an empty list when the registry is missing",
|
|
|
402
443
|
});
|
|
403
444
|
test("server projects route sorts by slug and summarizes rule counts", async () => {
|
|
404
445
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
+ "summary: Canonical project registry.\n"
|
|
410
|
-
+ "updated: 2026-05-12\n"
|
|
411
|
-
+ "---\n\n"
|
|
412
|
-
+ "# Projects\n\n"
|
|
413
|
-
+ "## Project Registry\n\n"
|
|
414
|
-
+ "```yaml\n"
|
|
415
|
-
+ "zeta: /srv/zeta\n"
|
|
416
|
-
+ "alpha: /srv/alpha\n"
|
|
417
|
-
+ "```\n", "utf-8");
|
|
446
|
+
seedProjectRegistry(testRoot, {
|
|
447
|
+
zeta: "/srv/zeta",
|
|
448
|
+
alpha: "/srv/alpha",
|
|
449
|
+
});
|
|
418
450
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
419
451
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
420
452
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -448,18 +480,9 @@ test("server projects route sorts by slug and summarizes rule counts", async ()
|
|
|
448
480
|
});
|
|
449
481
|
test("server project detail route returns cwd plus hard and soft rules", async () => {
|
|
450
482
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
+ "title: Projects\n"
|
|
455
|
-
+ "summary: Canonical project registry.\n"
|
|
456
|
-
+ "updated: 2026-05-12\n"
|
|
457
|
-
+ "---\n\n"
|
|
458
|
-
+ "# Projects\n\n"
|
|
459
|
-
+ "## Project Registry\n\n"
|
|
460
|
-
+ "```yaml\n"
|
|
461
|
-
+ "alpha: /srv/alpha\n"
|
|
462
|
-
+ "```\n", "utf-8");
|
|
483
|
+
seedProjectRegistry(testRoot, {
|
|
484
|
+
alpha: "/srv/alpha",
|
|
485
|
+
});
|
|
463
486
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
464
487
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
465
488
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -509,18 +532,9 @@ test("server project detail route returns 404 for an unknown slug", async () =>
|
|
|
509
532
|
});
|
|
510
533
|
test("server projects create route rejects a duplicate slug", async () => {
|
|
511
534
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
+ "title: Projects\n"
|
|
516
|
-
+ "summary: Canonical project registry.\n"
|
|
517
|
-
+ "updated: 2026-05-12\n"
|
|
518
|
-
+ "---\n\n"
|
|
519
|
-
+ "# Projects\n\n"
|
|
520
|
-
+ "## Project Registry\n\n"
|
|
521
|
-
+ "```yaml\n"
|
|
522
|
-
+ "alpha: /srv/original\n"
|
|
523
|
-
+ "```\n", "utf-8");
|
|
535
|
+
seedProjectRegistry(testRoot, {
|
|
536
|
+
alpha: "/srv/original",
|
|
537
|
+
});
|
|
524
538
|
const response = await fetch(`${baseUrl}/api/projects`, {
|
|
525
539
|
method: "POST",
|
|
526
540
|
headers: {
|
|
@@ -534,33 +548,17 @@ test("server projects create route rejects a duplicate slug", async () => {
|
|
|
534
548
|
});
|
|
535
549
|
assert.equal(response.status, 400);
|
|
536
550
|
assert.deepEqual(await response.json(), { error: "Project 'alpha' already exists" });
|
|
537
|
-
assert.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
+ "updated: 2026-05-12\n"
|
|
541
|
-
+ "---\n\n"
|
|
542
|
-
+ "# Projects\n\n"
|
|
543
|
-
+ "## Project Registry\n\n"
|
|
544
|
-
+ "```yaml\n"
|
|
545
|
-
+ "alpha: /srv/original\n"
|
|
546
|
-
+ "```\n");
|
|
551
|
+
assert.deepEqual(readProjectRegistryRows(testRoot), [
|
|
552
|
+
{ slug: "alpha", cwd: "/srv/original" },
|
|
553
|
+
]);
|
|
547
554
|
}, {}, 60_000);
|
|
548
555
|
});
|
|
549
556
|
test("server projects delete route removes the registry entry and rules page", async () => {
|
|
550
557
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
+ "summary: Canonical project registry.\n"
|
|
556
|
-
+ "updated: 2026-05-12\n"
|
|
557
|
-
+ "---\n\n"
|
|
558
|
-
+ "# Projects\n\n"
|
|
559
|
-
+ "## Project Registry\n\n"
|
|
560
|
-
+ "```yaml\n"
|
|
561
|
-
+ "alpha: /srv/alpha\n"
|
|
562
|
-
+ "beta: /srv/beta\n"
|
|
563
|
-
+ "```\n", "utf-8");
|
|
558
|
+
seedProjectRegistry(testRoot, {
|
|
559
|
+
alpha: "/srv/alpha",
|
|
560
|
+
beta: "/srv/beta",
|
|
561
|
+
});
|
|
564
562
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
565
563
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
566
564
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -574,16 +572,9 @@ test("server projects delete route removes the registry entry and rules page", a
|
|
|
574
572
|
});
|
|
575
573
|
assert.equal(response.status, 200);
|
|
576
574
|
assert.deepEqual(await response.json(), { ok: true, slug: "alpha" });
|
|
577
|
-
assert.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
+ "updated: 2026-05-12\n"
|
|
581
|
-
+ "---\n\n"
|
|
582
|
-
+ "# Projects\n\n"
|
|
583
|
-
+ "## Project Registry\n\n"
|
|
584
|
-
+ "```yaml\n"
|
|
585
|
-
+ "beta: /srv/beta\n"
|
|
586
|
-
+ "```\n");
|
|
575
|
+
assert.deepEqual(readProjectRegistryRows(testRoot), [
|
|
576
|
+
{ slug: "beta", cwd: "/srv/beta" },
|
|
577
|
+
]);
|
|
587
578
|
assert.equal(existsSync(alphaRulesPath), false);
|
|
588
579
|
}, {}, 60_000);
|
|
589
580
|
});
|
|
@@ -599,18 +590,9 @@ test("server projects delete route returns 404 for an unknown slug", async () =>
|
|
|
599
590
|
});
|
|
600
591
|
test("server project hard-rules update route rewrites only hard-rule frontmatter fields", async () => {
|
|
601
592
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
+ "title: Projects\n"
|
|
606
|
-
+ "summary: Canonical project registry.\n"
|
|
607
|
-
+ "updated: 2026-05-12\n"
|
|
608
|
-
+ "---\n\n"
|
|
609
|
-
+ "# Projects\n\n"
|
|
610
|
-
+ "## Project Registry\n\n"
|
|
611
|
-
+ "```yaml\n"
|
|
612
|
-
+ "alpha: /srv/alpha\n"
|
|
613
|
-
+ "```\n", "utf-8");
|
|
593
|
+
seedProjectRegistry(testRoot, {
|
|
594
|
+
alpha: "/srv/alpha",
|
|
595
|
+
});
|
|
614
596
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
615
597
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
616
598
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -688,18 +670,9 @@ test("server project hard-rules update route rewrites only hard-rule frontmatter
|
|
|
688
670
|
});
|
|
689
671
|
test("server project soft-rules update route rewrites the body while preserving frontmatter", async () => {
|
|
690
672
|
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
+ "title: Projects\n"
|
|
695
|
-
+ "summary: Canonical project registry.\n"
|
|
696
|
-
+ "updated: 2026-05-12\n"
|
|
697
|
-
+ "---\n\n"
|
|
698
|
-
+ "# Projects\n\n"
|
|
699
|
-
+ "## Project Registry\n\n"
|
|
700
|
-
+ "```yaml\n"
|
|
701
|
-
+ "alpha: /srv/alpha\n"
|
|
702
|
-
+ "```\n", "utf-8");
|
|
673
|
+
seedProjectRegistry(testRoot, {
|
|
674
|
+
alpha: "/srv/alpha",
|
|
675
|
+
});
|
|
703
676
|
const alphaRulesPath = join(testRoot, ".chapterhouse", "wiki", "pages", "projects", "alpha", "rules.md");
|
|
704
677
|
mkdirSync(join(alphaRulesPath, ".."), { recursive: true });
|
|
705
678
|
writeFileSync(alphaRulesPath, "---\n"
|
|
@@ -14,7 +14,7 @@ import readline from "node:readline";
|
|
|
14
14
|
import { startApiServer } from "./dist/api/server.js";
|
|
15
15
|
import { agentEventBus } from "./dist/copilot/agent-event-bus.js";
|
|
16
16
|
import { initTaskEventLog } from "./dist/copilot/task-event-log.js";
|
|
17
|
-
import { appendTaskEvent, getDb } from "./dist/store/db.js";
|
|
17
|
+
import { appendTaskEvent, appendTaskOutputDeltaEvent, appendTaskStatusEvent, getDb } from "./dist/store/db.js";
|
|
18
18
|
|
|
19
19
|
const db = getDb();
|
|
20
20
|
initTaskEventLog();
|
|
@@ -33,12 +33,58 @@ function emitTaskEvent(taskId, kind, toolName, summary) {
|
|
|
33
33
|
_seq: event.seq,
|
|
34
34
|
_ts: event.ts,
|
|
35
35
|
_summary: event.summary,
|
|
36
|
+
_text: event.text,
|
|
37
|
+
_status: event.status,
|
|
36
38
|
...(event.toolName ? { toolName: event.toolName } : {}),
|
|
37
39
|
},
|
|
38
40
|
});
|
|
39
41
|
return event;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function emitOutputDelta(taskId, text) {
|
|
45
|
+
const event = appendTaskOutputDeltaEvent(taskId, text);
|
|
46
|
+
if (!event) {
|
|
47
|
+
throw new Error("appendTaskOutputDeltaEvent returned undefined");
|
|
48
|
+
}
|
|
49
|
+
agentEventBus.emit({
|
|
50
|
+
type: "session:tool_call",
|
|
51
|
+
sessionId: taskId,
|
|
52
|
+
payload: {
|
|
53
|
+
toolName: "",
|
|
54
|
+
toolArgs: {},
|
|
55
|
+
_kind: event.kind,
|
|
56
|
+
_seq: event.seq,
|
|
57
|
+
_ts: event.ts,
|
|
58
|
+
_summary: null,
|
|
59
|
+
_text: event.text,
|
|
60
|
+
_status: null,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
return event;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emitStatusEvent(taskId, status, summary) {
|
|
67
|
+
const event = appendTaskStatusEvent(taskId, status, summary ?? null);
|
|
68
|
+
if (!event) {
|
|
69
|
+
throw new Error("appendTaskStatusEvent returned undefined");
|
|
70
|
+
}
|
|
71
|
+
agentEventBus.emit({
|
|
72
|
+
type: "session:tool_call",
|
|
73
|
+
sessionId: taskId,
|
|
74
|
+
payload: {
|
|
75
|
+
toolName: "",
|
|
76
|
+
toolArgs: {},
|
|
77
|
+
_kind: event.kind,
|
|
78
|
+
_seq: event.seq,
|
|
79
|
+
_ts: event.ts,
|
|
80
|
+
_summary: event.summary,
|
|
81
|
+
_text: null,
|
|
82
|
+
_status: event.status,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return event;
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
await startApiServer();
|
|
43
89
|
process.stdout.write("${READY_PREFIX}\\n");
|
|
44
90
|
|
|
@@ -61,6 +107,18 @@ for await (const line of rl) {
|
|
|
61
107
|
event: emitTaskEvent(command.taskId, command.kind, command.toolName ?? null, command.summary ?? null),
|
|
62
108
|
});
|
|
63
109
|
break;
|
|
110
|
+
case "appendOutputDelta":
|
|
111
|
+
reply({
|
|
112
|
+
ok: true,
|
|
113
|
+
event: emitOutputDelta(command.taskId, command.text),
|
|
114
|
+
});
|
|
115
|
+
break;
|
|
116
|
+
case "appendStatusEvent":
|
|
117
|
+
reply({
|
|
118
|
+
ok: true,
|
|
119
|
+
event: emitStatusEvent(command.taskId, command.status, command.summary ?? null),
|
|
120
|
+
});
|
|
121
|
+
break;
|
|
64
122
|
case "finishTask": {
|
|
65
123
|
db.prepare(
|
|
66
124
|
"UPDATE agent_tasks SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?"
|
|
@@ -333,4 +391,186 @@ test("worker events SSE replays backlog, streams live events, and closes on comp
|
|
|
333
391
|
}
|
|
334
392
|
}, 30_000);
|
|
335
393
|
});
|
|
394
|
+
test("worker events SSE streams output_delta events in backlog and live", async () => {
|
|
395
|
+
await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
|
|
396
|
+
const taskId = "worker-events-sse-delta-001";
|
|
397
|
+
await sendCommand({
|
|
398
|
+
type: "createTask",
|
|
399
|
+
taskId,
|
|
400
|
+
description: "Stream output deltas",
|
|
401
|
+
status: "running",
|
|
402
|
+
});
|
|
403
|
+
await sendCommand({
|
|
404
|
+
type: "appendOutputDelta",
|
|
405
|
+
taskId,
|
|
406
|
+
text: "Hello ",
|
|
407
|
+
});
|
|
408
|
+
await sendCommand({
|
|
409
|
+
type: "appendOutputDelta",
|
|
410
|
+
taskId,
|
|
411
|
+
text: "world!",
|
|
412
|
+
});
|
|
413
|
+
const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
|
|
414
|
+
headers: {
|
|
415
|
+
Authorization: authHeader,
|
|
416
|
+
Accept: "text/event-stream",
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
assert.equal(response.status, 200);
|
|
420
|
+
assert.ok(response.body, "SSE response should have a readable body");
|
|
421
|
+
const reader = createSseReader(response.body);
|
|
422
|
+
try {
|
|
423
|
+
const backlogFrames = await reader.readFrames(2, 2_000);
|
|
424
|
+
assert.equal(backlogFrames.length, 2, "Expected 2 backlog output_delta frames");
|
|
425
|
+
assert.deepEqual(backlogFrames[0]?.data, {
|
|
426
|
+
type: "output_delta",
|
|
427
|
+
taskId,
|
|
428
|
+
seq: 1,
|
|
429
|
+
text: "Hello ",
|
|
430
|
+
});
|
|
431
|
+
assert.deepEqual(backlogFrames[1]?.data, {
|
|
432
|
+
type: "output_delta",
|
|
433
|
+
taskId,
|
|
434
|
+
seq: 2,
|
|
435
|
+
text: "world!",
|
|
436
|
+
});
|
|
437
|
+
assert.equal(backlogFrames[0]?.id, "1");
|
|
438
|
+
assert.equal(backlogFrames[1]?.id, "2");
|
|
439
|
+
// Live output_delta
|
|
440
|
+
await sendCommand({
|
|
441
|
+
type: "appendOutputDelta",
|
|
442
|
+
taskId,
|
|
443
|
+
text: " More text.",
|
|
444
|
+
});
|
|
445
|
+
const liveFrames = await reader.readFrames(1, 2_000);
|
|
446
|
+
assert.equal(liveFrames.length, 1);
|
|
447
|
+
assert.deepEqual(liveFrames[0]?.data, {
|
|
448
|
+
type: "output_delta",
|
|
449
|
+
taskId,
|
|
450
|
+
seq: 3,
|
|
451
|
+
text: " More text.",
|
|
452
|
+
});
|
|
453
|
+
await sendCommand({
|
|
454
|
+
type: "finishTask",
|
|
455
|
+
taskId,
|
|
456
|
+
status: "completed",
|
|
457
|
+
reason: "done",
|
|
458
|
+
});
|
|
459
|
+
assert.equal(await reader.waitForClose(2_000), true);
|
|
460
|
+
}
|
|
461
|
+
finally {
|
|
462
|
+
await reader.cancel();
|
|
463
|
+
}
|
|
464
|
+
}, 30_000);
|
|
465
|
+
});
|
|
466
|
+
test("worker events SSE closes on terminal task_status event", async () => {
|
|
467
|
+
await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
|
|
468
|
+
const taskId = "worker-events-sse-status-001";
|
|
469
|
+
await sendCommand({
|
|
470
|
+
type: "createTask",
|
|
471
|
+
taskId,
|
|
472
|
+
description: "Task status close test",
|
|
473
|
+
status: "running",
|
|
474
|
+
});
|
|
475
|
+
const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
|
|
476
|
+
headers: {
|
|
477
|
+
Authorization: authHeader,
|
|
478
|
+
Accept: "text/event-stream",
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
assert.equal(response.status, 200);
|
|
482
|
+
assert.ok(response.body, "SSE response should have a readable body");
|
|
483
|
+
const reader = createSseReader(response.body);
|
|
484
|
+
try {
|
|
485
|
+
// Emit a task_status completed event (which also marks DB terminal)
|
|
486
|
+
await sendCommand({
|
|
487
|
+
type: "appendStatusEvent",
|
|
488
|
+
taskId,
|
|
489
|
+
status: "completed",
|
|
490
|
+
summary: "All done",
|
|
491
|
+
});
|
|
492
|
+
// Also mark in DB so isTerminal() returns true
|
|
493
|
+
await sendCommand({
|
|
494
|
+
type: "finishTask",
|
|
495
|
+
taskId,
|
|
496
|
+
status: "completed",
|
|
497
|
+
reason: "complete",
|
|
498
|
+
});
|
|
499
|
+
const frames = await reader.readFrames(1, 2_000);
|
|
500
|
+
assert.ok(frames.length >= 1, "Expected at least one task_status frame");
|
|
501
|
+
const statusFrame = frames.find((f) => f.data.type === "task_status");
|
|
502
|
+
assert.ok(statusFrame, "Should have received a task_status frame");
|
|
503
|
+
assert.deepEqual(statusFrame?.data, {
|
|
504
|
+
type: "task_status",
|
|
505
|
+
taskId,
|
|
506
|
+
seq: 1,
|
|
507
|
+
status: "completed",
|
|
508
|
+
summary: "All done",
|
|
509
|
+
});
|
|
510
|
+
assert.equal(await reader.waitForClose(2_000), true, "SSE should close on terminal task_status");
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
await reader.cancel();
|
|
514
|
+
}
|
|
515
|
+
}, 30_000);
|
|
516
|
+
});
|
|
517
|
+
test("worker events SSE Last-Event-ID skips output_delta events before the ID", async () => {
|
|
518
|
+
await withControlledServer(async ({ baseUrl, authHeader, sendCommand }) => {
|
|
519
|
+
const taskId = "worker-events-sse-reconnect-001";
|
|
520
|
+
await sendCommand({
|
|
521
|
+
type: "createTask",
|
|
522
|
+
taskId,
|
|
523
|
+
description: "Reconnect test",
|
|
524
|
+
status: "running",
|
|
525
|
+
});
|
|
526
|
+
await sendCommand({
|
|
527
|
+
type: "appendOutputDelta",
|
|
528
|
+
taskId,
|
|
529
|
+
text: "chunk-1",
|
|
530
|
+
});
|
|
531
|
+
await sendCommand({
|
|
532
|
+
type: "appendOutputDelta",
|
|
533
|
+
taskId,
|
|
534
|
+
text: "chunk-2",
|
|
535
|
+
});
|
|
536
|
+
await sendCommand({
|
|
537
|
+
type: "appendEvent",
|
|
538
|
+
taskId,
|
|
539
|
+
kind: "tool_start",
|
|
540
|
+
toolName: "bash",
|
|
541
|
+
summary: "echo hi",
|
|
542
|
+
});
|
|
543
|
+
// Reconnect after seq 1 — should only get seq 2 and 3
|
|
544
|
+
const response = await fetch(`${baseUrl}/api/workers/${taskId}/events`, {
|
|
545
|
+
headers: {
|
|
546
|
+
Authorization: authHeader,
|
|
547
|
+
Accept: "text/event-stream",
|
|
548
|
+
"Last-Event-ID": "1",
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
assert.equal(response.status, 200);
|
|
552
|
+
assert.ok(response.body);
|
|
553
|
+
const reader = createSseReader(response.body);
|
|
554
|
+
try {
|
|
555
|
+
const frames = await reader.readFrames(2, 2_000);
|
|
556
|
+
assert.equal(frames.length, 2, "Expected 2 frames after Last-Event-ID=1");
|
|
557
|
+
assert.equal(frames[0]?.id, "2");
|
|
558
|
+
assert.deepEqual(frames[0]?.data, {
|
|
559
|
+
type: "output_delta",
|
|
560
|
+
taskId,
|
|
561
|
+
seq: 2,
|
|
562
|
+
text: "chunk-2",
|
|
563
|
+
});
|
|
564
|
+
assert.equal(frames[1]?.id, "3");
|
|
565
|
+
// tool_start events use the old format
|
|
566
|
+
const toolFrame = frames[1]?.data;
|
|
567
|
+
assert.equal(toolFrame.kind, "tool_start");
|
|
568
|
+
assert.equal(toolFrame.toolName, "bash");
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
await reader.cancel();
|
|
572
|
+
await sendCommand({ type: "finishTask", taskId, status: "completed", reason: "done" });
|
|
573
|
+
}
|
|
574
|
+
}, 30_000);
|
|
575
|
+
});
|
|
336
576
|
//# sourceMappingURL=worker-events-sse.integration.test.js.map
|
|
@@ -69,14 +69,19 @@ export function initTaskEventLog() {
|
|
|
69
69
|
if (!taskId)
|
|
70
70
|
return;
|
|
71
71
|
const p = event.payload;
|
|
72
|
+
const kind = p._kind === "tool_complete" || p._kind === "output_delta" || p._kind === "task_status"
|
|
73
|
+
? p._kind
|
|
74
|
+
: "tool_start";
|
|
72
75
|
const taskEvent = {
|
|
73
76
|
id: 0, // not a DB row — id is meaningless for ring-buffer entries
|
|
74
77
|
taskId,
|
|
75
78
|
seq: p._seq ?? 0,
|
|
76
79
|
ts: p._ts ?? Date.now(),
|
|
77
|
-
kind
|
|
80
|
+
kind,
|
|
78
81
|
toolName: p.toolName ?? null,
|
|
79
82
|
summary: p._summary ?? null,
|
|
83
|
+
text: p._text ?? null,
|
|
84
|
+
status: p._status ?? null,
|
|
80
85
|
};
|
|
81
86
|
const buf = getOrCreateBuffer(taskId);
|
|
82
87
|
buf.push(taskEvent);
|
|
@@ -197,5 +197,50 @@ describe("task event log — bus-wired", () => {
|
|
|
197
197
|
assert.equal(getTaskLogEvents("task-Y").length, 0);
|
|
198
198
|
clearTaskLog("task-X");
|
|
199
199
|
});
|
|
200
|
+
it("preserves output_delta payloads from the bus for live subscribers", () => {
|
|
201
|
+
agentEventBus.emit({
|
|
202
|
+
type: "session:tool_call",
|
|
203
|
+
sessionId: "task-output-live",
|
|
204
|
+
payload: {
|
|
205
|
+
toolName: "",
|
|
206
|
+
toolArgs: {},
|
|
207
|
+
_kind: "output_delta",
|
|
208
|
+
_seq: 4,
|
|
209
|
+
_ts: Date.now(),
|
|
210
|
+
_summary: null,
|
|
211
|
+
_text: "chunk-1",
|
|
212
|
+
_status: null,
|
|
213
|
+
},
|
|
214
|
+
timestamp: new Date(),
|
|
215
|
+
});
|
|
216
|
+
const events = getTaskLogEvents("task-output-live");
|
|
217
|
+
assert.equal(events.length, 1);
|
|
218
|
+
assert.equal(events[0].kind, "output_delta");
|
|
219
|
+
assert.equal(events[0].text, "chunk-1");
|
|
220
|
+
clearTaskLog("task-output-live");
|
|
221
|
+
});
|
|
222
|
+
it("preserves task_status payloads from the bus", () => {
|
|
223
|
+
agentEventBus.emit({
|
|
224
|
+
type: "session:tool_call",
|
|
225
|
+
sessionId: "task-status-live",
|
|
226
|
+
payload: {
|
|
227
|
+
toolName: "",
|
|
228
|
+
toolArgs: {},
|
|
229
|
+
_kind: "task_status",
|
|
230
|
+
_seq: 5,
|
|
231
|
+
_ts: Date.now(),
|
|
232
|
+
_summary: "All done",
|
|
233
|
+
_text: null,
|
|
234
|
+
_status: "completed",
|
|
235
|
+
},
|
|
236
|
+
timestamp: new Date(),
|
|
237
|
+
});
|
|
238
|
+
const events = getTaskLogEvents("task-status-live");
|
|
239
|
+
assert.equal(events.length, 1);
|
|
240
|
+
assert.equal(events[0].kind, "task_status");
|
|
241
|
+
assert.equal(events[0].status, "completed");
|
|
242
|
+
assert.equal(events[0].summary, "All done");
|
|
243
|
+
clearTaskLog("task-status-live");
|
|
244
|
+
});
|
|
200
245
|
});
|
|
201
246
|
//# sourceMappingURL=task-event-log.test.js.map
|