chapterhouse 0.4.2 → 0.5.0
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/agents/bellonda.agent.md +11 -0
- package/agents/hwi-noree.agent.md +12 -0
- package/dist/api/server.js +39 -2
- package/dist/api/server.test.js +20 -0
- package/dist/api/turn-sse.integration.test.js +12 -0
- package/dist/copilot/agents.js +16 -4
- package/dist/copilot/agents.test.js +43 -1
- package/dist/copilot/orchestrator.js +173 -32
- package/dist/copilot/orchestrator.test.js +236 -20
- package/dist/copilot/session-manager.js +11 -2
- package/dist/copilot/session-manager.test.js +25 -0
- package/dist/copilot/tools.agent.test.js +52 -4
- package/dist/copilot/tools.js +265 -18
- package/dist/copilot/tools.memory.test.js +175 -2
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/active-scope.js +9 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +108 -0
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/index.js +2 -1
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +130 -17
- package/dist/store/db.test.js +61 -5
- package/package.json +1 -1
- package/web/dist/assets/{index-B_cCSHan.js → index-BfHqP3-C.js} +87 -87
- package/web/dist/assets/{index-B_cCSHan.js.map → index-BfHqP3-C.js.map} +1 -1
- package/web/dist/assets/index-_O6AoWOS.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
|
@@ -29,6 +29,9 @@ function createFakeClient(state) {
|
|
|
29
29
|
if (state.sendErrorMessage) {
|
|
30
30
|
throw new Error(state.sendErrorMessage);
|
|
31
31
|
}
|
|
32
|
+
for (const delta of state.sendDeltas ?? []) {
|
|
33
|
+
this.emit("assistant.message_delta", { deltaContent: delta });
|
|
34
|
+
}
|
|
32
35
|
return { data: { content: state.sendResult } };
|
|
33
36
|
}
|
|
34
37
|
async setModel(model) {
|
|
@@ -102,7 +105,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
102
105
|
{ taskId: "task-2", agentSlug: "designer", status: "done", description: "Ignore me" },
|
|
103
106
|
],
|
|
104
107
|
registry: [
|
|
105
|
-
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
108
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
106
109
|
],
|
|
107
110
|
sendResult: "Finished successfully",
|
|
108
111
|
taskEvents: new Map(),
|
|
@@ -150,11 +153,35 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
150
153
|
t.mock.module("../memory/hot-tier.js", {
|
|
151
154
|
namedExports: {
|
|
152
155
|
renderHotTierForActiveScope: () => state.hotTierXml ?? "",
|
|
156
|
+
getHotTierEntries: (scopeId) => ({
|
|
157
|
+
scope: scopeId !== undefined
|
|
158
|
+
? makeScope(scopeId, "infra", "Infra", "Infrastructure work.")
|
|
159
|
+
: state.activeScope ?? null,
|
|
160
|
+
entities: [],
|
|
161
|
+
observations: [],
|
|
162
|
+
decisions: [],
|
|
163
|
+
actionItems: [],
|
|
164
|
+
}),
|
|
165
|
+
renderHotTierXML: (entries) => entries.scope ? state.hotTierByScope?.get(entries.scope.slug) ?? "" : "",
|
|
153
166
|
},
|
|
154
167
|
});
|
|
155
168
|
t.mock.module("../memory/active-scope.js", {
|
|
156
169
|
namedExports: {
|
|
157
170
|
getActiveScope: () => state.activeScope ?? null,
|
|
171
|
+
withActiveScope: async (_slug, fn) => fn(),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
t.mock.module("../memory/scopes.js", {
|
|
175
|
+
namedExports: {
|
|
176
|
+
getScope: (slugOrId) => {
|
|
177
|
+
if (slugOrId === "infra" || slugOrId === 3) {
|
|
178
|
+
return makeScope(3, "infra", "Infra", "Infrastructure work.");
|
|
179
|
+
}
|
|
180
|
+
if (slugOrId === "brian" || slugOrId === 5) {
|
|
181
|
+
return makeScope(5, "brian", "Brian", "Brian's personal context.");
|
|
182
|
+
}
|
|
183
|
+
return state.activeScope ?? null;
|
|
184
|
+
},
|
|
158
185
|
},
|
|
159
186
|
});
|
|
160
187
|
t.mock.module("../memory/checkpoint.js", {
|
|
@@ -249,8 +276,14 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
249
276
|
});
|
|
250
277
|
t.mock.module("../store/db.js", {
|
|
251
278
|
namedExports: {
|
|
252
|
-
logConversation: (role, content, source) => {
|
|
253
|
-
state.dbLogs.push({
|
|
279
|
+
logConversation: (role, content, source, sessionKey, metadata) => {
|
|
280
|
+
state.dbLogs.push({
|
|
281
|
+
role,
|
|
282
|
+
content,
|
|
283
|
+
source,
|
|
284
|
+
...(sessionKey && sessionKey !== "default" ? { sessionKey } : {}),
|
|
285
|
+
...metadata,
|
|
286
|
+
});
|
|
254
287
|
},
|
|
255
288
|
getState: (key) => state.store.get(key),
|
|
256
289
|
setState: (key, value) => {
|
|
@@ -336,10 +369,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
336
369
|
namedExports: {
|
|
337
370
|
loadAgents: () => {
|
|
338
371
|
state.loadAgentsCalls++;
|
|
339
|
-
return
|
|
340
|
-
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6" },
|
|
341
|
-
{ slug: "designer", name: "Wash", model: "claude-opus-4.6" },
|
|
342
|
-
];
|
|
372
|
+
return state.registry;
|
|
343
373
|
},
|
|
344
374
|
ensureDefaultAgents: () => {
|
|
345
375
|
state.ensureDefaultAgentsCalls++;
|
|
@@ -348,6 +378,7 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
348
378
|
state.clearActiveTasksCalls++;
|
|
349
379
|
},
|
|
350
380
|
getAgentRegistry: () => state.registry,
|
|
381
|
+
getAgent: (slug) => state.registry.find((agent) => agent.slug === slug),
|
|
351
382
|
getActiveAgent: () => undefined,
|
|
352
383
|
setActiveAgent: (channelKey, agentSlug) => {
|
|
353
384
|
state.setActiveAgentCalls.push({ channelKey, agentSlug });
|
|
@@ -360,6 +391,10 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
360
391
|
state.buildAgentRosterArgs.push(projectRoot);
|
|
361
392
|
return "@coder @designer";
|
|
362
393
|
},
|
|
394
|
+
composeAgentSystemMessage: (agent) => agent.systemMessage ?? `You are ${agent.slug}.`,
|
|
395
|
+
filterToolsForAgent: (_agent, tools) => tools,
|
|
396
|
+
bindToolsToAgent: (_agentSlug, tools) => tools,
|
|
397
|
+
withToolTaskContext: (_taskId, fn) => fn(),
|
|
363
398
|
getActiveTasks: () => state.activeTasks,
|
|
364
399
|
completeTask: () => { },
|
|
365
400
|
failTask: () => { },
|
|
@@ -510,6 +545,143 @@ test("initOrchestrator omits hot-tier XML when memory injection is disabled", as
|
|
|
510
545
|
await orchestrator.initOrchestrator(client);
|
|
511
546
|
assert.equal(state.systemOptions?.hotTierXml, undefined);
|
|
512
547
|
});
|
|
548
|
+
test("initOrchestrator prewarms persistent agent sessions with scoped hot-tier context", async (t) => {
|
|
549
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
550
|
+
registry: [
|
|
551
|
+
{ slug: "coder", name: "Kaylee", model: "claude-sonnet-4.6", systemMessage: "You are Kaylee." },
|
|
552
|
+
{
|
|
553
|
+
slug: "bellonda",
|
|
554
|
+
name: "Bellonda",
|
|
555
|
+
model: "claude-sonnet-4.6",
|
|
556
|
+
systemMessage: "You are Bellonda.",
|
|
557
|
+
persistent: true,
|
|
558
|
+
scope: "infra",
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
hotTierByScope: new Map([
|
|
562
|
+
["infra", "<memory_context scope=\"infra\"><observation>terraform drift</observation></memory_context>"],
|
|
563
|
+
]),
|
|
564
|
+
});
|
|
565
|
+
await orchestrator.initOrchestrator(client);
|
|
566
|
+
assert.equal(state.createSessionCalls.length, 2);
|
|
567
|
+
const persistentCall = state.createSessionCalls.find((call) => String(call.systemMessage?.content ?? "").includes("Bellonda"));
|
|
568
|
+
assert.ok(persistentCall, "expected a prewarmed Bellonda session");
|
|
569
|
+
assert.equal(persistentCall.systemMessage.content.includes("Bellonda"), true);
|
|
570
|
+
assert.equal(persistentCall.systemMessage.content.includes("scope=\"infra\""), true);
|
|
571
|
+
assert.deepEqual(state.dbWrites.filter((write) => write.sql.includes("copilot_sessions")), []);
|
|
572
|
+
});
|
|
573
|
+
test("sendToOrchestrator routes agent session keys directly to persistent agent sessions", async (t) => {
|
|
574
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
575
|
+
config: {
|
|
576
|
+
copilotModel: "claude-sonnet-4.6",
|
|
577
|
+
selfEditEnabled: true,
|
|
578
|
+
memoryInjectEnabled: true,
|
|
579
|
+
},
|
|
580
|
+
registry: [
|
|
581
|
+
{
|
|
582
|
+
slug: "bellonda",
|
|
583
|
+
name: "Bellonda",
|
|
584
|
+
model: "claude-sonnet-4.6",
|
|
585
|
+
systemMessage: "You are Bellonda.",
|
|
586
|
+
persistent: true,
|
|
587
|
+
scope: "infra",
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
sendResult: "Infra answer",
|
|
591
|
+
hotTierByScope: new Map([
|
|
592
|
+
["infra", "<memory_context scope=\"infra\"><observation>vpc state</observation></memory_context>"],
|
|
593
|
+
]),
|
|
594
|
+
});
|
|
595
|
+
await orchestrator.initOrchestrator(client);
|
|
596
|
+
state.sessionPrompts.length = 0;
|
|
597
|
+
state.routerArgs.length = 0;
|
|
598
|
+
const final = await new Promise((resolve) => {
|
|
599
|
+
orchestrator.sendToOrchestrator("What changed in prod?", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
|
|
600
|
+
if (done)
|
|
601
|
+
resolve(text);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
assert.equal(final, "Infra answer");
|
|
605
|
+
assert.deepEqual(state.routerArgs, [], "direct persistent-agent chat must not use orchestrator model routing");
|
|
606
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] What changed in prod?" }]);
|
|
607
|
+
assert.deepEqual(state.dbLogs.map((entry) => ({
|
|
608
|
+
role: entry.role,
|
|
609
|
+
content: entry.content,
|
|
610
|
+
source: entry.source,
|
|
611
|
+
sessionKey: entry.sessionKey,
|
|
612
|
+
})), [
|
|
613
|
+
{ role: "user", content: "What changed in prod?", source: "web", sessionKey: "agent:bellonda" },
|
|
614
|
+
{ role: "assistant", content: "Infra answer", source: "web", sessionKey: "agent:bellonda" },
|
|
615
|
+
]);
|
|
616
|
+
});
|
|
617
|
+
test("sendToAgentSession annotates delegated turns as via chapterhouse", async (t) => {
|
|
618
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
619
|
+
config: {
|
|
620
|
+
copilotModel: "claude-sonnet-4.6",
|
|
621
|
+
selfEditEnabled: true,
|
|
622
|
+
memoryInjectEnabled: true,
|
|
623
|
+
},
|
|
624
|
+
registry: [
|
|
625
|
+
{
|
|
626
|
+
slug: "bellonda",
|
|
627
|
+
name: "Bellonda",
|
|
628
|
+
model: "claude-sonnet-4.6",
|
|
629
|
+
systemMessage: "You are Bellonda.",
|
|
630
|
+
persistent: true,
|
|
631
|
+
scope: "infra",
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
sendResult: "Delegated infra answer",
|
|
635
|
+
});
|
|
636
|
+
await orchestrator.initOrchestrator(client);
|
|
637
|
+
state.sessionPrompts.length = 0;
|
|
638
|
+
const final = await orchestrator.sendToAgentSession("bellonda", "Check deploy health", "task-bellonda-1");
|
|
639
|
+
assert.equal(final, "Delegated infra answer");
|
|
640
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via @chapterhouse] Check deploy health" }]);
|
|
641
|
+
assert.deepEqual(state.dbLogs.map((entry) => ({
|
|
642
|
+
role: entry.role,
|
|
643
|
+
content: entry.content,
|
|
644
|
+
source: entry.source,
|
|
645
|
+
sessionKey: entry.sessionKey,
|
|
646
|
+
})), [
|
|
647
|
+
{ role: "user", content: "Check deploy health", source: "delegated", sessionKey: "agent:bellonda" },
|
|
648
|
+
{ role: "assistant", content: "Delegated infra answer", source: "delegated", sessionKey: "agent:bellonda" },
|
|
649
|
+
]);
|
|
650
|
+
});
|
|
651
|
+
test("sendToOrchestrator preserves literal mentions inside persistent agent sessions", async (t) => {
|
|
652
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
653
|
+
registry: [
|
|
654
|
+
{
|
|
655
|
+
slug: "bellonda",
|
|
656
|
+
name: "Bellonda",
|
|
657
|
+
model: "claude-sonnet-4.6",
|
|
658
|
+
systemMessage: "You are Bellonda.",
|
|
659
|
+
persistent: true,
|
|
660
|
+
scope: "infra",
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
slug: "hwi-noree",
|
|
664
|
+
name: "Hwi Noree",
|
|
665
|
+
model: "claude-sonnet-4.6",
|
|
666
|
+
systemMessage: "You are Hwi Noree.",
|
|
667
|
+
persistent: true,
|
|
668
|
+
scope: "brian",
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
parseMentionResult: { agentSlug: "hwi-noree", message: "please archive this" },
|
|
672
|
+
});
|
|
673
|
+
await orchestrator.initOrchestrator(client);
|
|
674
|
+
state.sessionPrompts.length = 0;
|
|
675
|
+
state.setActiveAgentCalls.length = 0;
|
|
676
|
+
await new Promise((resolve) => {
|
|
677
|
+
orchestrator.sendToOrchestrator("@hwi-noree please archive this", { type: "sse-web", sessionKey: "agent:bellonda" }, (text, done) => {
|
|
678
|
+
if (done)
|
|
679
|
+
resolve(text);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] @hwi-noree please archive this" }]);
|
|
683
|
+
assert.deepEqual(state.setActiveAgentCalls, []);
|
|
684
|
+
});
|
|
513
685
|
test("sendToOrchestrator logs both sides, remembers web auth context, and records routing state", async (t) => {
|
|
514
686
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
515
687
|
config: {
|
|
@@ -559,9 +731,11 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
|
|
|
559
731
|
{ direction: "in", source: "web", text: "Summarize the deployment" },
|
|
560
732
|
{ direction: "out", source: "web", text: "All green" },
|
|
561
733
|
]);
|
|
734
|
+
const loggedTurnId = state.dbLogs[0]?.turnId;
|
|
735
|
+
assert.equal(typeof loggedTurnId, "string");
|
|
562
736
|
assert.deepEqual(state.dbLogs, [
|
|
563
|
-
{ role: "user", content: "Summarize the deployment", source: "web" },
|
|
564
|
-
{ role: "assistant", content: "All green", source: "web" },
|
|
737
|
+
{ role: "user", content: "Summarize the deployment", source: "web", turnId: loggedTurnId },
|
|
738
|
+
{ role: "assistant", content: "All green", source: "web", turnId: loggedTurnId },
|
|
565
739
|
]);
|
|
566
740
|
assert.equal(state.episodeWrites, 1);
|
|
567
741
|
});
|
|
@@ -999,13 +1173,14 @@ test("@mentions route through the orchestrator session without invoking the mode
|
|
|
999
1173
|
assert.deepEqual(state.routerArgs, []);
|
|
1000
1174
|
assert.deepEqual(state.sessionPrompts, [{ prompt: "[via web] polish the landing page" }]);
|
|
1001
1175
|
});
|
|
1002
|
-
test("feedAgentResult
|
|
1176
|
+
test("feedAgentResult emits an attributed short agent reply before starting the orchestrator acknowledgement", async (t) => {
|
|
1003
1177
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1004
1178
|
config: {
|
|
1005
1179
|
copilotModel: "claude-sonnet-4.6",
|
|
1006
1180
|
selfEditEnabled: true,
|
|
1007
1181
|
},
|
|
1008
1182
|
sendResult: "Agent complete",
|
|
1183
|
+
sendDeltas: ["Agent complete"],
|
|
1009
1184
|
taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
|
|
1010
1185
|
});
|
|
1011
1186
|
await orchestrator.initOrchestrator(client);
|
|
@@ -1013,28 +1188,69 @@ test("feedAgentResult injects a background completion turn and proactively notif
|
|
|
1013
1188
|
const notified = new Promise((resolve) => {
|
|
1014
1189
|
orchestrator.setProactiveNotify(resolve);
|
|
1015
1190
|
});
|
|
1016
|
-
|
|
1191
|
+
const agentReply = "Fixed the flaky test. ".repeat(40);
|
|
1192
|
+
orchestrator.feedAgentResult("task-9", "coder", agentReply);
|
|
1193
|
+
assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete"], "short agent replies should fully emit before the orchestrator acknowledgement starts");
|
|
1194
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1017
1195
|
assert.equal(await notified, "Agent complete");
|
|
1018
1196
|
assert.deepEqual(state.sessionPrompts, [{
|
|
1019
|
-
prompt: "[Agent task completed] @coder finished task task-9
|
|
1197
|
+
prompt: "[Agent task completed] @coder finished task task-9. Their reply has been shown to the user. Acknowledge briefly.",
|
|
1020
1198
|
}]);
|
|
1199
|
+
assert.equal(state.sessionPrompts[0]?.prompt.includes(agentReply), false, "orchestrator notification must not include the full agent reply body");
|
|
1200
|
+
const started = events.filter((event) => event.type === "turn:started");
|
|
1201
|
+
const deltas = events.filter((event) => event.type === "turn:delta");
|
|
1202
|
+
const completed = events.filter((event) => event.type === "turn:complete");
|
|
1203
|
+
assert.equal(started.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:started");
|
|
1204
|
+
assert.equal(deltas.length, 2, "agent reply plus orchestrator acknowledgement should each emit one delta");
|
|
1205
|
+
assert.equal(completed.length, 2, "agent reply plus orchestrator acknowledgement should each emit turn:complete");
|
|
1206
|
+
assert.equal(started[0]?.agentSlug, "coder");
|
|
1207
|
+
assert.equal(started[0]?.agentDisplayName, "Kaylee");
|
|
1208
|
+
assert.equal(started[0]?.prompt, "");
|
|
1021
1209
|
assert.deepEqual(state.dbLogs, [
|
|
1022
1210
|
{
|
|
1023
1211
|
role: "agent_completion",
|
|
1024
|
-
content:
|
|
1212
|
+
content: agentReply,
|
|
1025
1213
|
source: "background",
|
|
1214
|
+
sessionKey: "chat:bg-lifecycle",
|
|
1215
|
+
agentSlug: "coder",
|
|
1216
|
+
agentDisplayName: "Kaylee",
|
|
1217
|
+
turnId: started[0]?.turnId,
|
|
1026
1218
|
},
|
|
1027
1219
|
{
|
|
1028
1220
|
role: "assistant",
|
|
1029
1221
|
content: "Agent complete",
|
|
1030
1222
|
source: "background",
|
|
1223
|
+
sessionKey: "chat:bg-lifecycle",
|
|
1224
|
+
turnId: started[1]?.turnId,
|
|
1031
1225
|
},
|
|
1032
1226
|
]);
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
assert.equal(
|
|
1036
|
-
assert.equal(completed
|
|
1037
|
-
assert.
|
|
1227
|
+
assert.equal(deltas[0]?.turnId, started[0]?.turnId);
|
|
1228
|
+
assert.deepEqual(deltas[0]?.part, { type: "text", text: agentReply });
|
|
1229
|
+
assert.equal(completed[0]?.turnId, started[0]?.turnId);
|
|
1230
|
+
assert.equal(completed[0]?.finalMessage, agentReply);
|
|
1231
|
+
assert.notEqual(started[0]?.turnId, started[1]?.turnId, "agent reply and orchestrator acknowledgement need distinct turns");
|
|
1232
|
+
assert.deepEqual(events.map((event) => event.type), ["turn:started", "turn:delta", "turn:complete", "turn:started", "turn:delta", "turn:complete"]);
|
|
1233
|
+
});
|
|
1234
|
+
test("feedAgentResult emits a delta even when the agent result is empty", async (t) => {
|
|
1235
|
+
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
1236
|
+
config: { copilotModel: "claude-sonnet-4.6", selfEditEnabled: true },
|
|
1237
|
+
sendResult: "Acknowledged",
|
|
1238
|
+
taskSessionKeys: new Map([["task-empty", "chat:bg-empty"]]),
|
|
1239
|
+
});
|
|
1240
|
+
await orchestrator.initOrchestrator(client);
|
|
1241
|
+
const events = captureSessionEvents(t, "chat:bg-empty");
|
|
1242
|
+
const notified = new Promise((resolve) => {
|
|
1243
|
+
orchestrator.setProactiveNotify(resolve);
|
|
1244
|
+
});
|
|
1245
|
+
orchestrator.feedAgentResult("task-empty", "coder", "");
|
|
1246
|
+
assert.equal(await notified, "Acknowledged");
|
|
1247
|
+
const agentStarted = events.find((event) => event.type === "turn:started" && event.agentSlug === "coder");
|
|
1248
|
+
assert.ok(agentStarted, "agent reply should emit a started event");
|
|
1249
|
+
const deltas = events
|
|
1250
|
+
.filter((event) => event.type === "turn:delta")
|
|
1251
|
+
.filter((event) => event.turnId === agentStarted.turnId);
|
|
1252
|
+
assert.equal(deltas.length, 1);
|
|
1253
|
+
assert.deepEqual(deltas[0]?.part, { type: "text", text: "" });
|
|
1038
1254
|
});
|
|
1039
1255
|
test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
|
|
1040
1256
|
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
@@ -1227,11 +1443,11 @@ test("feedAgentResult routes to a non-default session when the task's session_ke
|
|
|
1227
1443
|
// A second createSession call proves the orchestrator opened a fresh non-default session
|
|
1228
1444
|
// rather than reusing the already-open default session.
|
|
1229
1445
|
assert.equal(state.createSessionCalls.length, sessionsAfterInit + 1, "feedAgentResult should spin up a non-default session, not recycle the default one");
|
|
1230
|
-
// The prompt must reference the task and agent
|
|
1446
|
+
// The prompt must reference the task and agent but not include the full reply body.
|
|
1231
1447
|
const prompt = state.sessionPrompts.at(-1);
|
|
1232
1448
|
assert.ok(prompt?.prompt.includes("chat-task-1"), "prompt should reference the task id");
|
|
1233
1449
|
assert.ok(prompt?.prompt.includes("coder"), "prompt should reference the agent slug");
|
|
1234
|
-
assert.
|
|
1450
|
+
assert.equal(prompt?.prompt.includes("Feature done"), false, "prompt should not include the result text");
|
|
1235
1451
|
});
|
|
1236
1452
|
test("ensureOrchestratorSession cleans up in-flight promise on session creation failure", async (t) => {
|
|
1237
1453
|
const { orchestrator, state, 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"]) {
|
|
@@ -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
|