chapterhouse 0.4.3 → 0.5.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/dist/api/server.js +65 -2
- package/dist/api/server.test.js +63 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +13 -2
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +146 -29
- package/dist/copilot/orchestrator.test.js +232 -14
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/system-message.js +3 -3
- package/dist/copilot/system-message.test.js +10 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +149 -13
- package/dist/copilot/tools.memory.test.js +139 -2
- package/dist/memory/active-scope.js +9 -0
- package/dist/memory/active-scope.test.js +7 -2
- package/dist/memory/eot.js +96 -8
- package/dist/memory/eot.test.js +186 -5
- package/dist/memory/hot-tier.test.js +14 -4
- package/dist/memory/housekeeping.test.js +20 -13
- package/dist/memory/index.js +1 -1
- package/dist/memory/scopes.test.js +0 -24
- package/dist/store/db.js +27 -19
- package/package.json +1 -1
- package/web/dist/assets/{index-D4-uRAi6.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/index-BfHqP3-C.js.map +1 -0
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BTI_m0OE.css +0 -10
- package/web/dist/assets/index-D4-uRAi6.js.map +0 -1
|
@@ -3,8 +3,12 @@ import test from "node:test";
|
|
|
3
3
|
import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
|
|
4
4
|
function createFakeClient(state) {
|
|
5
5
|
class FakeSession {
|
|
6
|
+
options;
|
|
6
7
|
sessionId = "session-123";
|
|
7
8
|
listeners = new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
8
12
|
on(eventName, handler) {
|
|
9
13
|
const handlers = this.listeners.get(eventName) || [];
|
|
10
14
|
handlers.push(handler);
|
|
@@ -20,6 +24,9 @@ function createFakeClient(state) {
|
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
26
|
async sendAndWait(request, _timeoutMs) {
|
|
27
|
+
const hooks = this.options.hooks;
|
|
28
|
+
const hookResult = await hooks?.onUserPromptSubmitted?.({ prompt: request.prompt }, { sessionId: this.sessionId });
|
|
29
|
+
state.promptMemoryContexts.push(hookResult?.additionalContext);
|
|
23
30
|
state.sessionPrompts.push(request);
|
|
24
31
|
if (state.sendResult === "__PENDING__") {
|
|
25
32
|
return await new Promise((_resolve, reject) => {
|
|
@@ -29,6 +36,9 @@ function createFakeClient(state) {
|
|
|
29
36
|
if (state.sendErrorMessage) {
|
|
30
37
|
throw new Error(state.sendErrorMessage);
|
|
31
38
|
}
|
|
39
|
+
for (const delta of state.sendDeltas ?? []) {
|
|
40
|
+
this.emit("assistant.message_delta", { deltaContent: delta });
|
|
41
|
+
}
|
|
32
42
|
return { data: { content: state.sendResult } };
|
|
33
43
|
}
|
|
34
44
|
async setModel(model) {
|
|
@@ -54,7 +64,7 @@ function createFakeClient(state) {
|
|
|
54
64
|
if (state.createSessionError) {
|
|
55
65
|
throw new Error(state.createSessionError);
|
|
56
66
|
}
|
|
57
|
-
const session = new FakeSession();
|
|
67
|
+
const session = new FakeSession(options);
|
|
58
68
|
state.lastSession = {
|
|
59
69
|
emit: (eventName, data) => session.emit(eventName, data),
|
|
60
70
|
};
|
|
@@ -102,9 +112,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
102
112
|
{ taskId: "task-2", agentSlug: "designer", status: "done", description: "Ignore me" },
|
|
103
113
|
],
|
|
104
114
|
registry: [
|
|
105
|
-
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
115
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
106
116
|
],
|
|
107
117
|
sendResult: "Finished successfully",
|
|
118
|
+
promptMemoryContexts: [],
|
|
108
119
|
taskEvents: new Map(),
|
|
109
120
|
projectRegistry: {},
|
|
110
121
|
resolveProjectArgs: [],
|
|
@@ -150,11 +161,37 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
150
161
|
t.mock.module("../memory/hot-tier.js", {
|
|
151
162
|
namedExports: {
|
|
152
163
|
renderHotTierForActiveScope: () => state.hotTierXml ?? "",
|
|
164
|
+
getHotTierEntries: (scopeId) => ({
|
|
165
|
+
scope: scopeId !== undefined
|
|
166
|
+
? scopeId === state.activeScope?.id
|
|
167
|
+
? state.activeScope
|
|
168
|
+
: makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
|
|
169
|
+
: state.activeScope ?? null,
|
|
170
|
+
entities: [],
|
|
171
|
+
observations: [],
|
|
172
|
+
decisions: [],
|
|
173
|
+
actionItems: [],
|
|
174
|
+
}),
|
|
175
|
+
renderHotTierXML: (entries) => entries.scope ? state.hotTierByScope?.get(entries.scope.slug) ?? "" : "",
|
|
153
176
|
},
|
|
154
177
|
});
|
|
155
178
|
t.mock.module("../memory/active-scope.js", {
|
|
156
179
|
namedExports: {
|
|
157
180
|
getActiveScope: () => state.activeScope ?? null,
|
|
181
|
+
withActiveScope: async (_slug, fn) => fn(),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
t.mock.module("../memory/scopes.js", {
|
|
185
|
+
namedExports: {
|
|
186
|
+
getScope: (slugOrId) => {
|
|
187
|
+
if (slugOrId === "infra" || slugOrId === 3) {
|
|
188
|
+
return makeScope(3, "infra", "Infra", "Infrastructure work.");
|
|
189
|
+
}
|
|
190
|
+
if (slugOrId === "brian" || slugOrId === 5) {
|
|
191
|
+
return makeScope(5, "brian", "Brian", "Brian's personal context.");
|
|
192
|
+
}
|
|
193
|
+
return state.activeScope ?? null;
|
|
194
|
+
},
|
|
158
195
|
},
|
|
159
196
|
});
|
|
160
197
|
t.mock.module("../memory/checkpoint.js", {
|
|
@@ -342,10 +379,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
342
379
|
namedExports: {
|
|
343
380
|
loadAgents: () => {
|
|
344
381
|
state.loadAgentsCalls++;
|
|
345
|
-
return
|
|
346
|
-
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
347
|
-
{ slug: "designer", name: "Wash", model: "claude-opus-4.6" },
|
|
348
|
-
];
|
|
382
|
+
return state.registry;
|
|
349
383
|
},
|
|
350
384
|
ensureDefaultAgents: () => {
|
|
351
385
|
state.ensureDefaultAgentsCalls++;
|
|
@@ -354,6 +388,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
354
388
|
state.clearActiveTasksCalls++;
|
|
355
389
|
},
|
|
356
390
|
getAgentRegistry: () => state.registry,
|
|
391
|
+
getAgent: (slug) => state.registry.find((agent) => agent.slug === slug),
|
|
357
392
|
getActiveAgent: () => undefined,
|
|
358
393
|
setActiveAgent: (channelKey, agentSlug) => {
|
|
359
394
|
state.setActiveAgentCalls.push({ channelKey, agentSlug });
|
|
@@ -366,6 +401,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
366
401
|
state.buildAgentRosterArgs.push(projectRoot);
|
|
367
402
|
return "@coder @designer";
|
|
368
403
|
},
|
|
404
|
+
composeAgentSystemMessage: (agent) => agent.systemMessage ?? `You are ${agent.slug}.`,
|
|
405
|
+
filterToolsForAgent: (_agent, tools) => tools,
|
|
406
|
+
bindToolsToAgent: (_agentSlug, tools) => tools,
|
|
407
|
+
withToolTaskContext: (_taskId, fn) => fn(),
|
|
369
408
|
getActiveTasks: () => state.activeTasks,
|
|
370
409
|
completeTask: () => { },
|
|
371
410
|
failTask: () => { },
|
|
@@ -497,6 +536,43 @@ test("initOrchestrator passes hot-tier XML into the orchestrator system prompt w
|
|
|
497
536
|
assert.match(hotTierXml, /<decision id="decision-1">hi<\/decision>/);
|
|
498
537
|
assert.doesNotMatch(hotTierXml, /<memory_context[^>]*>[\s\S]*<memory_context\b/);
|
|
499
538
|
});
|
|
539
|
+
test("orchestrator refreshes hot-tier memory context for each assistant turn", async (t) => {
|
|
540
|
+
const firstMemoryContext = [
|
|
541
|
+
"<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:00:00.000Z\">",
|
|
542
|
+
" <observation id=\"observation-1\">Initial hot memory</observation>",
|
|
543
|
+
"</memory_context>",
|
|
544
|
+
].join("\n");
|
|
545
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
546
|
+
hotTierXml: firstMemoryContext,
|
|
547
|
+
hotTierByScope: new Map([["chapterhouse", firstMemoryContext]]),
|
|
548
|
+
});
|
|
549
|
+
await orchestrator.initOrchestrator(client);
|
|
550
|
+
await new Promise((resolve) => {
|
|
551
|
+
orchestrator.sendToOrchestrator("first turn", { type: "background" }, (text, done) => {
|
|
552
|
+
if (done)
|
|
553
|
+
resolve(text);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
const secondMemoryContext = [
|
|
557
|
+
"<memory_context scope=\"chapterhouse\" generated_at=\"2026-05-13T00:01:00.000Z\">",
|
|
558
|
+
" <observation id=\"observation-1\">Initial hot memory</observation>",
|
|
559
|
+
" <observation id=\"observation-2\">Checkpoint wrote this between turns</observation>",
|
|
560
|
+
"</memory_context>",
|
|
561
|
+
].join("\n");
|
|
562
|
+
state.hotTierXml = secondMemoryContext;
|
|
563
|
+
state.hotTierByScope?.set("chapterhouse", secondMemoryContext);
|
|
564
|
+
await new Promise((resolve) => {
|
|
565
|
+
orchestrator.sendToOrchestrator("second turn", { type: "background" }, (text, done) => {
|
|
566
|
+
if (done)
|
|
567
|
+
resolve(text);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
assert.equal(state.createSessionCalls.length, 1, "same SDK session should handle both turns");
|
|
571
|
+
assert.equal(state.promptMemoryContexts.length, 2);
|
|
572
|
+
assert.match(state.promptMemoryContexts[0] ?? "", /Initial hot memory/);
|
|
573
|
+
assert.doesNotMatch(state.promptMemoryContexts[0] ?? "", /Checkpoint wrote this between turns/);
|
|
574
|
+
assert.match(state.promptMemoryContexts[1] ?? "", /Checkpoint wrote this between turns/);
|
|
575
|
+
});
|
|
500
576
|
test("initOrchestrator omits hot-tier XML when no active-scope memory is available", async (t) => {
|
|
501
577
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
502
578
|
hotTierXml: "",
|
|
@@ -516,6 +592,143 @@ test("initOrchestrator omits hot-tier XML when memory injection is disabled", as
|
|
|
516
592
|
await orchestrator.initOrchestrator(client);
|
|
517
593
|
assert.equal(state.systemOptions?.hotTierXml, undefined);
|
|
518
594
|
});
|
|
595
|
+
test("initOrchestrator prewarms persistent agent sessions with scoped hot-tier context", async (t) => {
|
|
596
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
597
|
+
registry: [
|
|
598
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
599
|
+
{
|
|
600
|
+
slug: "bellonda",
|
|
601
|
+
name: "Bellonda",
|
|
602
|
+
model: "claude-sonnet-4.6",
|
|
603
|
+
systemMessage: "You are Bellonda.",
|
|
604
|
+
persistent: true,
|
|
605
|
+
scope: "infra",
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
hotTierByScope: new Map([
|
|
609
|
+
["infra", "<memory_context scope=\"infra\"><observation>terraform drift</observation></memory_context>"],
|
|
610
|
+
]),
|
|
611
|
+
});
|
|
612
|
+
await orchestrator.initOrchestrator(client);
|
|
613
|
+
assert.equal(state.createSessionCalls.length, 2);
|
|
614
|
+
const persistentCall = state.createSessionCalls.find((call) => String(call.systemMessage?.content ?? "").includes("Bellonda"));
|
|
615
|
+
assert.ok(persistentCall, "expected a prewarmed Bellonda session");
|
|
616
|
+
assert.equal(persistentCall.systemMessage.content.includes("Bellonda"), true);
|
|
617
|
+
assert.equal(persistentCall.systemMessage.content.includes("scope=\"infra\""), true);
|
|
618
|
+
assert.deepEqual(state.dbWrites.filter((write) => write.sql.includes("copilot_sessions")), []);
|
|
619
|
+
});
|
|
620
|
+
test("sendToOrchestrator routes agent session keys directly to persistent agent sessions", async (t) => {
|
|
621
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
622
|
+
config: {
|
|
623
|
+
copilotModel: "claude-sonnet-4.6",
|
|
624
|
+
selfEditEnabled: true,
|
|
625
|
+
memoryInjectEnabled: true,
|
|
626
|
+
},
|
|
627
|
+
registry: [
|
|
628
|
+
{
|
|
629
|
+
slug: "bellonda",
|
|
630
|
+
name: "Bellonda",
|
|
631
|
+
model: "claude-sonnet-4.6",
|
|
632
|
+
systemMessage: "You are Bellonda.",
|
|
633
|
+
persistent: true,
|
|
634
|
+
scope: "infra",
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
sendResult: "Infra answer",
|
|
638
|
+
hotTierByScope: new Map([
|
|
639
|
+
["infra", "<memory_context scope=\"infra\"><observation>vpc state</observation></memory_context>"],
|
|
640
|
+
]),
|
|
641
|
+
});
|
|
642
|
+
await orchestrator.initOrchestrator(client);
|
|
643
|
+
state.sessionPrompts.length = 0;
|
|
644
|
+
state.routerArgs.length = 0;
|
|
645
|
+
const final = await new Promise((resolve) => {
|
|
646
|
+
orchestrator.sendToOrchestrator("What changed in prod?", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
|
|
647
|
+
if (done)
|
|
648
|
+
resolve(text);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
assert.equal(final, "Infra answer");
|
|
652
|
+
assert.deepEqual(state.routerArgs, [], "direct persistent-agent chat must not use orchestrator model routing");
|
|
653
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] What changed in prod?" }]);
|
|
654
|
+
assert.deepEqual(state.dbLogs.map((entry) => ({
|
|
655
|
+
role: entry.role,
|
|
656
|
+
content: entry.content,
|
|
657
|
+
source: entry.source,
|
|
658
|
+
sessionKey: entry.sessionKey,
|
|
659
|
+
})), [
|
|
660
|
+
{ role: "user", content: "What changed in prod?", source: "web", sessionKey: "agent:bellonda" },
|
|
661
|
+
{ role: "assistant", content: "Infra answer", source: "web", sessionKey: "agent:bellonda" },
|
|
662
|
+
]);
|
|
663
|
+
});
|
|
664
|
+
test("sendToAgentSession annotates delegated turns as via chapterhouse", async (t) => {
|
|
665
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
666
|
+
config: {
|
|
667
|
+
copilotModel: "claude-sonnet-4.6",
|
|
668
|
+
selfEditEnabled: true,
|
|
669
|
+
memoryInjectEnabled: true,
|
|
670
|
+
},
|
|
671
|
+
registry: [
|
|
672
|
+
{
|
|
673
|
+
slug: "bellonda",
|
|
674
|
+
name: "Bellonda",
|
|
675
|
+
model: "claude-sonnet-4.6",
|
|
676
|
+
systemMessage: "You are Bellonda.",
|
|
677
|
+
persistent: true,
|
|
678
|
+
scope: "infra",
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
sendResult: "Delegated infra answer",
|
|
682
|
+
});
|
|
683
|
+
await orchestrator.initOrchestrator(client);
|
|
684
|
+
state.sessionPrompts.length = 0;
|
|
685
|
+
const final = await orchestrator.sendToAgentSession("bellonda", "Check deploy health", "task-bellonda-1");
|
|
686
|
+
assert.equal(final, "Delegated infra answer");
|
|
687
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via @chapterhouse] Check deploy health" }]);
|
|
688
|
+
assert.deepEqual(state.dbLogs.map((entry) => ({
|
|
689
|
+
role: entry.role,
|
|
690
|
+
content: entry.content,
|
|
691
|
+
source: entry.source,
|
|
692
|
+
sessionKey: entry.sessionKey,
|
|
693
|
+
})), [
|
|
694
|
+
{ role: "user", content: "Check deploy health", source: "delegated", sessionKey: "agent:bellonda" },
|
|
695
|
+
{ role: "assistant", content: "Delegated infra answer", source: "delegated", sessionKey: "agent:bellonda" },
|
|
696
|
+
]);
|
|
697
|
+
});
|
|
698
|
+
test("sendToOrchestrator preserves literal mentions inside persistent agent sessions", async (t) => {
|
|
699
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
700
|
+
registry: [
|
|
701
|
+
{
|
|
702
|
+
slug: "bellonda",
|
|
703
|
+
name: "Bellonda",
|
|
704
|
+
model: "claude-sonnet-4.6",
|
|
705
|
+
systemMessage: "You are Bellonda.",
|
|
706
|
+
persistent: true,
|
|
707
|
+
scope: "infra",
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
slug: "hwi-noree",
|
|
711
|
+
name: "Hwi Noree",
|
|
712
|
+
model: "claude-sonnet-4.6",
|
|
713
|
+
systemMessage: "You are Hwi Noree.",
|
|
714
|
+
persistent: true,
|
|
715
|
+
scope: "brian",
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
parseMentionResult: { agentSlug: "hwi-noree", message: "please archive this" },
|
|
719
|
+
});
|
|
720
|
+
await orchestrator.initOrchestrator(client);
|
|
721
|
+
state.sessionPrompts.length = 0;
|
|
722
|
+
state.setActiveAgentCalls.length = 0;
|
|
723
|
+
await new Promise((resolve) => {
|
|
724
|
+
orchestrator.sendToOrchestrator("@hwi-noree please archive this", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
|
|
725
|
+
if (done)
|
|
726
|
+
resolve(text);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] @hwi-noree please archive this" }]);
|
|
730
|
+
assert.deepEqual(state.setActiveAgentCalls, []);
|
|
731
|
+
});
|
|
519
732
|
test("sendToOrchestrator logs both sides, remembers web auth context, and records routing state", async (t) => {
|
|
520
733
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
521
734
|
config: {
|
|
@@ -1007,13 +1220,14 @@ test("@mentions route through the orchestrator session without invoking the mode
|
|
|
1007
1220
|
assert.deepEqual(state.routerArgs, []);
|
|
1008
1221
|
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
|
|
1009
1222
|
});
|
|
1010
|
-
test("feedAgentResult emits an attributed agent reply
|
|
1223
|
+
test("feedAgentResult emits an attributed short agent reply before starting the orchestrator acknowledgement", async (t) => {
|
|
1011
1224
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1012
1225
|
config: {
|
|
1013
1226
|
copilotModel: "claude-sonnet-4.6",
|
|
1014
1227
|
selfEditEnabled: true,
|
|
1015
1228
|
},
|
|
1016
1229
|
sendResult: "Agent complete",
|
|
1230
|
+
sendDeltas: ["Agent complete"],
|
|
1017
1231
|
taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
|
|
1018
1232
|
});
|
|
1019
1233
|
await orchestrator.initOrchestrator(client);
|
|
@@ -1021,17 +1235,20 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
|
|
|
1021
1235
|
const notified = new Promise((resolve) => {
|
|
1022
1236
|
orchestrator.setProactiveNotify(resolve);
|
|
1023
1237
|
});
|
|
1024
|
-
|
|
1238
|
+
const agentReply = "Fixed the flaky test. ".repeat(40);
|
|
1239
|
+
orchestrator.feedAgentResult("task-9", "coder", agentReply);
|
|
1240
|
+
assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete"], "short agent replies should fully emit before the orchestrator acknowledgement starts");
|
|
1241
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1025
1242
|
assert.equal(await notified, "Agent complete");
|
|
1026
1243
|
assert.deepEqual(state.sessionPrompts, [{
|
|
1027
|
-
prompt: "[Agent task completed] @coder finished task task-9.
|
|
1244
|
+
prompt: "[Agent task completed] @coder finished task task-9. The user has already seen this reply in the agent's own bubble. Acknowledge briefly without restating content.",
|
|
1028
1245
|
}]);
|
|
1029
|
-
assert.equal(state.sessionPrompts[0]?.prompt.includes(
|
|
1246
|
+
assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
|
|
1030
1247
|
const started = events.filter((event) => event.type === "turn:started");
|
|
1031
1248
|
const deltas = events.filter((event) => event.type === "turn:delta");
|
|
1032
1249
|
const completed = events.filter((event) => event.type === "turn:complete");
|
|
1033
1250
|
assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
|
|
1034
|
-
assert.equal(deltas.length,
|
|
1251
|
+
assert.equal(deltas.length, 2, "agent reply plus orchestrator acknowledgement should each emit one delta");
|
|
1035
1252
|
assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
|
|
1036
1253
|
assert.equal(started[0]?.agentSlug, "coder");
|
|
1037
1254
|
assert.equal(started[0]?.agentDisplayName, "Kaylee");
|
|
@@ -1039,7 +1256,7 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
|
|
|
1039
1256
|
assert.deepEqual(state.dbLogs, [
|
|
1040
1257
|
{
|
|
1041
1258
|
role: "agent_completion",
|
|
1042
|
-
content:
|
|
1259
|
+
content: agentReply,
|
|
1043
1260
|
source: "background",
|
|
1044
1261
|
sessionKey: "chat:bg-lifecycle",
|
|
1045
1262
|
agentSlug: "coder",
|
|
@@ -1055,10 +1272,11 @@ test("feedAgentResult emits an attributed agent reply turn and sends only a shor
|
|
|
1055
1272
|
},
|
|
1056
1273
|
]);
|
|
1057
1274
|
assert.equal(deltas[0]?.turnId, started[0]?.turnId);
|
|
1058
|
-
assert.deepEqual(deltas[0]?.part, { type: "text", text:
|
|
1275
|
+
assert.deepEqual(deltas[0]?.part, { type: "text", text: agentReply });
|
|
1059
1276
|
assert.equal(completed[0]?.turnId, started[0]?.turnId);
|
|
1060
|
-
assert.equal(completed[0]?.finalMessage,
|
|
1277
|
+
assert.equal(completed[0]?.finalMessage, agentReply);
|
|
1061
1278
|
assert.notEqual(started[0]?.turnId, started[1]?.turnId, "agent reply and orchestrator acknowledgement need distinct turns");
|
|
1279
|
+
assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete", "turn:started", "turn:delta", "turn:complete"]);
|
|
1062
1280
|
});
|
|
1063
1281
|
test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
|
|
1064
1282
|
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
@@ -76,6 +76,9 @@ export class SessionManager {
|
|
|
76
76
|
get canEvict() {
|
|
77
77
|
return !this._processing && this._queue.length === 0;
|
|
78
78
|
}
|
|
79
|
+
get isPersistent() {
|
|
80
|
+
return this.sessionKey.startsWith("agent:");
|
|
81
|
+
}
|
|
79
82
|
get lastActivityAt() {
|
|
80
83
|
return this._lastActivityAt;
|
|
81
84
|
}
|
|
@@ -273,7 +276,7 @@ export class SessionRegistry {
|
|
|
273
276
|
const existing = this.managers.get(sessionKey);
|
|
274
277
|
if (existing)
|
|
275
278
|
return existing;
|
|
276
|
-
if (this.
|
|
279
|
+
if (this.nonPersistentSize() >= this.options.maxActive) {
|
|
277
280
|
this.evictLRU();
|
|
278
281
|
}
|
|
279
282
|
const manager = this.createManager(sessionKey);
|
|
@@ -330,6 +333,9 @@ export class SessionRegistry {
|
|
|
330
333
|
runTtlEviction() {
|
|
331
334
|
const now = Date.now();
|
|
332
335
|
for (const [sessionKey, manager] of [...this.managers.entries()]) {
|
|
336
|
+
if (manager.isPersistent) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
333
339
|
if (manager.canEvict && now - manager.lastActivityAt > this.options.idleTtlMs) {
|
|
334
340
|
const idleMs = now - manager.lastActivityAt;
|
|
335
341
|
this.managers.delete(sessionKey);
|
|
@@ -340,7 +346,7 @@ export class SessionRegistry {
|
|
|
340
346
|
}
|
|
341
347
|
evictLRU() {
|
|
342
348
|
const evictable = [...this.managers.entries()]
|
|
343
|
-
.filter(([, m]) => m.canEvict)
|
|
349
|
+
.filter(([, m]) => m.canEvict && !m.isPersistent)
|
|
344
350
|
.sort(([, a], [, b]) => a.lastActivityAt - b.lastActivityAt);
|
|
345
351
|
if (evictable.length === 0) {
|
|
346
352
|
log.warn({ size: this.managers.size, max: this.options.maxActive }, "At max active sessions and no idle sessions available for LRU eviction");
|
|
@@ -351,6 +357,9 @@ export class SessionRegistry {
|
|
|
351
357
|
void manager.evict("lru-bumped");
|
|
352
358
|
log.info({ sessionKey, reason: "lru-bumped" }, "session.evicted");
|
|
353
359
|
}
|
|
360
|
+
nonPersistentSize() {
|
|
361
|
+
return [...this.managers.values()].filter((manager) => !manager.isPersistent).length;
|
|
362
|
+
}
|
|
354
363
|
/** Shut down all sessions. Stops the eviction timer and disconnects every session. */
|
|
355
364
|
async shutdown() {
|
|
356
365
|
this.stopEvictionTimer();
|
|
@@ -312,6 +312,31 @@ test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async
|
|
|
312
312
|
assert.ok(disconnectLog.includes("idle-session"), "idle session must be evicted after TTL");
|
|
313
313
|
assert.ok(!registry.get("idle-session"), "idle session must be removed");
|
|
314
314
|
});
|
|
315
|
+
test("SessionRegistry: TTL eviction does not remove persistent agent sessions", async () => {
|
|
316
|
+
const SHORT_TTL = 40;
|
|
317
|
+
const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
|
|
318
|
+
const manager = registry.getOrCreate("agent:bellonda");
|
|
319
|
+
await manager.ensureSession();
|
|
320
|
+
registry.startEvictionTimer();
|
|
321
|
+
await new Promise((r) => setTimeout(r, SHORT_TTL * 5));
|
|
322
|
+
registry.stopEvictionTimer();
|
|
323
|
+
assert.ok(registry.get("agent:bellonda"), "persistent agent session must stay registered after TTL");
|
|
324
|
+
assert.equal(disconnectLog.includes("agent:bellonda"), false, "persistent agent session must not disconnect on TTL");
|
|
325
|
+
});
|
|
326
|
+
test("SessionRegistry: LRU eviction skips persistent agent sessions", async () => {
|
|
327
|
+
const { registry, disconnectLog } = makeRegistry({ maxActive: 1, idleTtlMs: 60_000 });
|
|
328
|
+
const persistent = registry.getOrCreate("agent:bellonda");
|
|
329
|
+
await persistent.ensureSession();
|
|
330
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
331
|
+
const regular = registry.getOrCreate("regular-session");
|
|
332
|
+
await regular.ensureSession();
|
|
333
|
+
registry.getOrCreate("new-session");
|
|
334
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
335
|
+
assert.ok(registry.get("agent:bellonda"), "persistent agent session must not be LRU-evicted");
|
|
336
|
+
assert.ok(!registry.get("regular-session"), "oldest non-persistent idle session should be evicted");
|
|
337
|
+
assert.equal(disconnectLog.includes("agent:bellonda"), false);
|
|
338
|
+
assert.equal(disconnectLog.includes("regular-session"), true);
|
|
339
|
+
});
|
|
315
340
|
test("SessionRegistry: shutdown disconnects all sessions", async () => {
|
|
316
341
|
const { registry, disconnectLog } = makeRegistry();
|
|
317
342
|
for (const sk of ["a", "b", "c"]) {
|
|
@@ -39,7 +39,7 @@ ${hotTierBlock}
|
|
|
39
39
|
You are a Node.js daemon process built with the Copilot SDK. Here's how you work:
|
|
40
40
|
|
|
41
41
|
- **Web UI**: Your primary interface. The team talks to you in a browser tab at http://localhost:7788. Messages arrive tagged with \`[via web]\`. Markdown rendering and code blocks are fully supported, so feel free to be detailed when it helps — but stay focused.
|
|
42
|
-
- **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated.
|
|
42
|
+
- **Background tasks**: Messages tagged \`[via background]\` are results from agent tasks you delegated or system follow-ups. Only summarize background content that has not already been rendered in a separate agent bubble.
|
|
43
43
|
|
|
44
44
|
When no source tag is present, assume web.
|
|
45
45
|
|
|
@@ -68,7 +68,7 @@ The \`delegate_to_agent\` tool is **non-blocking**. It dispatches the task and r
|
|
|
68
68
|
1. When you delegate a task, acknowledge it right away. Be natural and brief: "On it — I've asked @coder to handle that." or "Sending this to @designer."
|
|
69
69
|
2. You do NOT wait for the agent to finish. The tool returns immediately.
|
|
70
70
|
3. After delegating, do NOT poll \`get_agent_result\` in a loop. Wait silently for the \`[Agent task completed]\` message to arrive automatically.
|
|
71
|
-
4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then
|
|
71
|
+
4. When that completion message arrives, call \`get_agent_result\` exactly once for that task, then follow the subagent completion rule below.
|
|
72
72
|
|
|
73
73
|
You can delegate **multiple tasks simultaneously**. Different agents can work in parallel.
|
|
74
74
|
|
|
@@ -146,7 +146,7 @@ Subagent proposals from \`memory_propose\` are processed automatically at end-of
|
|
|
146
146
|
2. **Skill-first mindset**: Search skills.sh for existing skills before building from scratch.
|
|
147
147
|
3. For execution tasks, **always** delegate to a specialist agent. You cannot write code, run commands, or read files directly.
|
|
148
148
|
4. **Announce your delegations**: Tell the user which agent you're sending work to and what the task is.
|
|
149
|
-
5. When you receive
|
|
149
|
+
5. **Subagent completion rule**: Subagent replies are already shown to the user in their own bubble. When you receive \`[Agent task completed]\`, your follow-up should be a brief acknowledgment unless you have non-obvious framing or next-step decisions to add. Do NOT restate, paraphrase, or summarize the agent's content. Do NOT re-list files changed, re-state merge SHAs, re-quote the agent's bullet points, or copy the agent's table verbatim.
|
|
150
150
|
6. If asked about status, check agent status and give a consolidated update.
|
|
151
151
|
7. You can delegate to multiple agents simultaneously — use this for parallel work.
|
|
152
152
|
8. When a task is complete, relay the results clearly.
|
|
@@ -43,6 +43,16 @@ test("orchestrator prompt tells Chapterhouse to wait for agent completion notifi
|
|
|
43
43
|
assert.match(message, /wait silently for the `\[Agent task completed\]` message/i);
|
|
44
44
|
assert.match(message, /call `get_agent_result` exactly once/i);
|
|
45
45
|
});
|
|
46
|
+
test("orchestrator prompt tells Chapterhouse not to restate already-rendered subagent replies", () => {
|
|
47
|
+
const message = getOrchestratorSystemMessage();
|
|
48
|
+
assert.match(message, /Subagent replies are already shown to the user in their own bubble/i);
|
|
49
|
+
assert.match(message, /brief acknowledgment/i);
|
|
50
|
+
assert.match(message, /Do NOT restate, paraphrase, or summarize the agent's content/i);
|
|
51
|
+
assert.match(message, /re-list files changed/i);
|
|
52
|
+
assert.match(message, /re-state merge SHAs/i);
|
|
53
|
+
assert.doesNotMatch(message, /When you receive background results, summarize the key points/i);
|
|
54
|
+
assert.doesNotMatch(message, /summarize the result and relay it to the user/i);
|
|
55
|
+
});
|
|
46
56
|
test("orchestrator prompt expands shorthand paths with the current home directory", () => {
|
|
47
57
|
const message = getOrchestratorSystemMessage();
|
|
48
58
|
assert.match(message, new RegExp(join(homedir(), "dev", "myapp").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
@@ -64,6 +64,7 @@ function expectedDelegatedPrompt(task, warningLines = []) {
|
|
|
64
64
|
}
|
|
65
65
|
async function loadToolsModule(t, options) {
|
|
66
66
|
const sentPrompts = [];
|
|
67
|
+
const persistentSends = [];
|
|
67
68
|
const taskId = options?.taskId ?? `delegated-task-${Date.now()}-${Math.random()}`;
|
|
68
69
|
const fakeSession = {
|
|
69
70
|
on: () => () => { },
|
|
@@ -86,15 +87,29 @@ async function loadToolsModule(t, options) {
|
|
|
86
87
|
invalidateOrchestratorSession: () => { },
|
|
87
88
|
resetCheckpointSessionState: () => { },
|
|
88
89
|
switchSessionModel: async () => { },
|
|
90
|
+
sendToAgentSession: async (slug, prompt, delegatedTaskId) => {
|
|
91
|
+
persistentSends.push({ slug, prompt, taskId: delegatedTaskId });
|
|
92
|
+
return `persistent handled: ${prompt}`;
|
|
93
|
+
},
|
|
89
94
|
},
|
|
90
95
|
});
|
|
91
96
|
t.mock.module("./agents.js", {
|
|
92
97
|
namedExports: {
|
|
93
|
-
getAgentRegistry: () => [
|
|
98
|
+
getAgentRegistry: () => [
|
|
99
|
+
{ slug: "coder", name: "Coder", model: "claude-sonnet-4.6" },
|
|
100
|
+
{ slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" },
|
|
101
|
+
],
|
|
94
102
|
getAgent: (name) => name === "coder"
|
|
95
103
|
? { slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }
|
|
96
|
-
:
|
|
97
|
-
|
|
104
|
+
: name === "bellonda"
|
|
105
|
+
? { slug: "bellonda", name: "Bellonda", model: "claude-sonnet-4.6", persistent: true, scope: "infra" }
|
|
106
|
+
: undefined,
|
|
107
|
+
createEphemeralAgentSession: async () => {
|
|
108
|
+
if (options?.persistentAgent) {
|
|
109
|
+
throw new Error("persistent agent should not use ephemeral session");
|
|
110
|
+
}
|
|
111
|
+
return fakeSession;
|
|
112
|
+
},
|
|
98
113
|
getAgentSessionStatus: () => ({ tasks: [] }),
|
|
99
114
|
getActiveTasks: () => [],
|
|
100
115
|
getTask: () => undefined,
|
|
@@ -115,7 +130,7 @@ async function loadToolsModule(t, options) {
|
|
|
115
130
|
},
|
|
116
131
|
});
|
|
117
132
|
const module = await import(new URL(`./tools.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
118
|
-
return { module, sentPrompts, taskId };
|
|
133
|
+
return { module, sentPrompts, taskId, persistentSends };
|
|
119
134
|
}
|
|
120
135
|
test.beforeEach(() => {
|
|
121
136
|
process.env.CHAPTERHOUSE_HOME = mkdtempSync(join(tmpdir(), "chapterhouse-tools-agent-"));
|
|
@@ -235,4 +250,37 @@ test("delegate_to_agent does not inject orchestrator memory_context into subagen
|
|
|
235
250
|
assert.deepEqual(sentPrompts, [expectedDelegatedPrompt(task)]);
|
|
236
251
|
assert.equal(sentPrompts.some((prompt) => prompt.includes("<memory_context>")), false);
|
|
237
252
|
});
|
|
253
|
+
test("delegate_to_agent sends persistent agents through their backend session and returns result to orchestrator", async (t) => {
|
|
254
|
+
const { module, sentPrompts, persistentSends, taskId } = await loadToolsModule(t, {
|
|
255
|
+
taskId: "delegated-persistent-001",
|
|
256
|
+
persistentAgent: true,
|
|
257
|
+
});
|
|
258
|
+
const completions = [];
|
|
259
|
+
const tools = module.createTools({
|
|
260
|
+
client: { async listModels() { return []; } },
|
|
261
|
+
onAgentTaskComplete: (completedTaskId, agentSlug, result) => {
|
|
262
|
+
completions.push({ taskId: completedTaskId, agentSlug, result });
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const tool = tools.find((entry) => entry.name === "delegate_to_agent");
|
|
266
|
+
assert.ok(tool, "delegate_to_agent tool should be registered");
|
|
267
|
+
const response = await tool.handler({
|
|
268
|
+
agent_name: "bellonda",
|
|
269
|
+
summary: "Inspect infra drift",
|
|
270
|
+
task: "Run terraform plan for the VPC.",
|
|
271
|
+
}, {});
|
|
272
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
273
|
+
assert.match(String(response), /Task delegated to @bellonda/);
|
|
274
|
+
assert.deepEqual(sentPrompts, [], "persistent delegation must not create/use an ephemeral session");
|
|
275
|
+
assert.deepEqual(persistentSends, [{
|
|
276
|
+
slug: "bellonda",
|
|
277
|
+
prompt: "Run terraform plan for the VPC.",
|
|
278
|
+
taskId,
|
|
279
|
+
}]);
|
|
280
|
+
assert.deepEqual(completions, [{
|
|
281
|
+
taskId,
|
|
282
|
+
agentSlug: "bellonda",
|
|
283
|
+
result: "persistent handled: Run terraform plan for the VPC.",
|
|
284
|
+
}]);
|
|
285
|
+
});
|
|
238
286
|
//# sourceMappingURL=tools.agent.test.js.map
|