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.
@@ -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 turn and sends only a short orchestrator prompt", async (t) => {
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
- orchestrator.feedAgentResult("task-9", "coder", "Fixed the flaky test");
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. Their reply has been shown to the user. Acknowledge briefly.",
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("Fixed the flaky test"), false, "orchestrator notification must not include the full agent reply body");
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, 1, "agent reply should stream as one or more deltas");
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: "Fixed the flaky test",
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: "Fixed the flaky test" });
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, "Fixed the flaky test");
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.managers.size >= this.options.maxActive) {
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. Summarize and relay these to the team.
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 summarize the result and relay it to the user in a clear, concise way.
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 background results, summarize the key points. Don't relay the entire output verbatim.
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: () => [{ slug: "coder", name: "Coder", model: "claude-sonnet-4.6" }],
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
- : undefined,
97
- createEphemeralAgentSession: async () => fakeSession,
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