chapterhouse 0.3.19 → 0.3.21
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 +154 -3
- package/dist/api/server.test.js +461 -14
- package/dist/copilot/orchestrator.js +97 -13
- package/dist/copilot/orchestrator.test.js +409 -1
- package/dist/copilot/project-resolution.js +73 -0
- package/dist/copilot/project-resolution.test.js +124 -0
- package/dist/copilot/project-rule-warnings.js +73 -0
- package/dist/copilot/project-rule-warnings.test.js +46 -0
- package/dist/copilot/project-rules-injection.js +71 -0
- package/dist/copilot/project-rules-injection.test.js +84 -0
- package/dist/copilot/tools.agent.test.js +214 -0
- package/dist/copilot/tools.js +14 -3
- package/dist/store/db.js +4 -0
- package/dist/store/db.test.js +30 -0
- package/dist/wiki/frontmatter.js +1 -1
- package/dist/wiki/lint.js +37 -10
- package/dist/wiki/lint.test.js +72 -0
- package/dist/wiki/project-registry.js +160 -0
- package/dist/wiki/project-registry.test.js +72 -0
- package/dist/wiki/project-rules.js +155 -0
- package/dist/wiki/project-rules.test.js +217 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-9We9vWBC.js → index-B8dZzlmE.js} +66 -66
- package/web/dist/assets/index-B8dZzlmE.js.map +1 -0
- package/web/dist/assets/index-D9flFppK.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-9We9vWBC.js.map +0 -1
- package/web/dist/assets/index-DYx2idiH.css +0 -10
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { clearTurnLog, subscribeSession } from "./turn-event-log.js";
|
|
3
4
|
function createFakeClient(state) {
|
|
4
5
|
class FakeSession {
|
|
5
6
|
sessionId = "session-123";
|
|
@@ -25,6 +26,9 @@ function createFakeClient(state) {
|
|
|
25
26
|
state.pendingReject = reject;
|
|
26
27
|
});
|
|
27
28
|
}
|
|
29
|
+
if (state.sendErrorMessage) {
|
|
30
|
+
throw new Error(state.sendErrorMessage);
|
|
31
|
+
}
|
|
28
32
|
return { data: { content: state.sendResult } };
|
|
29
33
|
}
|
|
30
34
|
async setModel(model) {
|
|
@@ -96,6 +100,9 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
96
100
|
],
|
|
97
101
|
sendResult: "Finished successfully",
|
|
98
102
|
taskEvents: new Map(),
|
|
103
|
+
projectRegistry: {},
|
|
104
|
+
resolveProjectArgs: [],
|
|
105
|
+
loadProjectRulesArgs: [],
|
|
99
106
|
...overrides,
|
|
100
107
|
};
|
|
101
108
|
const client = createFakeClient(state);
|
|
@@ -187,6 +194,25 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
187
194
|
getWikiSummary: () => "wiki summary",
|
|
188
195
|
},
|
|
189
196
|
});
|
|
197
|
+
t.mock.module("../wiki/project-registry.js", {
|
|
198
|
+
namedExports: {
|
|
199
|
+
loadRegistry: () => state.projectRegistry,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
t.mock.module("../wiki/project-rules.js", {
|
|
203
|
+
namedExports: {
|
|
204
|
+
loadProjectRules: (slug) => {
|
|
205
|
+
state.loadProjectRulesArgs.push(slug);
|
|
206
|
+
if (state.projectRulesError) {
|
|
207
|
+
throw new Error(state.projectRulesError);
|
|
208
|
+
}
|
|
209
|
+
return state.projectRulesResult ?? {
|
|
210
|
+
found: false,
|
|
211
|
+
path: `pages/projects/${slug}/rules.md`,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|
|
190
216
|
t.mock.module("../paths.js", {
|
|
191
217
|
namedExports: {
|
|
192
218
|
SESSIONS_DIR: "/sessions",
|
|
@@ -242,9 +268,88 @@ async function loadOrchestratorModule(t, overrides = {}) {
|
|
|
242
268
|
state.healthCheckIntervalMs = delayMs;
|
|
243
269
|
return { ref() { }, unref() { } };
|
|
244
270
|
});
|
|
271
|
+
t.mock.module("./project-resolution.js", {
|
|
272
|
+
namedExports: {
|
|
273
|
+
resolveProject: (message, context, registry) => {
|
|
274
|
+
state.resolveProjectArgs.push({
|
|
275
|
+
message,
|
|
276
|
+
context: { ...context },
|
|
277
|
+
registry: { ...registry },
|
|
278
|
+
});
|
|
279
|
+
return state.resolvedProject ?? null;
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
});
|
|
245
283
|
const orchestrator = await import(new URL(`./orchestrator.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
246
284
|
return { orchestrator, state, client };
|
|
247
285
|
}
|
|
286
|
+
function createLoadedProjectRules() {
|
|
287
|
+
return {
|
|
288
|
+
found: true,
|
|
289
|
+
path: "pages/projects/chapterhouse/rules.md",
|
|
290
|
+
hard: {
|
|
291
|
+
auto_pr: false,
|
|
292
|
+
require_worktree: true,
|
|
293
|
+
pr_draft_default: true,
|
|
294
|
+
default_branch: "main",
|
|
295
|
+
commit_co_author: "Copilot <223556219+Copilot@users.noreply.github.com>",
|
|
296
|
+
test_command: "npm test",
|
|
297
|
+
build_command: "npm run build",
|
|
298
|
+
lint_command: "npm run lint:md",
|
|
299
|
+
require_clean_worktree: true,
|
|
300
|
+
},
|
|
301
|
+
soft: "- Keep tests green.\n- Prefer explicit names.\n",
|
|
302
|
+
warnings: [],
|
|
303
|
+
metadata: {},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function expectedWarningLines() {
|
|
307
|
+
return [
|
|
308
|
+
"⚠️ Project rule warning: this task may violate `auto_pr: false` — proceeding anyway.",
|
|
309
|
+
"⚠️ Project rule warning: this task may violate `require_worktree: true` — proceeding anyway.",
|
|
310
|
+
"⚠️ Project rule warning: this task may violate `pr_draft_default: true` — proceeding anyway.",
|
|
311
|
+
"⚠️ Project rule warning: this task may violate `default_branch: main` — proceeding anyway.",
|
|
312
|
+
"⚠️ Project rule warning: this task may violate `require_clean_worktree: true` — proceeding anyway.",
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
function expectedProjectRulesPrompt(userPrompt, warningLines = []) {
|
|
316
|
+
const warningBlock = warningLines.length > 0 ? `${warningLines.join("\n")}\n\n` : "";
|
|
317
|
+
return warningBlock
|
|
318
|
+
+ "## Active Project Rules\n\n"
|
|
319
|
+
+ "Project: chapterhouse\n"
|
|
320
|
+
+ "Registry path: /home/bjk/projects/chapterhouse\n"
|
|
321
|
+
+ "Rules page: pages/projects/chapterhouse/rules.md\n\n"
|
|
322
|
+
+ "Hard rules:\n"
|
|
323
|
+
+ "- auto_pr: false\n"
|
|
324
|
+
+ "- require_worktree: true\n"
|
|
325
|
+
+ "- pr_draft_default: true\n"
|
|
326
|
+
+ "- default_branch: main\n"
|
|
327
|
+
+ "- commit_co_author: Copilot <223556219+Copilot@users.noreply.github.com>\n"
|
|
328
|
+
+ "- test_command: npm test\n"
|
|
329
|
+
+ "- build_command: npm run build\n"
|
|
330
|
+
+ "- lint_command: npm run lint:md\n"
|
|
331
|
+
+ "- require_clean_worktree: true\n\n"
|
|
332
|
+
+ "Soft rules:\n"
|
|
333
|
+
+ "- Keep tests green.\n"
|
|
334
|
+
+ "- Prefer explicit names.\n\n"
|
|
335
|
+
+ "These rules are advisory. If a planned action may violate a hard rule, emit a visible warning and proceed unless the user says otherwise.\n\n"
|
|
336
|
+
+ userPrompt;
|
|
337
|
+
}
|
|
338
|
+
function captureSessionEvents(t, sessionKey) {
|
|
339
|
+
const events = [];
|
|
340
|
+
const seenTurnIds = new Set();
|
|
341
|
+
const unsubscribe = subscribeSession(sessionKey, (event) => {
|
|
342
|
+
events.push(event);
|
|
343
|
+
seenTurnIds.add(event.turnId);
|
|
344
|
+
});
|
|
345
|
+
t.after(() => {
|
|
346
|
+
unsubscribe();
|
|
347
|
+
for (const turnId of seenTurnIds) {
|
|
348
|
+
clearTurnLog(turnId);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
return events;
|
|
352
|
+
}
|
|
248
353
|
test("initOrchestrator falls back to an available model and eagerly creates a session", async (t) => {
|
|
249
354
|
const { orchestrator, state, client } = await loadOrchestratorModule(t);
|
|
250
355
|
await orchestrator.initOrchestrator(client);
|
|
@@ -311,6 +416,222 @@ test("sendToOrchestrator logs both sides, remembers web auth context, and record
|
|
|
311
416
|
]);
|
|
312
417
|
assert.equal(state.episodeWrites, 1);
|
|
313
418
|
});
|
|
419
|
+
test("sendToOrchestrator prepends active project rules when @project resolution succeeds", async (t) => {
|
|
420
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
421
|
+
config: {
|
|
422
|
+
copilotModel: "claude-sonnet-4.6",
|
|
423
|
+
selfEditEnabled: true,
|
|
424
|
+
},
|
|
425
|
+
routeResults: [
|
|
426
|
+
{
|
|
427
|
+
model: "claude-sonnet-4.6",
|
|
428
|
+
tier: "standard",
|
|
429
|
+
switched: false,
|
|
430
|
+
routerMode: "auto",
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
sendResult: "Scoped summary",
|
|
434
|
+
projectRegistry: {
|
|
435
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
436
|
+
},
|
|
437
|
+
resolvedProject: {
|
|
438
|
+
slug: "chapterhouse",
|
|
439
|
+
path: "/home/bjk/projects/chapterhouse",
|
|
440
|
+
source: "mention",
|
|
441
|
+
},
|
|
442
|
+
projectRulesResult: createLoadedProjectRules(),
|
|
443
|
+
});
|
|
444
|
+
await orchestrator.initOrchestrator(client);
|
|
445
|
+
const final = await new Promise((resolve) => {
|
|
446
|
+
orchestrator.sendToOrchestrator("@project:chapterhouse summarize the deployment", { type: "web", connectionId: "conn-project" }, (text, done) => {
|
|
447
|
+
if (done) {
|
|
448
|
+
resolve(text);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
assert.equal(final, "Scoped summary");
|
|
453
|
+
assert.deepEqual(state.routerArgs, [["[via web] @project:chapterhouse summarize the deployment", "claude-sonnet-4.6", []]]);
|
|
454
|
+
assert.deepEqual(state.resolveProjectArgs, [{
|
|
455
|
+
message: "[via web] @project:chapterhouse summarize the deployment",
|
|
456
|
+
context: {
|
|
457
|
+
projectPath: undefined,
|
|
458
|
+
},
|
|
459
|
+
registry: {
|
|
460
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
461
|
+
},
|
|
462
|
+
}]);
|
|
463
|
+
assert.deepEqual(state.loadProjectRulesArgs, ["chapterhouse"]);
|
|
464
|
+
assert.deepEqual(state.sessionPrompts, [{
|
|
465
|
+
prompt: expectedProjectRulesPrompt("[via web] @project:chapterhouse summarize the deployment"),
|
|
466
|
+
}]);
|
|
467
|
+
});
|
|
468
|
+
test("sendToOrchestrator prepends active project rules when the web project path resolves a project", async (t) => {
|
|
469
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
470
|
+
config: {
|
|
471
|
+
copilotModel: "claude-sonnet-4.6",
|
|
472
|
+
selfEditEnabled: true,
|
|
473
|
+
},
|
|
474
|
+
routeResults: [
|
|
475
|
+
{
|
|
476
|
+
model: "claude-sonnet-4.6",
|
|
477
|
+
tier: "standard",
|
|
478
|
+
switched: false,
|
|
479
|
+
routerMode: "auto",
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
sendResult: "Path summary",
|
|
483
|
+
projectRegistry: {
|
|
484
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
485
|
+
},
|
|
486
|
+
resolvedProject: {
|
|
487
|
+
slug: "chapterhouse",
|
|
488
|
+
path: "/home/bjk/projects/chapterhouse",
|
|
489
|
+
source: "selectedProjectPath",
|
|
490
|
+
},
|
|
491
|
+
projectRulesResult: createLoadedProjectRules(),
|
|
492
|
+
});
|
|
493
|
+
await orchestrator.initOrchestrator(client);
|
|
494
|
+
const final = await new Promise((resolve) => {
|
|
495
|
+
orchestrator.sendToOrchestrator("summarize the deployment", { type: "web", connectionId: "conn-path", projectPath: "/home/bjk/projects/chapterhouse/src/copilot" }, (text, done) => {
|
|
496
|
+
if (done) {
|
|
497
|
+
resolve(text);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
assert.equal(final, "Path summary");
|
|
502
|
+
assert.deepEqual(state.resolveProjectArgs, [{
|
|
503
|
+
message: "[via web] summarize the deployment",
|
|
504
|
+
context: {
|
|
505
|
+
projectPath: "/home/bjk/projects/chapterhouse/src/copilot",
|
|
506
|
+
},
|
|
507
|
+
registry: {
|
|
508
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
509
|
+
},
|
|
510
|
+
}]);
|
|
511
|
+
assert.deepEqual(state.loadProjectRulesArgs, ["chapterhouse"]);
|
|
512
|
+
assert.deepEqual(state.sessionPrompts, [{
|
|
513
|
+
prompt: expectedProjectRulesPrompt("[via web] summarize the deployment"),
|
|
514
|
+
}]);
|
|
515
|
+
});
|
|
516
|
+
test("sendToOrchestrator surfaces project rule warnings before the orchestrator response", async (t) => {
|
|
517
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
518
|
+
config: {
|
|
519
|
+
copilotModel: "claude-sonnet-4.6",
|
|
520
|
+
selfEditEnabled: true,
|
|
521
|
+
},
|
|
522
|
+
routeResults: [
|
|
523
|
+
{
|
|
524
|
+
model: "claude-sonnet-4.6",
|
|
525
|
+
tier: "standard",
|
|
526
|
+
switched: false,
|
|
527
|
+
routerMode: "auto",
|
|
528
|
+
},
|
|
529
|
+
],
|
|
530
|
+
sendResult: "Working on it",
|
|
531
|
+
projectRegistry: {
|
|
532
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
533
|
+
},
|
|
534
|
+
resolvedProject: {
|
|
535
|
+
slug: "chapterhouse",
|
|
536
|
+
path: "/home/bjk/projects/chapterhouse",
|
|
537
|
+
source: "mention",
|
|
538
|
+
},
|
|
539
|
+
projectRulesResult: createLoadedProjectRules(),
|
|
540
|
+
});
|
|
541
|
+
await orchestrator.initOrchestrator(client);
|
|
542
|
+
const callbackStates = [];
|
|
543
|
+
const prompt = "@project:chapterhouse start the refactor directly in the main checkout, open a non-draft PR against develop, and release from the dirty worktree";
|
|
544
|
+
const warningBlock = `${expectedWarningLines().join("\n")}\n\n`;
|
|
545
|
+
const final = await new Promise((resolve) => {
|
|
546
|
+
orchestrator.sendToOrchestrator(prompt, { type: "web", connectionId: "conn-warning" }, (text, done) => {
|
|
547
|
+
callbackStates.push({ text, done });
|
|
548
|
+
if (done) {
|
|
549
|
+
resolve(text);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
assert.equal(final, `${warningBlock}Working on it`);
|
|
554
|
+
assert.deepEqual(callbackStates, [
|
|
555
|
+
{ text: warningBlock, done: false },
|
|
556
|
+
{ text: `${warningBlock}Working on it`, done: true },
|
|
557
|
+
]);
|
|
558
|
+
assert.deepEqual(state.sessionPrompts, [{
|
|
559
|
+
prompt: expectedProjectRulesPrompt(`[via web] ${prompt}`, expectedWarningLines()),
|
|
560
|
+
}]);
|
|
561
|
+
});
|
|
562
|
+
test("sendToOrchestrator leaves the prompt unchanged when no project resolves", async (t) => {
|
|
563
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
564
|
+
config: {
|
|
565
|
+
copilotModel: "claude-sonnet-4.6",
|
|
566
|
+
selfEditEnabled: true,
|
|
567
|
+
},
|
|
568
|
+
routeResults: [
|
|
569
|
+
{
|
|
570
|
+
model: "claude-sonnet-4.6",
|
|
571
|
+
tier: "standard",
|
|
572
|
+
switched: false,
|
|
573
|
+
routerMode: "auto",
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
sendResult: "No rules",
|
|
577
|
+
projectRegistry: {
|
|
578
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
579
|
+
},
|
|
580
|
+
resolvedProject: null,
|
|
581
|
+
});
|
|
582
|
+
await orchestrator.initOrchestrator(client);
|
|
583
|
+
const final = await new Promise((resolve) => {
|
|
584
|
+
orchestrator.sendToOrchestrator("summarize the deployment", { type: "web", connectionId: "conn-no-project" }, (text, done) => {
|
|
585
|
+
if (done) {
|
|
586
|
+
resolve(text);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
assert.equal(final, "No rules");
|
|
591
|
+
assert.deepEqual(state.loadProjectRulesArgs, []);
|
|
592
|
+
assert.deepEqual(state.sessionPrompts, [{
|
|
593
|
+
prompt: "[via web] summarize the deployment",
|
|
594
|
+
}]);
|
|
595
|
+
});
|
|
596
|
+
test("sendToOrchestrator leaves the prompt unchanged when loading project rules fails", async (t) => {
|
|
597
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
598
|
+
config: {
|
|
599
|
+
copilotModel: "claude-sonnet-4.6",
|
|
600
|
+
selfEditEnabled: true,
|
|
601
|
+
},
|
|
602
|
+
routeResults: [
|
|
603
|
+
{
|
|
604
|
+
model: "claude-sonnet-4.6",
|
|
605
|
+
tier: "standard",
|
|
606
|
+
switched: false,
|
|
607
|
+
routerMode: "auto",
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
sendResult: "No rules",
|
|
611
|
+
projectRegistry: {
|
|
612
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
613
|
+
},
|
|
614
|
+
resolvedProject: {
|
|
615
|
+
slug: "chapterhouse",
|
|
616
|
+
path: "/home/bjk/projects/chapterhouse",
|
|
617
|
+
source: "mention",
|
|
618
|
+
},
|
|
619
|
+
projectRulesError: "invalid frontmatter",
|
|
620
|
+
});
|
|
621
|
+
await orchestrator.initOrchestrator(client);
|
|
622
|
+
const final = await new Promise((resolve) => {
|
|
623
|
+
orchestrator.sendToOrchestrator("@project:chapterhouse summarize the deployment", { type: "web", connectionId: "conn-project" }, (text, done) => {
|
|
624
|
+
if (done) {
|
|
625
|
+
resolve(text);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
assert.equal(final, "No rules");
|
|
630
|
+
assert.deepEqual(state.loadProjectRulesArgs, ["chapterhouse"]);
|
|
631
|
+
assert.deepEqual(state.sessionPrompts, [{
|
|
632
|
+
prompt: "[via web] @project:chapterhouse summarize the deployment",
|
|
633
|
+
}]);
|
|
634
|
+
});
|
|
314
635
|
test("@mentions route through the orchestrator session without invoking the model router", async (t) => {
|
|
315
636
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
316
637
|
config: {
|
|
@@ -340,8 +661,10 @@ test("feedAgentResult injects a background completion turn and proactively notif
|
|
|
340
661
|
selfEditEnabled: true,
|
|
341
662
|
},
|
|
342
663
|
sendResult: "Agent complete",
|
|
664
|
+
taskSessionKeys: new Map([["task-9", "chat:bg-lifecycle"]]),
|
|
343
665
|
});
|
|
344
666
|
await orchestrator.initOrchestrator(client);
|
|
667
|
+
const events = captureSessionEvents(t, "chat:bg-lifecycle");
|
|
345
668
|
const notified = new Promise((resolve) => {
|
|
346
669
|
orchestrator.setProactiveNotify(resolve);
|
|
347
670
|
});
|
|
@@ -362,6 +685,57 @@ test("feedAgentResult injects a background completion turn and proactively notif
|
|
|
362
685
|
source: "background",
|
|
363
686
|
},
|
|
364
687
|
]);
|
|
688
|
+
const started = events.filter((event) => event.type === "turn:started");
|
|
689
|
+
const completed = events.filter((event) => event.type === "turn:complete");
|
|
690
|
+
assert.equal(started.length, 1, "background turn should emit one turn:started event");
|
|
691
|
+
assert.equal(completed.length, 1, "background turn should emit one turn:complete event");
|
|
692
|
+
assert.equal(started[0]?.turnId, completed[0]?.turnId, "background lifecycle events must share the same turnId");
|
|
693
|
+
});
|
|
694
|
+
test("enqueueForSse emits exactly one turn lifecycle pair for sse-web turns", async (t) => {
|
|
695
|
+
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
696
|
+
config: {
|
|
697
|
+
copilotModel: "claude-sonnet-4.6",
|
|
698
|
+
selfEditEnabled: true,
|
|
699
|
+
},
|
|
700
|
+
sendResult: "SSE complete",
|
|
701
|
+
});
|
|
702
|
+
await orchestrator.initOrchestrator(client);
|
|
703
|
+
const sessionKey = `chat:sse-lifecycle-${Date.now()}`;
|
|
704
|
+
const events = captureSessionEvents(t, sessionKey);
|
|
705
|
+
const turnId = orchestrator.enqueueForSse({ sessionKey, prompt: "hello from sse" });
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
707
|
+
const turnEvents = events.filter((event) => event.turnId === turnId);
|
|
708
|
+
const started = turnEvents.filter((event) => event.type === "turn:started");
|
|
709
|
+
const completed = turnEvents.filter((event) => event.type === "turn:complete");
|
|
710
|
+
assert.equal(started.length, 1, "sse-web turn should emit turn:started exactly once");
|
|
711
|
+
assert.equal(completed.length, 1, "sse-web turn should emit turn:complete exactly once");
|
|
712
|
+
});
|
|
713
|
+
test("sendToOrchestrator emits turn:error instead of turn:complete on failures", async (t) => {
|
|
714
|
+
const { orchestrator, client } = await loadOrchestratorModule(t, {
|
|
715
|
+
config: {
|
|
716
|
+
copilotModel: "claude-sonnet-4.6",
|
|
717
|
+
selfEditEnabled: true,
|
|
718
|
+
},
|
|
719
|
+
sendErrorMessage: "session exploded",
|
|
720
|
+
sendResult: "unreachable",
|
|
721
|
+
});
|
|
722
|
+
await orchestrator.initOrchestrator(client);
|
|
723
|
+
const sessionKey = `chat:error-lifecycle-${Date.now()}`;
|
|
724
|
+
const events = captureSessionEvents(t, sessionKey);
|
|
725
|
+
const received = new Promise((resolve) => {
|
|
726
|
+
orchestrator.sendToOrchestrator("trigger an error", { type: "background", sessionKey }, (text, done, turnId) => {
|
|
727
|
+
if (done) {
|
|
728
|
+
resolve({ text, done, turnId });
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
const result = await received;
|
|
733
|
+
assert.match(result.text, /^Error:/);
|
|
734
|
+
const turnEvents = events.filter((event) => event.turnId === result.turnId);
|
|
735
|
+
const errors = turnEvents.filter((event) => event.type === "turn:error");
|
|
736
|
+
const completed = turnEvents.filter((event) => event.type === "turn:complete");
|
|
737
|
+
assert.equal(errors.length, 1, "failed turn should emit one turn:error event");
|
|
738
|
+
assert.equal(completed.length, 0, "failed turn must not emit turn:complete");
|
|
365
739
|
});
|
|
366
740
|
test("cancelCurrentMessage aborts the active request and agent helpers expose running work", async (t) => {
|
|
367
741
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
@@ -606,10 +980,18 @@ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses sp
|
|
|
606
980
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
607
981
|
assert.ok(state.lastSession, "FakeSession must have been created");
|
|
608
982
|
// Step 1: emit tool.execution_start for a "task" call with spawn parameters
|
|
983
|
+
const promptText = [
|
|
984
|
+
"Investigate why /workers omits the dispatched prompt.",
|
|
985
|
+
"Return the exact DB column and API response change needed.",
|
|
986
|
+
].join("\n");
|
|
609
987
|
state.lastSession.emit("tool.execution_start", {
|
|
610
988
|
toolName: "task",
|
|
611
989
|
toolCallId: "tc-spawn-1",
|
|
612
|
-
arguments: {
|
|
990
|
+
arguments: {
|
|
991
|
+
name: "kaylee",
|
|
992
|
+
description: "🔧 Kaylee: test spawn",
|
|
993
|
+
prompt: promptText,
|
|
994
|
+
},
|
|
613
995
|
});
|
|
614
996
|
// Step 2: emit subagent.started with the same toolCallId — SDK only knows agent_type details
|
|
615
997
|
state.lastSession.emit("subagent.started", {
|
|
@@ -623,6 +1005,7 @@ test("#81: tool.execution_start stash + subagent.started → agent_tasks uses sp
|
|
|
623
1005
|
const argsJson = JSON.stringify(insertWrite.args);
|
|
624
1006
|
assert.ok(argsJson.includes("kaylee"), `agent_slug must be "kaylee" but got: ${argsJson}`);
|
|
625
1007
|
assert.ok(argsJson.includes("🔧 Kaylee: test spawn"), `description must be spawn description but got: ${argsJson}`);
|
|
1008
|
+
assert.ok(insertWrite.args.some((arg) => arg === promptText), `prompt must be persisted from task spawn args but got: ${argsJson}`);
|
|
626
1009
|
assert.ok(!argsJson.includes("general-purpose"), `agent_slug must NOT fall back to "general-purpose" when spawn name is available`);
|
|
627
1010
|
state.pendingReject?.(new Error("test teardown"));
|
|
628
1011
|
});
|
|
@@ -810,6 +1193,31 @@ test("#98: interruptCurrentTurn aborts active turn and starts replacement turn",
|
|
|
810
1193
|
state.pendingReject?.(new Error("aborted"));
|
|
811
1194
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
812
1195
|
});
|
|
1196
|
+
test("interruptCurrentTurn prepends active project rules to the replacement turn", async (t) => {
|
|
1197
|
+
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
1198
|
+
sendResult: "__PENDING__",
|
|
1199
|
+
projectRegistry: {
|
|
1200
|
+
chapterhouse: "/home/bjk/projects/chapterhouse",
|
|
1201
|
+
},
|
|
1202
|
+
projectRulesResult: createLoadedProjectRules(),
|
|
1203
|
+
});
|
|
1204
|
+
await orchestrator.initOrchestrator(client);
|
|
1205
|
+
orchestrator.sendToOrchestrator("first long request", { type: "background" }, () => { });
|
|
1206
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1207
|
+
state.resolvedProject = {
|
|
1208
|
+
slug: "chapterhouse",
|
|
1209
|
+
path: "/home/bjk/projects/chapterhouse",
|
|
1210
|
+
source: "mention",
|
|
1211
|
+
};
|
|
1212
|
+
await orchestrator.interruptCurrentTurn("default", "@project:chapterhouse replacement request", { type: "background" }, () => { });
|
|
1213
|
+
state.sendResult = "replacement complete";
|
|
1214
|
+
state.pendingReject?.(new Error("aborted"));
|
|
1215
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1216
|
+
assert.deepEqual(state.sessionPrompts, [
|
|
1217
|
+
{ prompt: "first long request" },
|
|
1218
|
+
{ prompt: expectedProjectRulesPrompt("@project:chapterhouse replacement request") },
|
|
1219
|
+
]);
|
|
1220
|
+
});
|
|
813
1221
|
test("#98: interruptCurrentTurn falls back to normal send when no session is active", async (t) => {
|
|
814
1222
|
const { orchestrator, state, client } = await loadOrchestratorModule(t, {
|
|
815
1223
|
sendResult: "fallback response",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isAbsolute, normalize, relative, sep } from "node:path";
|
|
2
|
+
const PROJECT_MENTION_RE = /(^|[^@\w-])@project:([a-z0-9][a-z0-9-]*)(?=$|[^A-Za-z0-9_-])/;
|
|
3
|
+
export function extractProjectMention(message) {
|
|
4
|
+
const match = message.match(PROJECT_MENTION_RE);
|
|
5
|
+
return match?.[2] ?? null;
|
|
6
|
+
}
|
|
7
|
+
export function resolveProject(message, context, registry) {
|
|
8
|
+
const explicitSlug = extractProjectMention(message);
|
|
9
|
+
if (explicitSlug) {
|
|
10
|
+
const explicitPath = registry[explicitSlug];
|
|
11
|
+
if (explicitPath) {
|
|
12
|
+
return {
|
|
13
|
+
slug: explicitSlug,
|
|
14
|
+
path: normalizeProjectPath(explicitPath),
|
|
15
|
+
source: "mention",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const selectedProjectPath = context.selectedProjectPath ?? context.projectPath;
|
|
20
|
+
if (selectedProjectPath) {
|
|
21
|
+
const selectedMatch = matchProjectPath(selectedProjectPath, registry);
|
|
22
|
+
if (selectedMatch) {
|
|
23
|
+
return {
|
|
24
|
+
...selectedMatch,
|
|
25
|
+
source: "selectedProjectPath",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (context.cwd) {
|
|
30
|
+
const cwdMatch = matchProjectPath(context.cwd, registry);
|
|
31
|
+
if (cwdMatch) {
|
|
32
|
+
return {
|
|
33
|
+
...cwdMatch,
|
|
34
|
+
source: "cwd",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function matchProjectPath(candidatePath, registry) {
|
|
41
|
+
const normalizedCandidate = normalizeCandidatePath(candidatePath);
|
|
42
|
+
if (!normalizedCandidate)
|
|
43
|
+
return null;
|
|
44
|
+
const entries = Object.entries(registry)
|
|
45
|
+
.map(([slug, registeredPath]) => ({
|
|
46
|
+
slug,
|
|
47
|
+
path: normalizeProjectPath(registeredPath),
|
|
48
|
+
}))
|
|
49
|
+
.sort((left, right) => right.path.length - left.path.length || left.slug.localeCompare(right.slug));
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (pathMatches(entry.path, normalizedCandidate)) {
|
|
52
|
+
return entry;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function pathMatches(projectPath, candidatePath) {
|
|
58
|
+
const rel = relative(projectPath, candidatePath);
|
|
59
|
+
return rel === "" || (!rel.startsWith("..") && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
60
|
+
}
|
|
61
|
+
function normalizeCandidatePath(path) {
|
|
62
|
+
if (!isAbsolute(path))
|
|
63
|
+
return null;
|
|
64
|
+
return normalizeProjectPath(path);
|
|
65
|
+
}
|
|
66
|
+
function normalizeProjectPath(path) {
|
|
67
|
+
let normalized = normalize(path);
|
|
68
|
+
while (normalized.length > 1 && normalized.endsWith(sep)) {
|
|
69
|
+
normalized = normalized.slice(0, -1);
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=project-resolution.js.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
async function loadModule() {
|
|
4
|
+
const nonce = `${Date.now()}-${Math.random()}`;
|
|
5
|
+
return import(new URL(`./project-resolution.js?case=${nonce}`, import.meta.url).href);
|
|
6
|
+
}
|
|
7
|
+
test("extractProjectMention returns the first exact slug mention", async () => {
|
|
8
|
+
const { extractProjectMention } = await loadModule();
|
|
9
|
+
assert.equal(extractProjectMention("Please fix this for (@project:docs-site), then circle back to @project:chapterhouse."), "docs-site");
|
|
10
|
+
});
|
|
11
|
+
test("extractProjectMention ignores partial, cased, and embedded mentions", async () => {
|
|
12
|
+
const { extractProjectMention } = await loadModule();
|
|
13
|
+
assert.equal(extractProjectMention("email foo@project:docs-site later"), null);
|
|
14
|
+
assert.equal(extractProjectMention("@project:Docs-Site fix this"), null);
|
|
15
|
+
assert.equal(extractProjectMention("@project:docs_site fix this"), null);
|
|
16
|
+
});
|
|
17
|
+
test("resolveProject prefers an explicit project mention over path-based matches", async () => {
|
|
18
|
+
const { resolveProject } = await loadModule();
|
|
19
|
+
const registry = {
|
|
20
|
+
chapterhouse: "/work/chapterhouse",
|
|
21
|
+
"docs-site": "/work/docs-site",
|
|
22
|
+
};
|
|
23
|
+
assert.deepEqual(resolveProject("@project:chapterhouse fix the spinner", {
|
|
24
|
+
selectedProjectPath: "/work/docs-site",
|
|
25
|
+
cwd: "/work/docs-site",
|
|
26
|
+
}, registry), {
|
|
27
|
+
slug: "chapterhouse",
|
|
28
|
+
path: "/work/chapterhouse",
|
|
29
|
+
source: "mention",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
test("resolveProject falls back to the selected project path when an explicit mention is not registered", async () => {
|
|
33
|
+
const { resolveProject } = await loadModule();
|
|
34
|
+
const registry = {
|
|
35
|
+
chapterhouse: "/work/chapterhouse",
|
|
36
|
+
"docs-site": "/work/docs-site",
|
|
37
|
+
};
|
|
38
|
+
assert.deepEqual(resolveProject("@project:missing update the landing page", {
|
|
39
|
+
selectedProjectPath: "/work/docs-site",
|
|
40
|
+
cwd: "/work/chapterhouse",
|
|
41
|
+
}, registry), {
|
|
42
|
+
slug: "docs-site",
|
|
43
|
+
path: "/work/docs-site",
|
|
44
|
+
source: "selectedProjectPath",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
test("resolveProject treats projectPath as the selected project path signal", async () => {
|
|
48
|
+
const { resolveProject } = await loadModule();
|
|
49
|
+
const registry = {
|
|
50
|
+
chapterhouse: "/work/chapterhouse",
|
|
51
|
+
"docs-site": "/work/docs-site",
|
|
52
|
+
};
|
|
53
|
+
assert.deepEqual(resolveProject("Update the landing page", {
|
|
54
|
+
projectPath: "/work/docs-site",
|
|
55
|
+
cwd: "/work/chapterhouse",
|
|
56
|
+
}, registry), {
|
|
57
|
+
slug: "docs-site",
|
|
58
|
+
path: "/work/docs-site",
|
|
59
|
+
source: "selectedProjectPath",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
test("resolveProject matches an exact selected project path before cwd", async () => {
|
|
63
|
+
const { resolveProject } = await loadModule();
|
|
64
|
+
const registry = {
|
|
65
|
+
chapterhouse: "/work/chapterhouse",
|
|
66
|
+
"docs-site": "/work/docs-site",
|
|
67
|
+
};
|
|
68
|
+
assert.deepEqual(resolveProject("Update the landing page", {
|
|
69
|
+
selectedProjectPath: "/work/docs-site",
|
|
70
|
+
cwd: "/work/chapterhouse",
|
|
71
|
+
}, registry), {
|
|
72
|
+
slug: "docs-site",
|
|
73
|
+
path: "/work/docs-site",
|
|
74
|
+
source: "selectedProjectPath",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
test("resolveProject matches descendant paths and picks the longest registered prefix", async () => {
|
|
78
|
+
const { resolveProject } = await loadModule();
|
|
79
|
+
const registry = {
|
|
80
|
+
monorepo: "/work/chapterhouse",
|
|
81
|
+
web: "/work/chapterhouse/web",
|
|
82
|
+
docs: "/work/docs",
|
|
83
|
+
};
|
|
84
|
+
assert.deepEqual(resolveProject("Fix the UI build", {
|
|
85
|
+
cwd: "/work/chapterhouse/web/src/routes",
|
|
86
|
+
}, registry), {
|
|
87
|
+
slug: "web",
|
|
88
|
+
path: "/work/chapterhouse/web",
|
|
89
|
+
source: "cwd",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test("resolveProject does not treat sibling prefixes as descendant matches", async () => {
|
|
93
|
+
const { resolveProject } = await loadModule();
|
|
94
|
+
const registry = {
|
|
95
|
+
app: "/work/app",
|
|
96
|
+
};
|
|
97
|
+
assert.equal(resolveProject("Fix it", {
|
|
98
|
+
cwd: "/work/app2/src",
|
|
99
|
+
}, registry), null);
|
|
100
|
+
});
|
|
101
|
+
test("resolveProject trims trailing slashes before path matching", async () => {
|
|
102
|
+
const { resolveProject } = await loadModule();
|
|
103
|
+
const registry = {
|
|
104
|
+
chapterhouse: "/work/chapterhouse/",
|
|
105
|
+
};
|
|
106
|
+
assert.deepEqual(resolveProject("Fix it", {
|
|
107
|
+
cwd: "/work/chapterhouse/web/",
|
|
108
|
+
}, registry), {
|
|
109
|
+
slug: "chapterhouse",
|
|
110
|
+
path: "/work/chapterhouse",
|
|
111
|
+
source: "cwd",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
test("resolveProject returns null when neither message text nor paths resolve to a registered project", async () => {
|
|
115
|
+
const { resolveProject } = await loadModule();
|
|
116
|
+
const registry = {
|
|
117
|
+
chapterhouse: "/work/chapterhouse",
|
|
118
|
+
};
|
|
119
|
+
assert.equal(resolveProject("Fix it", {
|
|
120
|
+
selectedProjectPath: "/work/docs-site",
|
|
121
|
+
cwd: "/tmp/other",
|
|
122
|
+
}, registry), null);
|
|
123
|
+
});
|
|
124
|
+
//# sourceMappingURL=project-resolution.test.js.map
|