@selesai/code 0.5.29 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +18 -0
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/system-prompt.test.d.ts +2 -0
- package/dist/core/system-prompt.test.d.ts.map +1 -0
- package/dist/core/system-prompt.test.js +89 -0
- package/dist/core/system-prompt.test.js.map +1 -0
- package/dist/defaults/models.json +13 -45
- package/dist/defaults/settings.json +1 -2
- package/dist/extensions/copy-turn.test.ts +131 -0
- package/dist/extensions/copy-turn.ts +6 -1
- package/dist/extensions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/dist/extensions/package.json +0 -1
- package/dist/extensions/pi-subagents/CHANGELOG.md +3 -0
- package/dist/extensions/pi-subagents/README.md +27 -32
- package/dist/extensions/pi-subagents/agents/architect.md +4 -4
- package/dist/extensions/pi-subagents/agents/builder.md +5 -4
- package/dist/extensions/pi-subagents/agents/commentator.md +3 -2
- package/dist/extensions/pi-subagents/agents/explorer.md +3 -2
- package/dist/extensions/pi-subagents/agents/recapper.md +3 -2
- package/dist/extensions/pi-subagents/agents/researcher.md +4 -3
- package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +2 -0
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +10 -9
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +12 -11
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +10 -11
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +56 -9
- package/dist/extensions/pi-subagents/src/agents/task-aware-routing.ts +125 -0
- package/dist/extensions/pi-subagents/src/api/preflight.ts +1 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +5 -1
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/extension/tool-description.ts +24 -7
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +23 -5
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +27 -1
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +64 -6
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +16 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +72 -18
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +19 -5
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +127 -31
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +4 -6
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +63 -9
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +21 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +41 -2
- package/dist/extensions/pi-subagents/src/shared/utils.ts +29 -1
- package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +5 -1
- package/dist/extensions/pi-subagents/src/tui/render.ts +28 -6
- package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +111 -6
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +74 -43
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +36 -21
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +5 -3
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +20 -8
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +14 -7
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +81 -5
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +49 -10
- package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +18 -2
- package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +70 -6
- package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +161 -1
- package/dist/extensions/pi-subagents/test/unit/builtin-agent-documentation.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +34 -0
- package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +24 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/notify.test.ts +29 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +2 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +12 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +91 -1
- package/dist/extensions/pi-subagents/test/unit/task-aware-routing.test.ts +213 -0
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +23 -1
- package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +60 -9
- package/dist/skills/ponytail/SKILL.md +1 -3
- package/docs/plans/subagent-delegation/phase-0-correctness.md +265 -0
- package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +486 -0
- package/docs/plans/subagent-delegation/phase-2-context-controls.md +282 -0
- package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +362 -0
- package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +381 -0
- package/package.json +2 -2
- package/dist/extensions/caveman/caveman-instructions.cjs +0 -11
- package/dist/extensions/caveman/index.js +0 -118
- package/dist/extensions/caveman/package.json +0 -8
- package/dist/extensions/caveman/test/extension.test.js +0 -203
- package/dist/extensions/caveman/test/helpers.test.js +0 -58
- package/dist/skills/caveman/SKILL.md +0 -50
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { BUILTIN_AGENT_NAMES } from "../../src/agents/agents.ts";
|
|
7
|
+
|
|
8
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
9
|
+
|
|
10
|
+
const README_PATH = path.join(packageRoot, "README.md");
|
|
11
|
+
const PROMPTING_AND_ROLES_PATH = path.join(packageRoot, "skills", "pi-subagents", "references", "prompting-and-roles.md");
|
|
12
|
+
|
|
13
|
+
const README_TABLE_HEADING = "## Builtin agents in plain English";
|
|
14
|
+
const PROMPTING_AND_ROLES_TABLE_HEADING = "## Builtin Agents";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extracts the first-column agent names from every table row under the given
|
|
18
|
+
* markdown section heading. Skips the header row and the separator row.
|
|
19
|
+
*/
|
|
20
|
+
function tableFirstColumnNames(markdown: string, sectionHeading: string): string[] {
|
|
21
|
+
const lines = markdown.split(/\r?\n/);
|
|
22
|
+
const headingIndex = lines.findIndex((line) => line.trim() === sectionHeading);
|
|
23
|
+
assert.ok(headingIndex >= 0, `missing section heading: ${sectionHeading}`);
|
|
24
|
+
|
|
25
|
+
const names: string[] = [];
|
|
26
|
+
for (let i = headingIndex + 1; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i]!.trim();
|
|
28
|
+
if (line.startsWith("#")) break;
|
|
29
|
+
if (!line.startsWith("|")) continue;
|
|
30
|
+
|
|
31
|
+
const cells = line.split("|").map((cell) => cell.trim());
|
|
32
|
+
const firstCell = (cells[1] ?? "").replaceAll("`", "").trim();
|
|
33
|
+
if (firstCell === "Agent") continue;
|
|
34
|
+
if (/^:?-+:?$/.test(firstCell)) continue;
|
|
35
|
+
if (firstCell.length === 0) continue;
|
|
36
|
+
names.push(firstCell);
|
|
37
|
+
}
|
|
38
|
+
return names;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertExactlySixBuiltins(tablePath: string, heading: string, markdown: string): void {
|
|
42
|
+
const names = tableFirstColumnNames(markdown, heading);
|
|
43
|
+
|
|
44
|
+
assert.equal(names.length, 6, `${path.basename(tablePath)}: role table must contain exactly six builtin rows`);
|
|
45
|
+
assert.equal(new Set(names).size, names.length, `${path.basename(tablePath)}: role table contains duplicate rows`);
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
[...names].sort(),
|
|
48
|
+
[...BUILTIN_AGENT_NAMES].sort(),
|
|
49
|
+
`${path.basename(tablePath)}: role table must list exactly ${BUILTIN_AGENT_NAMES.join(", ")} once each, with no aliases`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("builtin agent documentation role tables", () => {
|
|
54
|
+
it("README lists exactly the six canonical builtins once, with no duplicates or aliases", () => {
|
|
55
|
+
const markdown = fs.readFileSync(README_PATH, "utf-8");
|
|
56
|
+
assertExactlySixBuiltins(README_PATH, README_TABLE_HEADING, markdown);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("prompting-and-roles reference lists exactly the six canonical builtins once, with no duplicates or aliases", () => {
|
|
60
|
+
const markdown = fs.readFileSync(PROMPTING_AND_ROLES_PATH, "utf-8");
|
|
61
|
+
assertExactlySixBuiltins(PROMPTING_AND_ROLES_PATH, PROMPTING_AND_ROLES_TABLE_HEADING, markdown);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -58,6 +58,40 @@ describe("capability ceiling agent allowlist", () => {
|
|
|
58
58
|
assert.match(text, /- commentator /);
|
|
59
59
|
assert.match(text, /Restricted agents \(not executable in this session; capability ceiling: plan-mode\):/);
|
|
60
60
|
assert.match(text, /- builder /);
|
|
61
|
+
|
|
62
|
+
const catalog = (result as { details: { catalog?: unknown } }).details.catalog as
|
|
63
|
+
| undefined
|
|
64
|
+
| {
|
|
65
|
+
version: number;
|
|
66
|
+
agents: Array<{ name: string; executable: boolean; restrictionSources?: string[]; defaultContext: string }>;
|
|
67
|
+
capabilityCeilingSources?: string[];
|
|
68
|
+
};
|
|
69
|
+
assert.ok(catalog, "list result must include machine catalog metadata");
|
|
70
|
+
assert.equal(catalog.version, 1);
|
|
71
|
+
assert.deepEqual(catalog.capabilityCeilingSources, ["plan-mode"]);
|
|
72
|
+
const commentator = catalog.agents.find((entry) => entry.name === "commentator");
|
|
73
|
+
const builder = catalog.agents.find((entry) => entry.name === "builder");
|
|
74
|
+
assert.equal(commentator?.executable, true);
|
|
75
|
+
assert.equal(commentator?.restrictionSources, undefined);
|
|
76
|
+
assert.equal(commentator?.defaultContext, "fresh");
|
|
77
|
+
assert.equal(builder?.executable, false);
|
|
78
|
+
assert.deepEqual(builder?.restrictionSources, ["plan-mode"]);
|
|
79
|
+
} finally {
|
|
80
|
+
handle.dispose();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("never recommends a capability-restricted writer for implementation list task advice", () => {
|
|
85
|
+
const sessionId = `allowlist-advice-${Date.now()}-${Math.random()}`;
|
|
86
|
+
const handle = registerSubagentCapabilityCeiling({ sessionId, source: "plan-mode", ceiling: { allowedAgents: ["commentator"] } });
|
|
87
|
+
try {
|
|
88
|
+
const result = handleList({ task: "Implement the fix" }, { cwd: process.cwd(), currentSessionId: sessionId, modelRegistry: { getAvailable: () => [] } });
|
|
89
|
+
assert.equal(result.isError, false);
|
|
90
|
+
const text = result.content[0]?.text ?? "";
|
|
91
|
+
assert.match(text, /Task-aware advisory routing:/);
|
|
92
|
+
assert.match(text, /- Intent: implementation/);
|
|
93
|
+
assert.match(text, /- Recommendation: none/);
|
|
94
|
+
assert.doesNotMatch(text, /- Recommended: /);
|
|
61
95
|
} finally {
|
|
62
96
|
handle.dispose();
|
|
63
97
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type SubagentDelegationV2Response,
|
|
18
18
|
} from "../../src/api/delegation.ts";
|
|
19
19
|
import { parseSubagentDelegationRequest } from "../../src/slash/delegation-request.ts";
|
|
20
|
+
import { toSubagentDelegationExecutionParams } from "../../src/slash/delegation-adapters.ts";
|
|
20
21
|
import {
|
|
21
22
|
registerPromptTemplateDelegationBridge,
|
|
22
23
|
type PromptTemplateBridgeEvents,
|
|
@@ -508,6 +509,29 @@ describe("public subagent delegation contract", () => {
|
|
|
508
509
|
assert.deepEqual(responses, []);
|
|
509
510
|
});
|
|
510
511
|
|
|
512
|
+
it("resolves an omitted v1 outputMode to explicit inline so response.output stays populated", () => {
|
|
513
|
+
const minimal: SubagentDelegationRequest = {
|
|
514
|
+
version: 1,
|
|
515
|
+
requestId: "adapter-default-1",
|
|
516
|
+
agent: "reviewer",
|
|
517
|
+
task: "Review evidence",
|
|
518
|
+
context: "fresh",
|
|
519
|
+
cwd: "/repo",
|
|
520
|
+
};
|
|
521
|
+
// Omitted outputMode on a strict v1 request keeps the legacy full-text
|
|
522
|
+
// contract: explicit inline, so `SubagentDelegationResponse.output` stays
|
|
523
|
+
// populated instead of degrading to the reference-first file-only default.
|
|
524
|
+
const params = toSubagentDelegationExecutionParams(minimal);
|
|
525
|
+
assert.equal(params.outputMode, "inline");
|
|
526
|
+
assert.equal(params.output, undefined);
|
|
527
|
+
|
|
528
|
+
// Explicit v1 modes are forwarded exactly; v2 stays unaffected (output: false).
|
|
529
|
+
assert.equal(toSubagentDelegationExecutionParams(request).outputMode, "file-only");
|
|
530
|
+
const inline = toSubagentDelegationExecutionParams({ ...request, outputMode: "inline" });
|
|
531
|
+
assert.equal(inline.outputMode, "inline");
|
|
532
|
+
assert.equal(toSubagentDelegationExecutionParams({ ...request, outputMode: undefined }).outputMode, "inline");
|
|
533
|
+
});
|
|
534
|
+
|
|
511
535
|
it("runs one v1 request through the existing executor and returns structured metadata", async () => {
|
|
512
536
|
const events = new FakeEvents();
|
|
513
537
|
let executeCalls = 0;
|
|
@@ -599,14 +599,19 @@ describe("subagent extension child mode", () => {
|
|
|
599
599
|
};
|
|
600
600
|
registerFanoutChildSubagentExtension(fakePi);
|
|
601
601
|
if (!registeredTool) throw new Error("tool not registered");
|
|
602
|
+
if (registeredTool.promptGuidelines !== undefined) {
|
|
603
|
+
throw new Error("child-safe fanout tool must not carry parent-only routing promptGuidelines");
|
|
604
|
+
}
|
|
602
605
|
const ctx = {
|
|
603
606
|
cwd: process.cwd(),
|
|
604
607
|
hasUI: false,
|
|
605
608
|
sessionManager: { getSessionId() { return "session-test"; }, getSessionFile() { return null; } },
|
|
606
609
|
modelRegistry: { getAvailable() { return []; } },
|
|
607
610
|
};
|
|
608
|
-
const list = await registeredTool.execute("list-check", { action: "list" }, new AbortController().signal, undefined, ctx);
|
|
611
|
+
const list = await registeredTool.execute("list-check", { action: "list", task: "Review only; do not edit files" }, new AbortController().signal, undefined, ctx);
|
|
609
612
|
if (list.isError) throw new Error("list should be allowed: " + JSON.stringify(list.content));
|
|
613
|
+
const listText = list.content?.[0]?.text ?? "";
|
|
614
|
+
if (!listText.includes("Task-aware advisory routing:")) throw new Error("list task advice missing in child mode: " + listText.slice(0, 200));
|
|
610
615
|
const create = await registeredTool.execute("create-check", { action: "create", config: { name: "x" } }, new AbortController().signal, undefined, ctx);
|
|
611
616
|
if (!create.isError) throw new Error("create should be blocked");
|
|
612
617
|
const text = create.content?.[0]?.text ?? "";
|
|
@@ -485,4 +485,33 @@ describe("completion formatting helpers", () => {
|
|
|
485
485
|
assert.equal(details.agent, "unknown");
|
|
486
486
|
assert.equal(details.status, "completed");
|
|
487
487
|
});
|
|
488
|
+
|
|
489
|
+
it("buildCompletionDetails prefers compact child summaries over the run summary", () => {
|
|
490
|
+
const details = buildCompletionDetails({
|
|
491
|
+
id: "x",
|
|
492
|
+
agent: "worker",
|
|
493
|
+
success: true,
|
|
494
|
+
summary: "alpha:\nfull child prose that must not leak",
|
|
495
|
+
timestamp: 1,
|
|
496
|
+
results: [
|
|
497
|
+
{ agent: "alpha", status: "completed", summary: "Output saved to: /tmp/alpha.md (12 B, 1 line). Read this file if needed." },
|
|
498
|
+
{ agent: "beta", status: "failed", summary: "boom\n\nOutput saved to: /tmp/beta.md (5 B, 1 line). Read this file if needed." },
|
|
499
|
+
],
|
|
500
|
+
});
|
|
501
|
+
assert.match(details.resultPreview, /1\. alpha\nOutput saved to: \/tmp\/alpha\.md/);
|
|
502
|
+
assert.match(details.resultPreview, /2\. beta\nboom\n\nOutput saved to: \/tmp\/beta\.md/);
|
|
503
|
+
assert.doesNotMatch(details.resultPreview, /full child prose that must not leak/);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("buildCompletionDetails uses the single child summary without an index prefix", () => {
|
|
507
|
+
const details = buildCompletionDetails({
|
|
508
|
+
id: "x",
|
|
509
|
+
agent: "worker",
|
|
510
|
+
success: true,
|
|
511
|
+
summary: "ignored run summary",
|
|
512
|
+
timestamp: 1,
|
|
513
|
+
results: [{ agent: "worker", status: "completed", summary: "Output saved to: /tmp/out.md (8 B, 1 line). Read this file if needed." }],
|
|
514
|
+
});
|
|
515
|
+
assert.equal(details.resultPreview, "Output saved to: /tmp/out.md (8 B, 1 line). Read this file if needed.");
|
|
516
|
+
});
|
|
488
517
|
});
|
|
@@ -107,6 +107,7 @@ Project prompt.
|
|
|
107
107
|
{ provider: "test", id: "fallback", fullId: "test/fallback" },
|
|
108
108
|
],
|
|
109
109
|
capabilityCeiling: ceiling,
|
|
110
|
+
artifacts: true,
|
|
110
111
|
});
|
|
111
112
|
|
|
112
113
|
assert.equal(result.ok, true);
|
|
@@ -143,6 +144,7 @@ Project prompt.
|
|
|
143
144
|
{ provider: "test", id: "fallback", fullId: "test/fallback" },
|
|
144
145
|
],
|
|
145
146
|
capabilityCeiling: ceiling,
|
|
147
|
+
artifacts: true,
|
|
146
148
|
});
|
|
147
149
|
assert.equal(repeated.ok, true);
|
|
148
150
|
assert.equal(repeated.contract.digest, result.contract.digest);
|
|
@@ -194,6 +194,18 @@ describe("SubagentParams schema", { skip: !schemasAvailable ? "typebox not avail
|
|
|
194
194
|
assert.doesNotMatch(description, /orchestration\./);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
it("documents list task as an optional advisory intent and keeps action a free string", () => {
|
|
198
|
+
const taskSchema = (SubagentParams?.properties as Record<string, JsonSchemaNode> | undefined)?.task;
|
|
199
|
+
assert.ok(taskSchema, "task schema should exist");
|
|
200
|
+
const description = String(taskSchema?.description ?? "");
|
|
201
|
+
assert.match(description, /action:'list'/);
|
|
202
|
+
assert.match(description, /never launches/);
|
|
203
|
+
assert.match(description, /explicitly call subagent/);
|
|
204
|
+
const actionSchema = SubagentParams?.properties?.action;
|
|
205
|
+
assert.equal(actionSchema?.type, "string");
|
|
206
|
+
assert.equal(actionSchema?.enum, undefined);
|
|
207
|
+
});
|
|
208
|
+
|
|
197
209
|
it("includes foreground timeout aliases and turn budget", () => {
|
|
198
210
|
const timeoutSchema = SubagentParams?.properties?.timeoutMs;
|
|
199
211
|
const maxRuntimeSchema = SubagentParams?.properties?.maxRuntimeMs;
|
|
@@ -6,8 +6,10 @@ import * as path from "node:path";
|
|
|
6
6
|
import type { Message, Usage } from "@earendil-works/pi-ai";
|
|
7
7
|
import {
|
|
8
8
|
captureSingleOutputSnapshot,
|
|
9
|
+
CONTEXT_FALLBACK_LIMIT,
|
|
9
10
|
extractChildWrittenOutput,
|
|
10
11
|
finalizeSingleOutput,
|
|
12
|
+
formatBoundedPersistenceFallback,
|
|
11
13
|
formatSavedOutputReference,
|
|
12
14
|
injectOutputPathSystemPrompt,
|
|
13
15
|
injectSingleOutputInstruction,
|
|
@@ -269,6 +271,49 @@ describe("validateFileOnlyOutputMode", () => {
|
|
|
269
271
|
});
|
|
270
272
|
});
|
|
271
273
|
|
|
274
|
+
describe("CONTEXT_FALLBACK_LIMIT and formatBoundedPersistenceFallback", () => {
|
|
275
|
+
it("exports the fixed internal 80-line/4-KiB context fallback cap", () => {
|
|
276
|
+
assert.equal(CONTEXT_FALLBACK_LIMIT.lines, 80);
|
|
277
|
+
assert.equal(CONTEXT_FALLBACK_LIMIT.bytes, 4096);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("includes error, process status, intended path, and a bounded slice", () => {
|
|
281
|
+
const fallback = formatBoundedPersistenceFallback({
|
|
282
|
+
error: "EACCES: permission denied",
|
|
283
|
+
outputPath: "/tmp/report.md",
|
|
284
|
+
fullOutput: "line one\nline two",
|
|
285
|
+
exitCode: 1,
|
|
286
|
+
processError: "boom",
|
|
287
|
+
});
|
|
288
|
+
assert.match(fallback, /\[Full output unavailable\] EACCES: permission denied/);
|
|
289
|
+
assert.match(fallback, /Process status: boom/);
|
|
290
|
+
assert.match(fallback, /Intended output path: \/tmp\/report\.md/);
|
|
291
|
+
assert.match(fallback, /Full output is unavailable; showing a bounded excerpt \(first 80 lines \/ 4 KiB\)\./);
|
|
292
|
+
assert.match(fallback, /line one\nline two/);
|
|
293
|
+
assert.doesNotMatch(fallback, /Output saved to:/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("never returns unbounded output when the source exceeds the cap", () => {
|
|
297
|
+
const longOutput = Array.from({ length: 500 }, (_, i) => `line ${i} ${`x`.repeat(50)}`).join("\n");
|
|
298
|
+
const fallback = formatBoundedPersistenceFallback({
|
|
299
|
+
error: "disk full",
|
|
300
|
+
fullOutput: longOutput,
|
|
301
|
+
exitCode: 0,
|
|
302
|
+
});
|
|
303
|
+
const body = fallback.split("\n").filter((line) => /^line \d+/.test(line)).length;
|
|
304
|
+
assert.ok(body <= 80, `fallback kept ${body} lines, expected at most 80`);
|
|
305
|
+
assert.ok(Buffer.byteLength(fallback, "utf-8") <= 12 * 1024, "fallback header plus 4-KiB slice stays bounded");
|
|
306
|
+
assert.doesNotMatch(fallback, /line 400/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("states completed status for exit code 0 and failed status otherwise", () => {
|
|
310
|
+
const completed = formatBoundedPersistenceFallback({ error: "read failed", fullOutput: "x", exitCode: 0 });
|
|
311
|
+
assert.match(completed, /Process status: completed/);
|
|
312
|
+
const failed = formatBoundedPersistenceFallback({ error: "read failed", fullOutput: "x", exitCode: 2 });
|
|
313
|
+
assert.match(failed, /Process status: failed \(exit 2\)/);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
272
317
|
describe("finalizeSingleOutput", () => {
|
|
273
318
|
it("formats saved-path messaging around the already-resolved output", () => {
|
|
274
319
|
const result = finalizeSingleOutput({
|
|
@@ -298,13 +343,58 @@ describe("finalizeSingleOutput", () => {
|
|
|
298
343
|
assert.match(result.displayOutput, /3 lines/);
|
|
299
344
|
});
|
|
300
345
|
|
|
301
|
-
it("
|
|
346
|
+
it("returns error/status plus the saved-output reference on failed runs with a persisted result", () => {
|
|
302
347
|
const result = finalizeSingleOutput({
|
|
303
348
|
fullOutput: "full output",
|
|
304
349
|
truncatedOutput: "truncated output",
|
|
305
350
|
outputPath: "/tmp/review.md",
|
|
306
351
|
savedPath: "/tmp/review.md",
|
|
307
352
|
exitCode: 1,
|
|
353
|
+
error: "exploded",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
assert.match(result.displayOutput, /^exploded\n\nOutput saved to: \/tmp\/review\.md/);
|
|
357
|
+
assert.doesNotMatch(result.displayOutput, /truncated output/);
|
|
358
|
+
assert.equal(result.savedPath, "/tmp/review.md");
|
|
359
|
+
assert.ok(result.outputReference);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("returns only the bounded persistence-failure fallback when saving failed", () => {
|
|
363
|
+
const result = finalizeSingleOutput({
|
|
364
|
+
fullOutput: "full output text",
|
|
365
|
+
truncatedOutput: "truncated output",
|
|
366
|
+
outputPath: "/tmp/review.md",
|
|
367
|
+
exitCode: 0,
|
|
368
|
+
saveError: "EACCES: permission denied",
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
assert.match(result.displayOutput, /\[Full output unavailable\]/);
|
|
372
|
+
assert.match(result.displayOutput, /Intended output path: \/tmp\/review\.md/);
|
|
373
|
+
assert.match(result.displayOutput, /full output text/);
|
|
374
|
+
assert.doesNotMatch(result.displayOutput, /Output saved to:/);
|
|
375
|
+
assert.equal(result.savedPath, undefined);
|
|
376
|
+
assert.equal(result.saveError, "EACCES: permission denied");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("returns only the bounded persistence-failure fallback on failed runs with a save error", () => {
|
|
380
|
+
const result = finalizeSingleOutput({
|
|
381
|
+
fullOutput: "partial child output",
|
|
382
|
+
outputPath: "/tmp/review.md",
|
|
383
|
+
exitCode: 1,
|
|
384
|
+
saveError: "Failed to read changed output file: boom",
|
|
385
|
+
error: "step failed",
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
assert.match(result.displayOutput, /\[Full output unavailable\] Failed to read changed output file: boom/);
|
|
389
|
+
assert.match(result.displayOutput, /Process status: step failed/);
|
|
390
|
+
assert.doesNotMatch(result.displayOutput, /Output saved to:/);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("keeps the legacy full inline output for successful inline runs without a saved path", () => {
|
|
394
|
+
const result = finalizeSingleOutput({
|
|
395
|
+
fullOutput: "legacy inline output",
|
|
396
|
+
truncatedOutput: "truncated output",
|
|
397
|
+
exitCode: 0,
|
|
308
398
|
});
|
|
309
399
|
|
|
310
400
|
assert.equal(result.displayOutput, "truncated output");
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
formatTaskAwareAgentRecommendation,
|
|
5
|
+
recommendTaskAwareAgent,
|
|
6
|
+
} from "../../src/agents/task-aware-routing.ts";
|
|
7
|
+
import type { AgentConfig } from "../../src/agents/agents.ts";
|
|
8
|
+
|
|
9
|
+
function agent(name: string, overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|
10
|
+
return {
|
|
11
|
+
name,
|
|
12
|
+
description: `${name} agent`,
|
|
13
|
+
systemPrompt: `${name} prompt`,
|
|
14
|
+
systemPromptMode: "append",
|
|
15
|
+
inheritProjectContext: false,
|
|
16
|
+
inheritSkills: false,
|
|
17
|
+
source: "builtin",
|
|
18
|
+
filePath: `/tmp/${name}.md`,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("recommendTaskAwareAgent", () => {
|
|
24
|
+
it("no-ops for empty or whitespace-only tasks", () => {
|
|
25
|
+
assert.equal(recommendTaskAwareAgent({ task: "", agents: [] }), undefined);
|
|
26
|
+
assert.equal(recommendTaskAwareAgent({ task: " \n\t ", agents: [] }), undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("recommends a canonical writer for an implementation task, never a read-only agent", () => {
|
|
30
|
+
const recommendation = recommendTaskAwareAgent({
|
|
31
|
+
task: "Implement the fix",
|
|
32
|
+
agents: [
|
|
33
|
+
agent("commentator", { tools: ["read", "grep"] }),
|
|
34
|
+
agent("builder"),
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
assert.ok(recommendation);
|
|
38
|
+
assert.equal(recommendation.intent, "implementation");
|
|
39
|
+
assert.equal(recommendation.agent?.name, "builder");
|
|
40
|
+
assert.equal(recommendation.agent?.role, "writer");
|
|
41
|
+
assert.equal(recommendation.agent?.roleBasis, "inferred");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("recommends a read-only agent without known write tools, never a writer", () => {
|
|
45
|
+
const recommendation = recommendTaskAwareAgent({
|
|
46
|
+
task: "Review only; do not edit files",
|
|
47
|
+
agents: [
|
|
48
|
+
agent("builder"),
|
|
49
|
+
agent("commentator", { tools: ["read", "grep", "find", "ls"] }),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
assert.ok(recommendation);
|
|
53
|
+
assert.equal(recommendation.intent, "read-only");
|
|
54
|
+
assert.equal(recommendation.agent?.name, "commentator");
|
|
55
|
+
assert.equal(recommendation.agent?.role, "read-only");
|
|
56
|
+
assert.equal(recommendation.agent?.roleBasis, "inferred");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("never recommends a read-only agent whose tools are unset or include bash", () => {
|
|
60
|
+
for (const candidate of [
|
|
61
|
+
agent("commentator"),
|
|
62
|
+
agent("commentator", { tools: ["read", "bash"] }),
|
|
63
|
+
]) {
|
|
64
|
+
const recommendation = recommendTaskAwareAgent({
|
|
65
|
+
task: "Review only; do not edit files",
|
|
66
|
+
agents: [candidate],
|
|
67
|
+
});
|
|
68
|
+
assert.ok(recommendation);
|
|
69
|
+
assert.equal(recommendation.agent, undefined);
|
|
70
|
+
assert.match(recommendation.next ?? "", /read-only role without known write tools/);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("gives recovery guidance, not a guess, for unknown intent", () => {
|
|
75
|
+
const recommendation = recommendTaskAwareAgent({
|
|
76
|
+
task: "Look into this",
|
|
77
|
+
agents: [agent("builder"), agent("commentator", { tools: ["read", "grep"] })],
|
|
78
|
+
});
|
|
79
|
+
assert.ok(recommendation);
|
|
80
|
+
assert.equal(recommendation.intent, "unknown");
|
|
81
|
+
assert.equal(recommendation.agent, undefined);
|
|
82
|
+
assert.match(recommendation.next ?? "", /Clarify whether the task is read-only analysis\/review or implementation allowed to edit files/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("excludes disabled agents even when they match the task role", () => {
|
|
86
|
+
const recommendation = recommendTaskAwareAgent({
|
|
87
|
+
task: "Implement the fix",
|
|
88
|
+
agents: [
|
|
89
|
+
agent("builder", { disabled: true }),
|
|
90
|
+
agent("fixer", { source: "project", acceptanceRole: "writer", tools: ["edit"] }),
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
assert.ok(recommendation);
|
|
94
|
+
assert.equal(recommendation.agent?.name, "fixer");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("excludes agents denied by the capability ceiling allowedAgents", () => {
|
|
98
|
+
const recommendation = recommendTaskAwareAgent({
|
|
99
|
+
task: "Implement the fix",
|
|
100
|
+
agents: [agent("builder"), agent("fixer", { source: "project", acceptanceRole: "writer", tools: ["edit"] })],
|
|
101
|
+
capabilityCeiling: { version: 1, allowedAgents: ["builder"], denyExtensions: false, sources: ["plan"] },
|
|
102
|
+
});
|
|
103
|
+
assert.ok(recommendation);
|
|
104
|
+
assert.equal(recommendation.agent?.name, "builder");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refuses a writer recommendation when allowedTools has no writer tool", () => {
|
|
108
|
+
const recommendation = recommendTaskAwareAgent({
|
|
109
|
+
task: "Implement the fix",
|
|
110
|
+
agents: [agent("builder")],
|
|
111
|
+
capabilityCeiling: { version: 1, allowedTools: ["read", "grep"], denyExtensions: false, sources: ["plan"] },
|
|
112
|
+
});
|
|
113
|
+
assert.ok(recommendation);
|
|
114
|
+
assert.equal(recommendation.intent, "implementation");
|
|
115
|
+
assert.equal(recommendation.agent, undefined);
|
|
116
|
+
assert.match(recommendation.next ?? "", /capability ceiling/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("recommends a writer when allowedTools includes a writer tool", () => {
|
|
120
|
+
const recommendation = recommendTaskAwareAgent({
|
|
121
|
+
task: "Implement the fix",
|
|
122
|
+
agents: [agent("builder")],
|
|
123
|
+
capabilityCeiling: { version: 1, allowedTools: ["read", "edit"], denyExtensions: false, sources: ["plan"] },
|
|
124
|
+
});
|
|
125
|
+
assert.ok(recommendation);
|
|
126
|
+
assert.equal(recommendation.agent?.name, "builder");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("prefers project over user, package, and builtin candidates", () => {
|
|
130
|
+
const recommendation = recommendTaskAwareAgent({
|
|
131
|
+
task: "Implement the fix",
|
|
132
|
+
agents: [
|
|
133
|
+
agent("builder", { source: "builtin" }),
|
|
134
|
+
agent("builder", { source: "package" }),
|
|
135
|
+
agent("builder", { source: "user" }),
|
|
136
|
+
agent("builder", { source: "project" }),
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
assert.ok(recommendation);
|
|
140
|
+
assert.equal(recommendation.agent?.name, "builder");
|
|
141
|
+
assert.equal(recommendation.agent?.source, "project");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("sorts equal candidates by canonical name", () => {
|
|
145
|
+
const recommendation = recommendTaskAwareAgent({
|
|
146
|
+
task: "Implement the fix",
|
|
147
|
+
agents: [
|
|
148
|
+
agent("zeta-builder", { source: "user", acceptanceRole: "writer", tools: ["edit"] }),
|
|
149
|
+
agent("alpha-builder", { source: "user", acceptanceRole: "writer", tools: ["edit"] }),
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
assert.ok(recommendation);
|
|
153
|
+
assert.equal(recommendation.agent?.name, "alpha-builder");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("prefers a declared role over an inferred role at the same source", () => {
|
|
157
|
+
const recommendation = recommendTaskAwareAgent({
|
|
158
|
+
task: "Implement the fix",
|
|
159
|
+
agents: [
|
|
160
|
+
agent("builder", { source: "user" }),
|
|
161
|
+
agent("fixer", { source: "user", acceptanceRole: "writer", tools: ["edit"] }),
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
assert.ok(recommendation);
|
|
165
|
+
assert.equal(recommendation.agent?.name, "fixer");
|
|
166
|
+
assert.equal(recommendation.agent?.roleBasis, "declared");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("never emits an alias as the recommended launch name", () => {
|
|
170
|
+
const recommendation = recommendTaskAwareAgent({
|
|
171
|
+
task: "Implement the fix",
|
|
172
|
+
agents: [
|
|
173
|
+
agent("canonical-builder", { aliases: ["developer"], source: "user", acceptanceRole: "writer", tools: ["edit"] }),
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
assert.ok(recommendation);
|
|
177
|
+
assert.equal(recommendation.agent?.name, "canonical-builder");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("formatTaskAwareAgentRecommendation", () => {
|
|
182
|
+
it("renders nothing for an empty-task no-op", () => {
|
|
183
|
+
assert.deepEqual(formatTaskAwareAgentRecommendation(undefined), []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("renders a safe recommendation with the canonical agent and explicit-launch note", () => {
|
|
187
|
+
const lines = formatTaskAwareAgentRecommendation({
|
|
188
|
+
intent: "implementation",
|
|
189
|
+
agent: { name: "builder", source: "project", role: "writer", roleBasis: "declared", reason: "declared writer role with write tools" },
|
|
190
|
+
});
|
|
191
|
+
assert.deepEqual(lines, [
|
|
192
|
+
"Task-aware advisory routing:",
|
|
193
|
+
"- Intent: implementation",
|
|
194
|
+
"- Recommended: builder (project)",
|
|
195
|
+
"- Reason: declared writer role with write tools",
|
|
196
|
+
"- Advisory only: no subagent was launched. To proceed, explicitly call subagent with this canonical agent name and the task.",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("renders recovery guidance without an agent name", () => {
|
|
201
|
+
const lines = formatTaskAwareAgentRecommendation({
|
|
202
|
+
intent: "unknown",
|
|
203
|
+
next: "Clarify whether the task is read-only analysis/review or implementation allowed to edit files.",
|
|
204
|
+
});
|
|
205
|
+
assert.deepEqual(lines, [
|
|
206
|
+
"Task-aware advisory routing:",
|
|
207
|
+
"- Intent: unknown",
|
|
208
|
+
"- Recommendation: none",
|
|
209
|
+
"- Next: Clarify whether the task is read-only analysis/review or implementation allowed to edit files.",
|
|
210
|
+
"- Advisory only: no subagent was launched.",
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import { classifyTaskMutationIntent, expectsImplementationMutation, taskMayMutate } from "../../src/runs/shared/task-intent.ts";
|
|
3
|
+
import { classifyTaskMutationIntent, expectsImplementationMutation, resolveAgentRoutingRole, taskMayMutate } from "../../src/runs/shared/task-intent.ts";
|
|
4
4
|
|
|
5
5
|
describe("classifyTaskMutationIntent", () => {
|
|
6
6
|
it("keeps write imperatives despite investigative wording", () => {
|
|
@@ -69,6 +69,28 @@ describe("classifyTaskMutationIntent", () => {
|
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
describe("resolveAgentRoutingRole", () => {
|
|
73
|
+
it("lets a declared acceptanceRole override name heuristics", () => {
|
|
74
|
+
assert.equal(resolveAgentRoutingRole("commentator", "writer"), "writer");
|
|
75
|
+
assert.equal(resolveAgentRoutingRole("builder", "read-only"), "read-only");
|
|
76
|
+
assert.equal(resolveAgentRoutingRole("custom-agent", "writer"), "writer");
|
|
77
|
+
assert.equal(resolveAgentRoutingRole("custom-agent", "read-only"), "read-only");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("infers writer for builder-named agents and read-only for reviewer-style names", () => {
|
|
81
|
+
assert.equal(resolveAgentRoutingRole("builder"), "writer");
|
|
82
|
+
assert.equal(resolveAgentRoutingRole("package.builder"), "writer");
|
|
83
|
+
for (const name of ["architect", "commentator", "explorer", "recapper", "researcher", "analyst"]) {
|
|
84
|
+
assert.equal(resolveAgentRoutingRole(name), "read-only", name);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns undefined for names without an established role", () => {
|
|
89
|
+
assert.equal(resolveAgentRoutingRole("custom-agent"), undefined);
|
|
90
|
+
assert.equal(resolveAgentRoutingRole("reviewer"), undefined);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
72
94
|
describe("taskMayMutate", () => {
|
|
73
95
|
it("treats any bare write verb as write-capable", () => {
|
|
74
96
|
for (const task of ["Write the code", "Commit the changes", "Delete temporary data", "Remove obsolete assets", "Update dependencies"]) {
|