@selesai/code 0.13.32 → 0.13.34
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 +28 -0
- package/dist/extensions/capability-gateway/catalog.ts +4 -1
- package/dist/extensions/capability-gateway/index.test.ts +93 -4
- package/dist/extensions/capability-gateway/index.ts +294 -59
- package/dist/extensions/capability-gateway/integration.test.ts +71 -0
- package/dist/extensions/capability-gateway/routing.test.ts +154 -1
- package/dist/extensions/capability-gateway/routing.ts +227 -45
- package/dist/extensions/jev/decisions.test.ts +37 -0
- package/dist/extensions/jev/decisions.ts +148 -43
- package/dist/extensions/jev-ask-tool.test.ts +436 -0
- package/dist/extensions/jev-ask-tool.ts +587 -0
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-hermes-memory/README.md +11 -36
- package/dist/extensions/pi-hermes-memory/src/config.ts +36 -8
- package/dist/extensions/pi-hermes-memory/src/constants.ts +5 -5
- package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +60 -46
- package/dist/extensions/pi-hermes-memory/tests/config.test.ts +26 -2
- package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +7 -1
- package/dist/extensions/pi-intercom/index.ts +5 -1
- package/dist/extensions/pi-subagents/agents/worker.md +3 -2
- package/dist/extensions/pi-subagents/docs/agents.md +2 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
- package/dist/extensions/pi-subagents/docs/observability.md +2 -0
- package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
- package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
- package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
- package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
- package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
- package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
- package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
- package/dist/extensions/rtk.test.ts +21 -13
- package/dist/extensions/tps.test.ts +32 -1
- package/dist/extensions/tps.ts +3 -1
- package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
- package/docs/settings.md +46 -7
- package/package.json +3 -3
|
@@ -9,6 +9,7 @@ import { describe, it, before, after, beforeEach } from "node:test";
|
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
import type { MockPi } from "../support/helpers.ts";
|
|
13
14
|
import { createMockPi, createTempDir, makeAgent, makeAgentConfigs, removeTempDir } from "../support/helpers.ts";
|
|
14
15
|
import { runSync } from "../../src/runs/foreground/execution.ts";
|
|
@@ -17,6 +18,9 @@ import { createNestedRoute } from "../../src/runs/shared/nested-events.ts";
|
|
|
17
18
|
import { createStructuredOutputRuntime } from "../../src/runs/shared/structured-output.ts";
|
|
18
19
|
import type { ForegroundChildSessionControls, SingleResult } from "../../src/shared/types.ts";
|
|
19
20
|
|
|
21
|
+
/** Explicit child tool surfaces also receive the scoped capability gateway controls. */
|
|
22
|
+
const CHILD_CAPABILITY_GATEWAY_TOOLS = ["capability_catalog", "capability_discover", "capability_skill_show"];
|
|
23
|
+
|
|
20
24
|
async function waitFor(read: () => boolean, timeoutMs = 5_000): Promise<void> {
|
|
21
25
|
const deadline = Date.now() + timeoutMs;
|
|
22
26
|
while (!read()) {
|
|
@@ -71,6 +75,30 @@ describe("in-process foreground child", () => {
|
|
|
71
75
|
}
|
|
72
76
|
});
|
|
73
77
|
|
|
78
|
+
it("passes wrapped core tools and the Graft provider to a launched foreground child", async () => {
|
|
79
|
+
const wrapperPath = path.join(tempDir, "wrapped-core-tools.ts");
|
|
80
|
+
const graftExtensionPath = fileURLToPath(new URL("../../../pi-graft/index.ts", import.meta.url));
|
|
81
|
+
const tools = ["read", "grep", "graft_find_code"];
|
|
82
|
+
fs.writeFileSync(wrapperPath, [
|
|
83
|
+
"export default function register(pi) {",
|
|
84
|
+
" const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));",
|
|
85
|
+
" for (const name of ['read', 'grep']) {",
|
|
86
|
+
" const tool = available.get(name);",
|
|
87
|
+
" if (tool) pi.registerTool({ ...tool, description: 'wrapped ' + name + ': ' + tool.description });",
|
|
88
|
+
" }",
|
|
89
|
+
"}",
|
|
90
|
+
].join("\n"));
|
|
91
|
+
mockPi.onCall({ output: "read, grep, and Graft tools were available" });
|
|
92
|
+
const result = await runSync(tempDir, [makeAgent("worker", { tools, subagentOnlyExtensions: [wrapperPath, graftExtensionPath] })], "worker", "Inspect with read, grep, and Graft", { runId: "wrapped-graft-child" });
|
|
93
|
+
assert.equal(result.exitCode, 0, result.error);
|
|
94
|
+
const launch = mockPi.sessions[0]?.launch;
|
|
95
|
+
const expectedTools = [...tools, ...CHILD_CAPABILITY_GATEWAY_TOOLS];
|
|
96
|
+
assert.deepEqual(launch?.tools, expectedTools);
|
|
97
|
+
assert.deepEqual(launch?.runtime.requiredTools, expectedTools);
|
|
98
|
+
assert.ok(launch?.extensionPaths.includes(wrapperPath));
|
|
99
|
+
assert.ok(launch?.extensionPaths.includes(graftExtensionPath));
|
|
100
|
+
});
|
|
101
|
+
|
|
74
102
|
it("adds the fanout hook and nested route only for fanout-authorized children", async () => {
|
|
75
103
|
const route = createNestedRoute("hooks-fanout");
|
|
76
104
|
try {
|
|
@@ -84,7 +112,7 @@ describe("in-process foreground child", () => {
|
|
|
84
112
|
assert.equal(session?.launch.runtime.fanoutChild, true);
|
|
85
113
|
assert.deepEqual(session?.launch.runtime.nestedRoute, route);
|
|
86
114
|
assert.deepEqual(session?.launch.runtime.nestedParent, { parentRunId: "hooks-fanout", parentChildIndex: 0, depth: 1, path: [{ runId: "hooks-fanout", stepIndex: 0, agent: "delegator" }] });
|
|
87
|
-
assert.deepEqual(session?.launch.tools, ["read", "subagent"]);
|
|
115
|
+
assert.deepEqual(session?.launch.tools, ["read", "subagent", ...CHILD_CAPABILITY_GATEWAY_TOOLS]);
|
|
88
116
|
} finally {
|
|
89
117
|
fs.rmSync(path.dirname(route.eventSink), { recursive: true, force: true });
|
|
90
118
|
}
|
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
tryImport,
|
|
24
24
|
} from "../support/helpers.ts";
|
|
25
25
|
|
|
26
|
+
/** Children with an explicit tool surface also receive the scoped capability gateway controls. */
|
|
27
|
+
const CHILD_CAPABILITY_GATEWAY_TOOLS = "capability_catalog,capability_discover,capability_skill_show";
|
|
28
|
+
|
|
26
29
|
interface ExecutorResult {
|
|
27
30
|
content: Array<{ text?: string }>;
|
|
28
31
|
isError?: boolean;
|
|
@@ -308,8 +311,8 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
|
|
|
308
311
|
|
|
309
312
|
const isolatedArgs = await readMockCallArgs(0);
|
|
310
313
|
const normalArgs = await readMockCallArgs(1);
|
|
311
|
-
assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1],
|
|
312
|
-
assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1],
|
|
314
|
+
assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
|
|
315
|
+
assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1], `read,contact_supervisor,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
|
|
313
316
|
assert.equal(events.emitted.filter((entry) => entry.channel === "subagent:result-intercom").length, 1);
|
|
314
317
|
assert.deepEqual(result.details?.workflow?.value, { isolated: "Isolated child output", normal: "Normal child output" });
|
|
315
318
|
});
|
|
@@ -1106,7 +1109,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
|
|
|
1106
1109
|
const args = await readMockCallArgs(0);
|
|
1107
1110
|
assert.equal(args[args.indexOf("--session") + 1], sessionFile);
|
|
1108
1111
|
assert.equal(args[args.indexOf("--model") + 1], "anthropic/claude-sonnet-4:high");
|
|
1109
|
-
assert.equal(args[args.indexOf("--tools") + 1],
|
|
1112
|
+
assert.equal(args[args.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
|
|
1110
1113
|
assert.equal(args.includes("--system-prompt"), true);
|
|
1111
1114
|
assert.equal(args.includes("--append-system-prompt"), false);
|
|
1112
1115
|
await waitForFile(path.join(RESULTS_DIR, `${revivedId}.json`));
|
|
@@ -1623,6 +1623,31 @@ if (!fs.existsSync(${JSON.stringify(holdPath)})) { console.log('{}'); } else {
|
|
|
1623
1623
|
if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
|
|
1624
1624
|
});
|
|
1625
1625
|
|
|
1626
|
+
it("reports bounded structured_output rejection evidence without leaking the submitted value", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1627
|
+
const secret = "PRIVATE_STRUCTURED_OUTPUT_VALUE";
|
|
1628
|
+
mockPi.onCall({
|
|
1629
|
+
stdoutRaw: [
|
|
1630
|
+
{ type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: { secret } } },
|
|
1631
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: value.secret: expected boolean; received value: ${secret}` }] } },
|
|
1632
|
+
{ type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output", isError: true },
|
|
1633
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
1634
|
+
});
|
|
1635
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1636
|
+
const result = await executor.execute(
|
|
1637
|
+
"single-schema-rejected",
|
|
1638
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false },
|
|
1639
|
+
new AbortController().signal,
|
|
1640
|
+
undefined,
|
|
1641
|
+
makeMinimalCtx(tempDir),
|
|
1642
|
+
);
|
|
1643
|
+
|
|
1644
|
+
const child = result.details?.results?.[0];
|
|
1645
|
+
assert.equal(result.isError, true);
|
|
1646
|
+
assert.equal(child?.structuredOutputFailed, true);
|
|
1647
|
+
assert.match(child?.error ?? "", /Structured output validation failed: value.secret/);
|
|
1648
|
+
assert.doesNotMatch(child?.error ?? "", new RegExp(secret));
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1626
1651
|
it("routes retained workflow follow-ups to distinct outputs without overwriting the writer report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1627
1652
|
for (const relative of [false, true]) {
|
|
1628
1653
|
const writerPath = path.join(tempDir, `writer-${relative}.md`);
|
|
@@ -638,16 +638,16 @@ Do work
|
|
|
638
638
|
assert.equal(worker?.defaultContext, "fork");
|
|
639
639
|
});
|
|
640
640
|
|
|
641
|
-
it("loads packaged worker
|
|
641
|
+
it("loads packaged worker with fresh writer defaults and keeps oracle fork context", () => {
|
|
642
642
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-builtin-default-context-"));
|
|
643
643
|
tempDirs.push(dir);
|
|
644
644
|
const agents = discoverAgentsAll(dir).builtin;
|
|
645
645
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
646
|
+
const worker = agents.find((candidate) => candidate.name === "worker");
|
|
647
|
+
assert.equal(worker?.defaultContext, "fresh");
|
|
648
|
+
assert.equal(worker?.acceptanceRole, "writer");
|
|
650
649
|
const oracle = agents.find((candidate) => candidate.name === "oracle");
|
|
650
|
+
assert.equal(oracle?.defaultContext, "fork");
|
|
651
651
|
assert.deepEqual(oracle?.aliases, ["advisor"]);
|
|
652
652
|
assert.doesNotMatch(oracle?.tools?.join(",") ?? "", /contact_supervisor/);
|
|
653
653
|
for (const name of ["scout", "researcher", "oracle", "reviewer"]) {
|
|
@@ -37,7 +37,19 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
|
|
|
37
37
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: pkg, version: "0.85.0", exports }));
|
|
38
38
|
}
|
|
39
39
|
const originalArgv1 = process.argv[1];
|
|
40
|
+
const inheritedGitEnv = {
|
|
41
|
+
GIT_DIR: "/outer/repo/.git",
|
|
42
|
+
GIT_WORK_TREE: "/outer/repo",
|
|
43
|
+
GIT_INDEX_FILE: "/outer/repo/.git/index",
|
|
44
|
+
GIT_CONFIG_COUNT: "1",
|
|
45
|
+
GIT_CONFIG_KEY_0: "core.hooksPath",
|
|
46
|
+
GIT_CONFIG_VALUE_0: "/outer/hooks",
|
|
47
|
+
GIT_AUTHOR_NAME: "Kept Author",
|
|
48
|
+
GIT_ENV_SENTINEL_KEEP: "kept",
|
|
49
|
+
};
|
|
50
|
+
const previousGitEnv = Object.fromEntries(Object.keys(inheritedGitEnv).map((key) => [key, process.env[key]]));
|
|
40
51
|
try {
|
|
52
|
+
Object.assign(process.env, inheritedGitEnv);
|
|
41
53
|
for (const pkg of Object.keys(hostExports)) {
|
|
42
54
|
if (pkg !== server) writeHostPackage(pkg);
|
|
43
55
|
}
|
|
@@ -79,6 +91,11 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
|
|
|
79
91
|
assert.equal(spawn.mock.callCount(), scenario === "supplemental" ? 1 : scenario === "complete" ? 2 : 3);
|
|
80
92
|
const [command, args, options] = spawn.mock.calls.at(-1)!.arguments;
|
|
81
93
|
assert.ok(path.isAbsolute(command));
|
|
94
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"]) {
|
|
95
|
+
assert.equal(options.env[key], undefined, key);
|
|
96
|
+
}
|
|
97
|
+
assert.equal(options.env.GIT_AUTHOR_NAME, "Kept Author");
|
|
98
|
+
assert.equal(options.env.GIT_ENV_SENTINEL_KEEP, "kept");
|
|
82
99
|
assert.equal(options.env[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV], host);
|
|
83
100
|
const actualAliases = JSON.parse(options.env.JITI_ALIAS) as Record<string, string>;
|
|
84
101
|
assert.deepEqual(Object.fromEntries(Object.entries(actualAliases).map(([key, target]) => [key, fs.realpathSync(target)])), expectedAliases);
|
|
@@ -96,6 +113,10 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
|
|
|
96
113
|
} finally {
|
|
97
114
|
t.mock.restoreAll();
|
|
98
115
|
syncBuiltinESMExports();
|
|
116
|
+
for (const [key, value] of Object.entries(previousGitEnv)) {
|
|
117
|
+
if (value === undefined) delete process.env[key];
|
|
118
|
+
else process.env[key] = value;
|
|
119
|
+
}
|
|
99
120
|
if (originalArgv1 === undefined) delete process.argv[1];
|
|
100
121
|
else process.argv[1] = originalArgv1;
|
|
101
122
|
fs.rmSync(root, { recursive: true, force: true });
|
|
@@ -11,6 +11,7 @@ import * as path from "node:path";
|
|
|
11
11
|
import { afterEach, describe, it } from "node:test";
|
|
12
12
|
import { buildInProcessChildLaunch, type BuildInProcessChildLaunchInput } from "../../src/runs/shared/child-launch.ts";
|
|
13
13
|
import {
|
|
14
|
+
CAPABILITY_GATEWAY_EXTENSION_PATH,
|
|
14
15
|
resolvePermissionSystemExtension,
|
|
15
16
|
resolvePiLaunchToolPlan,
|
|
16
17
|
} from "../../src/runs/shared/child-tool-plan.ts";
|
|
@@ -19,6 +20,7 @@ const originalEnv = {
|
|
|
19
20
|
HOME: process.env.HOME,
|
|
20
21
|
USERPROFILE: process.env.USERPROFILE,
|
|
21
22
|
SELESAI_CODING_AGENT_DIR: process.env.SELESAI_CODING_AGENT_DIR,
|
|
23
|
+
SELESAI_CAPABILITY_GATEWAY: process.env.SELESAI_CAPABILITY_GATEWAY,
|
|
22
24
|
};
|
|
23
25
|
const tempRoots: string[] = [];
|
|
24
26
|
|
|
@@ -33,6 +35,7 @@ function createFixture() {
|
|
|
33
35
|
process.env.HOME = home;
|
|
34
36
|
process.env.USERPROFILE = home;
|
|
35
37
|
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
38
|
+
delete process.env.SELESAI_CAPABILITY_GATEWAY;
|
|
36
39
|
process.chdir(projectDir);
|
|
37
40
|
return { root, agentDir, projectDir };
|
|
38
41
|
}
|
|
@@ -397,3 +400,98 @@ describe("child launch <active_agent> tag injection", () => {
|
|
|
397
400
|
);
|
|
398
401
|
});
|
|
399
402
|
});
|
|
403
|
+
|
|
404
|
+
describe("capability gateway child runtime policy", () => {
|
|
405
|
+
const gatewayTools = ["capability_catalog", "capability_discover", "capability_skill_show"];
|
|
406
|
+
|
|
407
|
+
for (const host of ["parent", "runner"] as const) {
|
|
408
|
+
it(`adds only gateway controls for extension-permitted ${host} children`, () => {
|
|
409
|
+
const { agentDir } = createFixture();
|
|
410
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
411
|
+
|
|
412
|
+
const launch = buildInProcessChildLaunch(childLaunch({ host, tools: ["read", "web_explore"] }));
|
|
413
|
+
assert.equal(launch.toolPlan.capabilityGatewayEnabled, true);
|
|
414
|
+
assert.ok(launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
415
|
+
assert.deepEqual(launch.session.tools, ["read", "web_explore", ...gatewayTools]);
|
|
416
|
+
assert.ok(gatewayTools.every((tool) => launch.toolPlan.requiredChildTools.includes(tool)));
|
|
417
|
+
if (host === "runner") {
|
|
418
|
+
assert.equal(launch.session.ambientExtensions, true);
|
|
419
|
+
assert.ok(launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
420
|
+
} else {
|
|
421
|
+
assert.equal(launch.session.ambientExtensions, false);
|
|
422
|
+
assert.equal(launch.session.processEnv, undefined);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
it("keeps omitted tools unrestricted without synthesizing an explicit allowlist", () => {
|
|
428
|
+
const { agentDir } = createFixture();
|
|
429
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
430
|
+
const launch = buildInProcessChildLaunch(childLaunch({ host: "runner" }));
|
|
431
|
+
assert.equal(launch.toolPlan.capabilityGatewayEnabled, true);
|
|
432
|
+
assert.equal(launch.session.tools, undefined);
|
|
433
|
+
assert.ok(launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
for (const host of ["parent", "runner"] as const) {
|
|
437
|
+
it(`skips gateway for ${host} children with an explicit empty tool set`, () => {
|
|
438
|
+
const { agentDir } = createFixture();
|
|
439
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
440
|
+
|
|
441
|
+
const launch = buildInProcessChildLaunch(childLaunch({ host, tools: [] }));
|
|
442
|
+
assert.equal(launch.toolPlan.capabilityGatewayEnabled, false);
|
|
443
|
+
assert.deepEqual(launch.session.tools, []);
|
|
444
|
+
assert.ok(!launch.toolPlan.extensionArgs.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
445
|
+
assert.ok(!launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
it("does not widen a capability ceiling and honors explicit extension policy", () => {
|
|
450
|
+
const { agentDir, projectDir } = createFixture();
|
|
451
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
452
|
+
const limited = resolvePiLaunchToolPlan({
|
|
453
|
+
tools: ["read", "web_explore"],
|
|
454
|
+
capabilityCeiling: { version: 1, allowedTools: ["read", "web_explore"], denyExtensions: false, sources: ["test"] },
|
|
455
|
+
});
|
|
456
|
+
assert.equal(limited.capabilityGatewayEnabled, false);
|
|
457
|
+
assert.deepEqual(limited.effectiveToolAllowlist, ["read", "web_explore"]);
|
|
458
|
+
assert.ok(!limited.extensionArgs.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
459
|
+
|
|
460
|
+
const explicitBlock = resolvePiLaunchToolPlan({
|
|
461
|
+
tools: ["read"],
|
|
462
|
+
extensions: [path.join(projectDir, "other-extension.ts")],
|
|
463
|
+
});
|
|
464
|
+
assert.equal(explicitBlock.capabilityGatewayEnabled, false);
|
|
465
|
+
assert.ok(!explicitBlock.extensionArgs.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
466
|
+
|
|
467
|
+
const explicitOptIn = resolvePiLaunchToolPlan({
|
|
468
|
+
tools: ["read"],
|
|
469
|
+
extensions: [],
|
|
470
|
+
subagentOnlyExtensions: [path.dirname(CAPABILITY_GATEWAY_EXTENSION_PATH)],
|
|
471
|
+
});
|
|
472
|
+
assert.equal(explicitOptIn.capabilityGatewayEnabled, true);
|
|
473
|
+
assert.ok(explicitOptIn.extensionArgs.includes(path.dirname(CAPABILITY_GATEWAY_EXTENSION_PATH)));
|
|
474
|
+
assert.ok(explicitOptIn.effectiveToolAllowlist.includes("capability_discover"));
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
for (const host of ["parent", "runner"] as const) {
|
|
478
|
+
it(`does not load or authorize the gateway for ${host} children with denyExtensions`, () => {
|
|
479
|
+
const { agentDir } = createFixture();
|
|
480
|
+
process.env.SELESAI_CODING_AGENT_DIR = agentDir;
|
|
481
|
+
|
|
482
|
+
const launch = buildInProcessChildLaunch(childLaunch({
|
|
483
|
+
host,
|
|
484
|
+
tools: ["read"],
|
|
485
|
+
subagentOnlyExtensions: [CAPABILITY_GATEWAY_EXTENSION_PATH],
|
|
486
|
+
capabilityCeiling: { version: 1, allowedTools: ["read"], denyExtensions: true, sources: ["test"] },
|
|
487
|
+
}));
|
|
488
|
+
assert.equal(launch.toolPlan.capabilityGatewayEnabled, false);
|
|
489
|
+
assert.deepEqual(launch.session.tools, ["read"]);
|
|
490
|
+
assert.ok(!launch.session.extensionPaths.includes(CAPABILITY_GATEWAY_EXTENSION_PATH));
|
|
491
|
+
assert.ok(!launch.toolPlan.requiredChildTools.some((tool) => tool.startsWith("capability_")));
|
|
492
|
+
if (host === "runner") {
|
|
493
|
+
assert.equal(launch.session.ambientExtensions, false);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
});
|
|
@@ -19,6 +19,17 @@ function runtimeSnapshotHost(serverName: string): McpRuntimeSnapshotHost {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
describe("child tool plan", () => {
|
|
22
|
+
it("requires fanout authorization for the child supervisor reply tool", () => {
|
|
23
|
+
assert.throws(() => resolvePiLaunchToolPlan({ tools: ["read", "subagent_supervisor"] }), /subagent_supervisor.*requires fanout authorization/);
|
|
24
|
+
assert.throws(() => resolvePiLaunchToolPlan({ tools: ["read", "subagent", "subagent_supervisor"], excludeTools: ["subagent"] }), /subagent_supervisor.*requires fanout authorization/);
|
|
25
|
+
assert.throws(() => resolvePiLaunchToolPlan({
|
|
26
|
+
tools: ["read", "subagent", "subagent_supervisor"],
|
|
27
|
+
capabilityCeiling: { version: 1, allowedTools: ["read", "subagent_supervisor"], sources: ["test"] },
|
|
28
|
+
}), /subagent_supervisor.*requires fanout authorization/);
|
|
29
|
+
assert.equal(resolvePiLaunchToolPlan({ tools: ["read", "subagent", "subagent_supervisor"] }).fanoutAuthorized, true);
|
|
30
|
+
assert.equal(resolvePiLaunchToolPlan({ tools: ["read", "subagent_supervisor"], allowNestedSubagents: true }).fanoutAuthorized, true);
|
|
31
|
+
});
|
|
32
|
+
|
|
22
33
|
it("fails a launch that selects MCP tools from the adapter's runtime snapshot", () => {
|
|
23
34
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-runtime-mcp-"));
|
|
24
35
|
try {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
evaluateCompletionMutationGuard,
|
|
8
8
|
expectsImplementationMutation,
|
|
9
9
|
hasMutationToolCall,
|
|
10
|
+
hasMutationToolCapability,
|
|
10
11
|
validateImplementationToolContract,
|
|
11
12
|
} from "../../src/runs/shared/completion-guard.ts";
|
|
12
13
|
import { isMutatingTool } from "../../src/runs/shared/long-running-guard.ts";
|
|
@@ -500,6 +501,17 @@ test("edit and write tool calls count as mutation attempts", () => {
|
|
|
500
501
|
assert.equal(hasMutationToolCall([assistantToolCall("write", { path: "a.ts" })]), true);
|
|
501
502
|
});
|
|
502
503
|
|
|
504
|
+
test("capability gateway controls never count as mutation capability", () => {
|
|
505
|
+
const gatewayTools = ["capability_catalog", "capability_discover", "capability_skill_show"];
|
|
506
|
+
assert.equal(hasMutationToolCapability(["read", "grep", ...gatewayTools], undefined), false);
|
|
507
|
+
assert.equal(validateImplementationToolContract({
|
|
508
|
+
agent: "worker",
|
|
509
|
+
task: "Implement the requested source fix.",
|
|
510
|
+
tools: ["read", "grep", "find", "ls", "contact_supervisor", ...gatewayTools],
|
|
511
|
+
}), "Agent 'worker' was given an implementation task, but its tool allowlist has no mutation-capable tools. Add bash, edit, write, or another mutation-capable tool to the agent, or use a read-only task/agent.");
|
|
512
|
+
assert.equal(hasMutationToolCapability(["read", "web_explore"], undefined), true);
|
|
513
|
+
});
|
|
514
|
+
|
|
503
515
|
test("declared extension mutation tools count without weakening unknown tools", () => {
|
|
504
516
|
const messages = [assistantToolCall("replace", { remove_from: "Liv" })];
|
|
505
517
|
assert.equal(hasMutationToolCall(messages), false);
|
|
@@ -43,6 +43,31 @@ describe("external CLI runner", () => {
|
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("strips inherited Git routing vars but honors an explicit environment allowlist", async () => {
|
|
47
|
+
const dir = tempDir();
|
|
48
|
+
const inherited = { GIT_DIR: "/outer/repo/.git", GIT_AUTHOR_NAME: "Kept Author" };
|
|
49
|
+
const previous = Object.fromEntries(Object.keys(inherited).map((key) => [key, process.env[key]]));
|
|
50
|
+
Object.assign(process.env, inherited);
|
|
51
|
+
const script = "process.stdout.write(JSON.stringify({gitDir:process.env.GIT_DIR??null,author:process.env.GIT_AUTHOR_NAME??null}))";
|
|
52
|
+
try {
|
|
53
|
+
const defaultEnv = await runExternalCli({ command: process.execPath, args: ["-e", script], cwd: dir, prompt: "x", asyncDir: dir, stepIndex: 1 });
|
|
54
|
+
assert.equal(defaultEnv.exitCode, 0);
|
|
55
|
+
assert.deepEqual(JSON.parse(defaultEnv.output), { gitDir: null, author: "Kept Author" });
|
|
56
|
+
|
|
57
|
+
const explicitEnv = await runExternalCli({
|
|
58
|
+
command: process.execPath, args: ["-e", script], cwd: dir, prompt: "x", asyncDir: dir, stepIndex: 2,
|
|
59
|
+
environment: { allowlist: ["GIT_DIR"] },
|
|
60
|
+
});
|
|
61
|
+
assert.equal(explicitEnv.exitCode, 0);
|
|
62
|
+
assert.deepEqual(JSON.parse(explicitEnv.output), { gitDir: "/outer/repo/.git", author: null });
|
|
63
|
+
} finally {
|
|
64
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
65
|
+
if (value === undefined) delete process.env[key];
|
|
66
|
+
else process.env[key] = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
46
71
|
it("delivers the combined prompt only through stdin and preserves argv", async () => {
|
|
47
72
|
const dir = tempDir();
|
|
48
73
|
const prompt = buildExternalCliPrompt("Follow exactly.", "Review $HOME; echo nope");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { omitGitRoutingEnv } from "../../src/runs/shared/git-environment.ts";
|
|
4
|
+
|
|
5
|
+
const GIT_ROUTING_ENV = {
|
|
6
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: "/repo/objects",
|
|
7
|
+
GIT_COMMON_DIR: "/repo/common",
|
|
8
|
+
GIT_CONFIG: "/repo/config",
|
|
9
|
+
GIT_CONFIG_COUNT: "1",
|
|
10
|
+
GIT_CONFIG_KEY_0: "core.worktree",
|
|
11
|
+
GIT_CONFIG_PARAMETERS: "'core.worktree=/repo'",
|
|
12
|
+
GIT_CONFIG_VALUE_0: "/repo",
|
|
13
|
+
GIT_DIR: "/repo/.git",
|
|
14
|
+
GIT_GRAFT_FILE: "/repo/grafts",
|
|
15
|
+
GIT_IMPLICIT_WORK_TREE: "1",
|
|
16
|
+
GIT_INDEX_FILE: "/repo/index",
|
|
17
|
+
GIT_NAMESPACE: "tenant",
|
|
18
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
19
|
+
GIT_OBJECT_DIRECTORY: "/repo/object-dir",
|
|
20
|
+
GIT_PREFIX: "src/",
|
|
21
|
+
GIT_REPLACE_REF_BASE: "refs/replace/",
|
|
22
|
+
GIT_SHALLOW_FILE: "/repo/shallow",
|
|
23
|
+
GIT_WORK_TREE: "/repo",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("omitGitRoutingEnv", () => {
|
|
27
|
+
it("removes inherited repository routing and keeps unrelated values", () => {
|
|
28
|
+
assert.deepEqual(omitGitRoutingEnv({ ...GIT_ROUTING_ENV, KEEP_ME: "yes" }), { KEEP_ME: "yes" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("removes every indexed Git config entry", () => {
|
|
32
|
+
assert.deepEqual(omitGitRoutingEnv({ GIT_CONFIG_KEY_17: "user.name", GIT_CONFIG_VALUE_17: "Test", KEEP_ME: "yes" }), { KEEP_ME: "yes" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("matches names case-insensitively, as Windows and Git for Windows do", () => {
|
|
36
|
+
assert.deepEqual(omitGitRoutingEnv({ git_dir: "/repo/.git", Git_Work_Tree: "/repo", git_config_key_0: "core.bare", KEEP_ME: "yes" }), { KEEP_ME: "yes" });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -857,7 +857,9 @@ Project prompt.
|
|
|
857
857
|
assert.equal(result.ok, true);
|
|
858
858
|
if (!result.ok) return;
|
|
859
859
|
assert.deepEqual(result.contract.tools.excludeTools, ["write", "unknown_tool"]);
|
|
860
|
-
|
|
860
|
+
// An explicit tool surface also gets the scoped capability gateway tools, which
|
|
861
|
+
// this launch does not exclude (see the capability-gateway child policy tests).
|
|
862
|
+
assert.deepEqual(result.contract.tools.effectiveAllowlist, ["read", "capability_catalog", "capability_discover", "capability_skill_show"]);
|
|
861
863
|
assert.match(result.contract.launchContractDigest, /^[a-f0-9]{64}$/);
|
|
862
864
|
});
|
|
863
865
|
|
|
@@ -5,6 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { describe, it } from "node:test";
|
|
6
6
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
7
7
|
import { consumeStopRequestPayload, stopRequestPath, stopRequestsDir } from "../../src/runs/background/control-channel.ts";
|
|
8
|
+
import { getArtifactPaths, getArtifactsDir } from "../../src/shared/artifacts.ts";
|
|
8
9
|
import {
|
|
9
10
|
SUBAGENT_RPC_PROTOCOL_VERSION,
|
|
10
11
|
SUBAGENT_RPC_READY_EVENT,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
subagentRpcReplyEvent,
|
|
14
15
|
type SubagentRpcReplyEnvelope,
|
|
15
16
|
} from "../../src/extension/rpc.ts";
|
|
16
|
-
import { SUBAGENT_CHILD_STATUS_EVENT, type Details, type SubagentChildStatusEvent, type SubagentState } from "../../src/shared/types.ts";
|
|
17
|
+
import { DIRS, SUBAGENT_CHILD_STATUS_EVENT, type Details, type SubagentChildStatusEvent, type SubagentState } from "../../src/shared/types.ts";
|
|
17
18
|
|
|
18
19
|
class FakeEvents {
|
|
19
20
|
readonly emitted: Array<{ event: string; data: unknown }> = [];
|
|
@@ -117,6 +118,8 @@ describe("subagent extension RPC bridge", () => {
|
|
|
117
118
|
(reply as { data: { capabilities?: { statusProjection?: unknown } } }).data.capabilities?.statusProjection,
|
|
118
119
|
{ version: 1, untargeted: "in-memory-when-ready", targeted: "executor" },
|
|
119
120
|
);
|
|
121
|
+
assert.deepEqual((reply as { data: { capabilities?: { cost?: unknown } } }).data.capabilities?.cost, { version: 1 });
|
|
122
|
+
assert.ok((reply as { data: { methods?: string[] } }).data.methods?.includes("cost"));
|
|
120
123
|
|
|
121
124
|
bridge.dispose();
|
|
122
125
|
});
|
|
@@ -1147,4 +1150,105 @@ describe("subagent extension RPC bridge", () => {
|
|
|
1147
1150
|
fs.rmSync(root, { recursive: true, force: true });
|
|
1148
1151
|
}
|
|
1149
1152
|
});
|
|
1153
|
+
|
|
1154
|
+
it("answers cost with structured parent-plus-child accounting from the session branch", async () => {
|
|
1155
|
+
const events = new FakeEvents();
|
|
1156
|
+
const childUsage = { input: 8, output: 3, cacheRead: 5, cacheWrite: 0, cost: 0.25, turns: 1 };
|
|
1157
|
+
const branch = [
|
|
1158
|
+
{ type: "message", message: { role: "assistant", usage: { input: 15, output: 3, cacheRead: 30, cacheWrite: 0, cost: { total: 0.25 } } } },
|
|
1159
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent", details: { mode: "single", results: [{ agent: "reviewer", runId: "run-a", usage: childUsage, sessionFile: "/sessions/child-a.jsonl" }] } } },
|
|
1160
|
+
{ type: "message", message: { role: "toolResult", toolName: "bg_wait", details: { mode: "single", results: [], completions: [{ mode: "single", runId: "run-a", results: [{ agent: "reviewer", runId: "run-a", usage: childUsage }] }] } } },
|
|
1161
|
+
];
|
|
1162
|
+
const bridge = registerSubagentRpcBridge({
|
|
1163
|
+
events,
|
|
1164
|
+
getContext: () => ({
|
|
1165
|
+
cwd: "/repo",
|
|
1166
|
+
sessionManager: { getSessionId: () => "session-123", getSessionFile: () => "/sessions/parent.jsonl", getBranch: () => branch },
|
|
1167
|
+
}) as any,
|
|
1168
|
+
execute: async () => assert.fail("cost should not call executor"),
|
|
1169
|
+
state: { baseCwd: "/repo" } as SubagentState,
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
const reply = await request(events, "cost-1", "cost");
|
|
1173
|
+
assert.equal(reply.success, true);
|
|
1174
|
+
assert.equal(reply.method, "cost");
|
|
1175
|
+
const data = (reply as { data: { version: number; parent: Record<string, number>; children: Array<{ agent?: string; runId?: string }>; childTotal: Record<string, number>; total: Record<string, number>; unresolvedAsyncChildren: number } }).data;
|
|
1176
|
+
assert.equal(data.version, 1);
|
|
1177
|
+
assert.deepEqual(data.parent, { input: 15, output: 3, cacheRead: 30, cacheWrite: 0, cost: 0.25, turns: 1 });
|
|
1178
|
+
assert.equal(data.children.length, 1, "run identity deduplicates the bg_wait completion");
|
|
1179
|
+
assert.equal(data.children[0]?.agent, "reviewer");
|
|
1180
|
+
assert.equal(data.children[0]?.runId, "run-a");
|
|
1181
|
+
assert.deepEqual(data.childTotal, childUsage);
|
|
1182
|
+
assert.deepEqual(data.total, { input: 23, output: 6, cacheRead: 35, cacheWrite: 0, cost: 0.5, turns: 2 });
|
|
1183
|
+
assert.equal(data.unresolvedAsyncChildren, 0);
|
|
1184
|
+
|
|
1185
|
+
const rejected = await request(events, "cost-2", "cost", "not-an-object");
|
|
1186
|
+
assert.equal(rejected.success, false);
|
|
1187
|
+
assert.equal((rejected as { error: { code: string } }).error.code, "invalid_params");
|
|
1188
|
+
bridge.dispose();
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it("answers cost with receipt-recovered async usage and an unresolved lower bound", async () => {
|
|
1192
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-rpc-cost-async-"));
|
|
1193
|
+
const workflowRunId = `workflow-rpc-cost-${process.pid}-${Date.now()}`;
|
|
1194
|
+
const recoveredRunId = `child-rpc-cost-${process.pid}-${Date.now()}`;
|
|
1195
|
+
const unresolvedRunId = `child-rpc-cost-missing-${process.pid}-${Date.now()}`;
|
|
1196
|
+
const unresolvedWorkflowRunId = `workflow-rpc-cost-missing-${process.pid}-${Date.now()}`;
|
|
1197
|
+
const asyncDir = path.join(DIRS.async, workflowRunId);
|
|
1198
|
+
try {
|
|
1199
|
+
const sessionFile = path.join(root, "sessions", "parent.jsonl");
|
|
1200
|
+
const artifactsDir = getArtifactsDir(sessionFile, root, "session");
|
|
1201
|
+
const metadataPath = getArtifactPaths(artifactsDir, recoveredRunId, "reviewer", 0).metadataPath;
|
|
1202
|
+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
1203
|
+
fs.writeFileSync(sessionFile, "", "utf-8");
|
|
1204
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
1205
|
+
fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
|
|
1206
|
+
fs.writeFileSync(path.join(asyncDir, "workflow-receipt.json"), JSON.stringify({
|
|
1207
|
+
version: 1, workflowRunId, state: "complete", createdAt: Date.now(),
|
|
1208
|
+
entries: {
|
|
1209
|
+
recovered: { key: "recovered", agent: "reviewer", latestRunId: recoveredRunId, continuation: { runIds: [recoveredRunId] }, resumability: { state: "resumable" } },
|
|
1210
|
+
unresolved: { key: "unresolved", agent: "worker", latestRunId: unresolvedRunId, continuation: { runIds: [unresolvedRunId] }, resumability: { state: "resumable" } },
|
|
1211
|
+
nestedWorkflow: { key: "nestedWorkflow", latestRunId: unresolvedWorkflowRunId, continuation: { runIds: [unresolvedWorkflowRunId] }, resumability: { state: "resumable" } },
|
|
1212
|
+
},
|
|
1213
|
+
}, null, 2), "utf-8");
|
|
1214
|
+
const recoveredUsage = { input: 20, output: 4, cacheRead: 80, cacheWrite: 0, cost: 0.5, turns: 2 };
|
|
1215
|
+
fs.writeFileSync(metadataPath, JSON.stringify({ runId: recoveredRunId, agent: "reviewer", usage: recoveredUsage }), "utf-8");
|
|
1216
|
+
|
|
1217
|
+
const events = new FakeEvents();
|
|
1218
|
+
const bridge = registerSubagentRpcBridge({
|
|
1219
|
+
events,
|
|
1220
|
+
getContext: () => ({
|
|
1221
|
+
cwd: root,
|
|
1222
|
+
sessionManager: {
|
|
1223
|
+
getSessionId: () => "session-async-cost",
|
|
1224
|
+
getSessionFile: () => sessionFile,
|
|
1225
|
+
getBranch: () => [{ type: "message", message: { role: "toolResult", toolName: "subagent", details: { mode: "workflow", runId: workflowRunId, results: [] } } }],
|
|
1226
|
+
},
|
|
1227
|
+
}) as any,
|
|
1228
|
+
execute: async () => assert.fail("cost should not call executor"),
|
|
1229
|
+
state: { baseCwd: root, artifactDirPreference: "session" } as SubagentState,
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
const reply = await request(events, "cost-async", "cost");
|
|
1233
|
+
assert.equal(reply.success, true);
|
|
1234
|
+
const data = (reply as { data: { children: Array<{ agent?: string; runId?: string }>; childTotal: Record<string, number>; total: Record<string, number>; unresolvedAsyncChildren: number } }).data;
|
|
1235
|
+
assert.deepEqual(data.children.map(({ agent, runId }) => ({ agent, runId })), [{ agent: "reviewer", runId: recoveredRunId }]);
|
|
1236
|
+
assert.deepEqual(data.childTotal, recoveredUsage);
|
|
1237
|
+
assert.deepEqual(data.total, recoveredUsage, "totals remain a lower bound when metadata is unavailable");
|
|
1238
|
+
assert.equal(data.unresolvedAsyncChildren, 2);
|
|
1239
|
+
bridge.dispose();
|
|
1240
|
+
} finally {
|
|
1241
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
1242
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it("cost requires an active session context like every non-ping method", async () => {
|
|
1247
|
+
const events = new FakeEvents();
|
|
1248
|
+
const bridge = registerSubagentRpcBridge({ events, getContext: () => null, execute: async () => assert.fail("cost should not call executor") });
|
|
1249
|
+
const reply = await request(events, "cost-3", "cost");
|
|
1250
|
+
assert.equal(reply.success, false);
|
|
1251
|
+
assert.equal((reply as { error: { code: string } }).error.code, "no_active_session");
|
|
1252
|
+
bridge.dispose();
|
|
1253
|
+
});
|
|
1150
1254
|
});
|
|
@@ -1630,4 +1630,40 @@ describe("async run status inspection", () => {
|
|
|
1630
1630
|
fs.rmSync(root, { recursive: true, force: true });
|
|
1631
1631
|
}
|
|
1632
1632
|
});
|
|
1633
|
+
|
|
1634
|
+
it("returns a workflow terminal proof after every async child exits", () => {
|
|
1635
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-status-workflow-terminal-"));
|
|
1636
|
+
try {
|
|
1637
|
+
const asyncRoot = path.join(root, "runs");
|
|
1638
|
+
const asyncDir = path.join(asyncRoot, "workflow-parent");
|
|
1639
|
+
const childDir = path.join(asyncRoot, "child-run");
|
|
1640
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
1641
|
+
fs.mkdirSync(childDir);
|
|
1642
|
+
const childProof = {
|
|
1643
|
+
version: 1, state: "observed", runId: "child-run", runnerProcessInstanceId: "runner-1", observedAt: 300,
|
|
1644
|
+
instances: [{ kind: "runner", processInstanceId: "runner-1", closeObservedAt: 300, exitCode: 0, signal: null }],
|
|
1645
|
+
};
|
|
1646
|
+
fs.writeFileSync(path.join(childDir, "process-terminal.json"), JSON.stringify(childProof));
|
|
1647
|
+
fs.writeFileSync(path.join(childDir, "status.json"), JSON.stringify({
|
|
1648
|
+
runId: "child-run", mode: "single", state: "complete", startedAt: 100, lastUpdate: 300,
|
|
1649
|
+
processTerminal: { version: 1, state: "pending", runId: "child-run", runnerProcessInstanceId: "runner-1" },
|
|
1650
|
+
}));
|
|
1651
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
1652
|
+
runId: "workflow-parent", mode: "workflow", state: "complete", startedAt: 100, lastUpdate: 200, endedAt: 250,
|
|
1653
|
+
steps: [{ agent: "worker", workflowKey: "main", runId: "child-run", async: true, status: "completed" }],
|
|
1654
|
+
workflowChildren: {
|
|
1655
|
+
version: 1, parentToolCallId: "tool-call", workflowRunId: "workflow-parent", inventoryComplete: true,
|
|
1656
|
+
workflowState: "completed", children: [{ childId: "main", runId: "child-run", state: "completed" }],
|
|
1657
|
+
},
|
|
1658
|
+
}, null, 2), "utf-8");
|
|
1659
|
+
|
|
1660
|
+
const result = inspectSubagentStatus({ id: "workflow-parent" }, { asyncDirRoot: asyncRoot, resultsDir: path.join(root, "results") });
|
|
1661
|
+
assert.deepEqual(result.details.workflowTerminalProof, {
|
|
1662
|
+
version: 1, kind: "workflow", runId: "workflow-parent", state: "observed", dispatchClosed: true,
|
|
1663
|
+
observedAt: 300, children: [childProof],
|
|
1664
|
+
});
|
|
1665
|
+
} finally {
|
|
1666
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1633
1669
|
});
|