chapterhouse 0.3.24 → 0.3.26
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-runtime.js +1 -1
- package/dist/api/server.js +53 -16
- package/dist/api/server.test.js +31 -56
- package/dist/api/sse.integration.test.js +4 -46
- package/dist/api/turn-sse.integration.test.js +20 -47
- package/dist/api/worker-events-sse.integration.test.js +241 -1
- package/dist/config.js +11 -1
- package/dist/config.test.js +14 -0
- package/dist/copilot/orchestrator.js +3 -1
- package/dist/copilot/orchestrator.test.js +1 -1
- 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 +56 -16
- package/dist/store/db.test.js +67 -7
- package/dist/test/api-server.js +50 -0
- package/dist/test/api-server.test.js +57 -0
- package/dist/test/setup-env.js +9 -0
- package/dist/test/setup-env.test.js +34 -0
- 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
|
@@ -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
|
package/dist/config.js
CHANGED
|
@@ -60,6 +60,16 @@ export const DEFAULT_API_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
|
60
60
|
export const DEFAULT_API_RATE_LIMIT_GENERAL_MAX = 100;
|
|
61
61
|
export const DEFAULT_API_RATE_LIMIT_AUTH_MAX = 10;
|
|
62
62
|
export const DEFAULT_API_RATE_LIMIT_SSE_MAX_CONNECTIONS = 5;
|
|
63
|
+
function parseZeroOneEnv(name, rawValue, defaultValue) {
|
|
64
|
+
const normalized = rawValue?.trim();
|
|
65
|
+
if (!normalized) {
|
|
66
|
+
return defaultValue;
|
|
67
|
+
}
|
|
68
|
+
if (normalized !== "0" && normalized !== "1") {
|
|
69
|
+
throw new Error(`${name} must be '0' or '1', got: "${rawValue}"`);
|
|
70
|
+
}
|
|
71
|
+
return normalized === "1";
|
|
72
|
+
}
|
|
63
73
|
function parseBooleanEnv(name, rawValue, defaultValue) {
|
|
64
74
|
const normalized = rawValue?.trim();
|
|
65
75
|
if (!normalized) {
|
|
@@ -230,7 +240,7 @@ export function parseRuntimeConfig(env, options = {}) {
|
|
|
230
240
|
apiRateLimitAuthMax,
|
|
231
241
|
apiRateLimitSseMaxConnections,
|
|
232
242
|
workiqAutoInstall: parseBooleanEnv("CHAPTERHOUSE_WORKIQ_AUTO_INSTALL", raw.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL, true),
|
|
233
|
-
chatSseEnabled: raw.CHAPTERHOUSE_CHAT_SSE
|
|
243
|
+
chatSseEnabled: parseZeroOneEnv("CHAPTERHOUSE_CHAT_SSE", raw.CHAPTERHOUSE_CHAT_SSE, true),
|
|
234
244
|
};
|
|
235
245
|
}
|
|
236
246
|
const runtimeConfig = parseRuntimeConfig(process.env);
|
package/dist/config.test.js
CHANGED
|
@@ -95,6 +95,20 @@ test("defaults TEAM_WIKI_PATHS to include the shared namespace", async () => {
|
|
|
95
95
|
const parsed = configModule.parseRuntimeConfig({});
|
|
96
96
|
assert.deepEqual(parsed.teamWikiPaths, ["pages/team", "pages/okrs", "pages/kpis", "pages/shared"]);
|
|
97
97
|
});
|
|
98
|
+
test("defaults chat SSE on and still honors explicit CHAPTERHOUSE_CHAT_SSE overrides", async () => {
|
|
99
|
+
const configModule = await import("./config.js");
|
|
100
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
101
|
+
const parsedDefault = configModule.parseRuntimeConfig({});
|
|
102
|
+
const parsedDisabled = configModule.parseRuntimeConfig({
|
|
103
|
+
CHAPTERHOUSE_CHAT_SSE: "0",
|
|
104
|
+
});
|
|
105
|
+
const parsedEnabled = configModule.parseRuntimeConfig({
|
|
106
|
+
CHAPTERHOUSE_CHAT_SSE: "1",
|
|
107
|
+
});
|
|
108
|
+
assert.equal(parsedDefault.chatSseEnabled, true);
|
|
109
|
+
assert.equal(parsedDisabled.chatSseEnabled, false);
|
|
110
|
+
assert.equal(parsedEnabled.chatSseEnabled, true);
|
|
111
|
+
});
|
|
98
112
|
test("prefers COPILOT_TOKEN over GITHUB_TOKEN for Copilot SDK auth", async () => {
|
|
99
113
|
const configModule = await import("./config.js");
|
|
100
114
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
@@ -97,6 +97,8 @@ function emitTaskEvent(taskId, event) {
|
|
|
97
97
|
_seq: event.seq,
|
|
98
98
|
_ts: event.ts,
|
|
99
99
|
_summary: event.summary,
|
|
100
|
+
_text: event.text ?? null,
|
|
101
|
+
_status: event.status ?? null,
|
|
100
102
|
},
|
|
101
103
|
timestamp: new Date(event.ts),
|
|
102
104
|
});
|
|
@@ -841,7 +843,7 @@ export async function sendToOrchestrator(prompt, source, callback, attachments,
|
|
|
841
843
|
const taggedPrompt = source.type === "background"
|
|
842
844
|
? routedPrompt
|
|
843
845
|
: `[via ${sourceLabel}] ${routedPrompt}`;
|
|
844
|
-
const logRole = source.type === "background" ? "
|
|
846
|
+
const logRole = source.type === "background" ? "agent_completion" : "user";
|
|
845
847
|
const sourceChannel = source.type === "web" ? "web" : undefined;
|
|
846
848
|
// Capture auth context at enqueue time — prevents cross-session contamination
|
|
847
849
|
// when concurrent sessions are processing simultaneously.
|
|
@@ -675,7 +675,7 @@ test("feedAgentResult injects a background completion turn and proactively notif
|
|
|
675
675
|
}]);
|
|
676
676
|
assert.deepEqual(state.dbLogs, [
|
|
677
677
|
{
|
|
678
|
-
role: "
|
|
678
|
+
role: "agent_completion",
|
|
679
679
|
content: "[Agent task completed] @coder finished task task-9:\n\nFixed the flaky test",
|
|
680
680
|
source: "background",
|
|
681
681
|
},
|
|
@@ -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
|
package/dist/copilot/tools.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { approveAll, defineTool } from "@github/copilot-sdk";
|
|
3
|
-
import { getDb } from "../store/db.js";
|
|
3
|
+
import { getDb, appendTaskOutputDeltaEvent, appendTaskStatusEvent, updateTaskResult } from "../store/db.js";
|
|
4
4
|
import { readdirSync, readFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { listSkills, createSkill, removeSkill } from "./skills.js";
|
|
8
8
|
import { config, persistModel } from "../config.js";
|
|
9
|
+
import { agentEventBus } from "./agent-event-bus.js";
|
|
9
10
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, switchSessionModel, } from "./orchestrator.js";
|
|
10
11
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
11
12
|
import { ensureWikiStructure, readPage, writePage, deletePage, writeRawSource, assertPagePath } from "../wiki/fs.js";
|
|
@@ -137,6 +138,34 @@ export function createTools(deps) {
|
|
|
137
138
|
// `executeOnSession` finishes.
|
|
138
139
|
const parentActivity = getCurrentActivityCallback();
|
|
139
140
|
const childUnsubs = [];
|
|
141
|
+
const emitTaskLogEvent = (taskEvent) => {
|
|
142
|
+
void agentEventBus.emit({
|
|
143
|
+
type: "session:tool_call",
|
|
144
|
+
sessionId: task.taskId,
|
|
145
|
+
payload: {
|
|
146
|
+
toolName: "",
|
|
147
|
+
toolArgs: {},
|
|
148
|
+
_kind: taskEvent.kind,
|
|
149
|
+
_seq: taskEvent.seq,
|
|
150
|
+
_ts: taskEvent.ts,
|
|
151
|
+
_summary: taskEvent.summary,
|
|
152
|
+
_text: taskEvent.text,
|
|
153
|
+
_status: taskEvent.status,
|
|
154
|
+
},
|
|
155
|
+
timestamp: new Date(taskEvent.ts),
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
let workerOutput = "";
|
|
159
|
+
childUnsubs.push(session.on("assistant.message_delta", (event) => {
|
|
160
|
+
const delta = typeof event.data.deltaContent === "string" ? event.data.deltaContent : "";
|
|
161
|
+
if (!delta)
|
|
162
|
+
return;
|
|
163
|
+
workerOutput += delta;
|
|
164
|
+
const taskEvent = appendTaskOutputDeltaEvent(task.taskId, delta);
|
|
165
|
+
if (!taskEvent)
|
|
166
|
+
return;
|
|
167
|
+
emitTaskLogEvent(taskEvent);
|
|
168
|
+
}));
|
|
140
169
|
if (parentActivity) {
|
|
141
170
|
childUnsubs.push(session.on("assistant.reasoning_delta", (event) => {
|
|
142
171
|
parentActivity({
|
|
@@ -179,15 +208,21 @@ export function createTools(deps) {
|
|
|
179
208
|
(async () => {
|
|
180
209
|
try {
|
|
181
210
|
const result = await session.sendAndWait({ prompt: taskPrompt }, timeoutMs);
|
|
182
|
-
const output = result?.data?.content || "No response";
|
|
211
|
+
const output = workerOutput || result?.data?.content || "No response";
|
|
183
212
|
completeTask(task.taskId, output);
|
|
184
|
-
|
|
213
|
+
updateTaskResult(task.taskId, "completed", output);
|
|
214
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "completed");
|
|
215
|
+
if (statusEvent)
|
|
216
|
+
emitTaskLogEvent(statusEvent);
|
|
185
217
|
deps.onAgentTaskComplete(task.taskId, delegatedSlug, output);
|
|
186
218
|
}
|
|
187
219
|
catch (err) {
|
|
188
220
|
const msg = err instanceof Error ? err.message : String(err);
|
|
189
221
|
failTask(task.taskId, msg);
|
|
190
|
-
|
|
222
|
+
updateTaskResult(task.taskId, "error", msg);
|
|
223
|
+
const statusEvent = appendTaskStatusEvent(task.taskId, "error", msg);
|
|
224
|
+
if (statusEvent)
|
|
225
|
+
emitTaskLogEvent(statusEvent);
|
|
191
226
|
deps.onAgentTaskComplete(task.taskId, delegatedSlug, `Error: ${msg}`);
|
|
192
227
|
}
|
|
193
228
|
finally {
|
package/dist/store/db.js
CHANGED
|
@@ -62,7 +62,7 @@ export function getDb() {
|
|
|
62
62
|
db.exec(`
|
|
63
63
|
CREATE TABLE IF NOT EXISTS conversation_log (
|
|
64
64
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
-
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
65
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
66
66
|
content TEXT NOT NULL,
|
|
67
67
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
68
68
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
@@ -80,16 +80,16 @@ export function getDb() {
|
|
|
80
80
|
`);
|
|
81
81
|
// Migrate: if the table already existed with a stricter CHECK, recreate it
|
|
82
82
|
try {
|
|
83
|
-
db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('
|
|
83
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('agent_completion', '__migration_test__', 'test')`).run();
|
|
84
84
|
db.prepare(`DELETE FROM conversation_log WHERE content = '__migration_test__'`).run();
|
|
85
85
|
}
|
|
86
86
|
catch {
|
|
87
|
-
// CHECK constraint doesn't allow
|
|
87
|
+
// CHECK constraint doesn't allow current synthetic roles — recreate table preserving data
|
|
88
88
|
db.exec(`ALTER TABLE conversation_log RENAME TO conversation_log_old`);
|
|
89
89
|
db.exec(`
|
|
90
90
|
CREATE TABLE conversation_log (
|
|
91
91
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
-
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
|
92
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
93
93
|
content TEXT NOT NULL,
|
|
94
94
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
95
95
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
@@ -126,19 +126,45 @@ export function getDb() {
|
|
|
126
126
|
if (!taskCols.some((c) => c.name === "prompt")) {
|
|
127
127
|
db.exec(`ALTER TABLE agent_tasks ADD COLUMN prompt TEXT`);
|
|
128
128
|
}
|
|
129
|
-
// agent_task_events: append-only per-task
|
|
129
|
+
// agent_task_events: append-only per-task activity log for /workers streaming
|
|
130
130
|
db.exec(`
|
|
131
131
|
CREATE TABLE IF NOT EXISTS agent_task_events (
|
|
132
132
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
133
133
|
task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
|
|
134
134
|
seq INTEGER NOT NULL,
|
|
135
135
|
ts INTEGER NOT NULL,
|
|
136
|
-
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
|
|
136
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
|
|
137
137
|
tool_name TEXT,
|
|
138
|
-
summary TEXT
|
|
138
|
+
summary TEXT,
|
|
139
|
+
text TEXT,
|
|
140
|
+
status TEXT
|
|
139
141
|
)
|
|
140
142
|
`);
|
|
141
143
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
|
|
144
|
+
// Migrate existing agent_task_events tables that lack text/status columns
|
|
145
|
+
const taskEventCols = db.prepare(`PRAGMA table_info(agent_task_events)`).all();
|
|
146
|
+
if (!taskEventCols.some((c) => c.name === "text") || !taskEventCols.some((c) => c.name === "status")) {
|
|
147
|
+
db.exec(`ALTER TABLE agent_task_events RENAME TO agent_task_events_old`);
|
|
148
|
+
db.exec(`
|
|
149
|
+
CREATE TABLE agent_task_events (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
|
|
152
|
+
seq INTEGER NOT NULL,
|
|
153
|
+
ts INTEGER NOT NULL,
|
|
154
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete', 'output_delta', 'task_status')),
|
|
155
|
+
tool_name TEXT,
|
|
156
|
+
summary TEXT,
|
|
157
|
+
text TEXT,
|
|
158
|
+
status TEXT
|
|
159
|
+
)
|
|
160
|
+
`);
|
|
161
|
+
db.exec(`
|
|
162
|
+
INSERT INTO agent_task_events (id, task_id, seq, ts, kind, tool_name, summary)
|
|
163
|
+
SELECT id, task_id, seq, ts, kind, tool_name, summary FROM agent_task_events_old
|
|
164
|
+
`);
|
|
165
|
+
db.exec(`DROP TABLE agent_task_events_old`);
|
|
166
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
|
|
167
|
+
}
|
|
142
168
|
// Migrate: add event_seq column to agent_tasks for monotonic event numbering
|
|
143
169
|
const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
|
|
144
170
|
if (!taskColsNow.some((c) => c.name === 'event_seq')) {
|
|
@@ -283,7 +309,8 @@ export function getRecentConversation(limit, sessionKey) {
|
|
|
283
309
|
return rows.map((r) => {
|
|
284
310
|
const tag = r.role === "user" ? `[${r.source}] User`
|
|
285
311
|
: r.role === "system" ? `[${r.source}] System`
|
|
286
|
-
: "
|
|
312
|
+
: r.role === "agent_completion" ? `[${r.source}] Agent completion`
|
|
313
|
+
: "Chapterhouse";
|
|
287
314
|
// Truncate long messages to keep context manageable
|
|
288
315
|
const content = r.content.length > 1500 ? r.content.slice(0, 1500) + "…" : r.content;
|
|
289
316
|
return `${tag}: ${content}`;
|
|
@@ -318,21 +345,22 @@ export function normalizeSqliteTsToIso(ts) {
|
|
|
318
345
|
* suitable for seeding the frontend Zustand store on mount.
|
|
319
346
|
*
|
|
320
347
|
* Unlike `getRecentConversation()`, this returns structured objects (not a
|
|
321
|
-
* formatted string) and omits system messages
|
|
322
|
-
*
|
|
348
|
+
* formatted string) and omits internal system messages. Synthetic background
|
|
349
|
+
* completion notices are included and mapped to assistant-style turns so reload
|
|
350
|
+
* matches the live chat rendering path.
|
|
323
351
|
*/
|
|
324
352
|
export function getSessionMessages(sessionKey, limit) {
|
|
325
353
|
const db = getDb();
|
|
326
354
|
const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
|
|
327
355
|
const rows = db
|
|
328
356
|
.prepare(`SELECT role, content, ts FROM conversation_log
|
|
329
|
-
WHERE session_key = ? AND role IN ('user', 'assistant')
|
|
357
|
+
WHERE session_key = ? AND role IN ('user', 'assistant', 'agent_completion')
|
|
330
358
|
ORDER BY id DESC LIMIT ?`)
|
|
331
359
|
.all(sessionKey, effectiveLimit);
|
|
332
360
|
// Reverse so oldest is first (chronological order for the UI)
|
|
333
361
|
rows.reverse();
|
|
334
362
|
return rows.map((r) => ({
|
|
335
|
-
role: r.role,
|
|
363
|
+
role: r.role === "agent_completion" ? "assistant" : r.role,
|
|
336
364
|
content: r.content,
|
|
337
365
|
ts: normalizeSqliteTsToIso(r.ts),
|
|
338
366
|
}));
|
|
@@ -342,7 +370,7 @@ export function getSessionMessages(sessionKey, limit) {
|
|
|
342
370
|
* Uses a transaction so seq is monotonically incremented.
|
|
343
371
|
* Non-fatal: silently ignores DB errors (task may not exist yet due to race).
|
|
344
372
|
*/
|
|
345
|
-
export function appendTaskEvent(taskId, kind, toolName, summary) {
|
|
373
|
+
export function appendTaskEvent(taskId, kind, toolName, summary, text = null, status = null) {
|
|
346
374
|
const db = getDb();
|
|
347
375
|
try {
|
|
348
376
|
return db.transaction(() => {
|
|
@@ -352,20 +380,30 @@ export function appendTaskEvent(taskId, kind, toolName, summary) {
|
|
|
352
380
|
return undefined;
|
|
353
381
|
const seq = row.event_seq;
|
|
354
382
|
const ts = Date.now();
|
|
355
|
-
const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
|
|
356
|
-
return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
|
|
383
|
+
const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary, text, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary, text, status);
|
|
384
|
+
return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary, text, status };
|
|
357
385
|
})();
|
|
358
386
|
}
|
|
359
387
|
catch {
|
|
360
388
|
return undefined;
|
|
361
389
|
}
|
|
362
390
|
}
|
|
391
|
+
export function appendTaskOutputDeltaEvent(taskId, text) {
|
|
392
|
+
return appendTaskEvent(taskId, "output_delta", null, null, text, null);
|
|
393
|
+
}
|
|
394
|
+
export function appendTaskStatusEvent(taskId, status, summary = null) {
|
|
395
|
+
return appendTaskEvent(taskId, "task_status", null, summary, null, status);
|
|
396
|
+
}
|
|
397
|
+
export function updateTaskResult(taskId, status, result) {
|
|
398
|
+
const db = getDb();
|
|
399
|
+
db.prepare(`UPDATE agent_tasks SET status = ?, result = ?, completed_at = CURRENT_TIMESTAMP WHERE task_id = ?`).run(status, result ? result.slice(0, 10000) : null, taskId);
|
|
400
|
+
}
|
|
363
401
|
/**
|
|
364
402
|
* Return all events for a task ordered by seq ascending.
|
|
365
403
|
*/
|
|
366
404
|
export function getTaskEvents(taskId, afterSeq = 0) {
|
|
367
405
|
const db = getDb();
|
|
368
|
-
const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
|
|
406
|
+
const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary, text, status
|
|
369
407
|
FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
|
|
370
408
|
return rows.map((r) => ({
|
|
371
409
|
id: r.id,
|
|
@@ -375,6 +413,8 @@ export function getTaskEvents(taskId, afterSeq = 0) {
|
|
|
375
413
|
kind: r.kind,
|
|
376
414
|
toolName: r.tool_name,
|
|
377
415
|
summary: r.summary,
|
|
416
|
+
text: r.text,
|
|
417
|
+
status: r.status,
|
|
378
418
|
}));
|
|
379
419
|
}
|
|
380
420
|
export function closeDb() {
|