@maxiaochao/pi-toolkit 0.11.21 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +11 -0
- package/packages/herdr-subagents/README.md +3 -3
- package/packages/herdr-subagents/TESTING.md +12 -2
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +2 -2
- package/packages/herdr-subagents/package.json +2 -2
- package/packages/herdr-subagents/src/batch-runner.ts +2 -1
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/tool.ts +2 -2
- package/packages/herdr-subagents/src/validation.ts +3 -0
- package/packages/herdr-subagents/tests/core-state-machine.mjs +56 -4
- package/packages/herdr-subagents/tests/real-herdr-config.mjs +14 -0
- package/packages/herdr-subagents/tests/real-herdr-config.test.mjs +80 -0
- package/packages/herdr-subagents/tests/real-herdr-smoke.mjs +7 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2026-10-15 - v0.12.0
|
|
2
|
+
|
|
3
|
+
- herdr-subagents v0.6.0: require explicit task Personas and clarify concise tool descriptions
|
|
4
|
+
- Require an explicit Herdr Subagents Persona for every new task, clarify concise tool descriptions, and identify tasks with conflicting write access in parallel requests.
|
|
5
|
+
|
|
6
|
+
## 2026-10-15 - v0.11.22
|
|
7
|
+
|
|
8
|
+
- herdr-subagents v0.5.4: preserve configured model and authentication extensions in isolated real-Herdr smoke tests
|
|
9
|
+
- Herdr Subagents: preserve configured models and explicit authentication extensions in isolated real-Herdr E2E tests instead of losing account-pool setup.
|
|
10
|
+
|
|
1
11
|
## 2026-10-15 - v0.11.21
|
|
2
12
|
|
|
3
13
|
- simplify Agent Team to config-driven orchestration with persistent investigation records and optional extra review
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Restart Pi after updating. `pi update --extensions` has the same effect when upd
|
|
|
39
39
|
|
|
40
40
|
Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
|
|
41
41
|
|
|
42
|
-
Herdr Subagents
|
|
42
|
+
Herdr Subagents tasks require an explicit `agent` Persona; no default `worker` is selected. Personas can opt into local extension files or npm/git Pi packages through `extensions`. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples. The [real Herdr smoke test](packages/herdr-subagents/TESTING.md) preserves the configured analyst model and explicit authentication extensions while isolating unrelated settings.
|
|
43
43
|
|
|
44
44
|
Multiple standalone children may be installed together. `pi-openai-account-pool` may also be installed alongside the complete toolkit because the toolkit does not embed it. Every other child in the table above is embedded, so a second install exposes the same resource twice.
|
|
45
45
|
|
package/package.json
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2026-10-15 - v0.6.0
|
|
2
|
+
|
|
3
|
+
- require explicit task Personas and clarify concise tool descriptions
|
|
4
|
+
- Require an explicit Herdr Subagents Persona for every new task, clarify concise tool descriptions, and identify tasks with conflicting write access in parallel requests.
|
|
5
|
+
|
|
6
|
+
## 2026-10-15 - v0.5.4
|
|
7
|
+
|
|
8
|
+
- preserve configured model and authentication extensions in isolated real-Herdr smoke tests
|
|
9
|
+
- Preserve the configured analyst model, thinking level, and explicit authentication extensions in isolated real-Herdr smoke tests; append the concurrent package probe without replacing those extensions.
|
|
10
|
+
- Add deterministic smoke-configuration coverage and isolate exposure test fixtures from personal settings.
|
|
11
|
+
|
|
1
12
|
## 2026-10-14 - v0.5.3
|
|
2
13
|
|
|
3
14
|
- allow optional Pi-native tool exposure while inheriting Pi defaults when unset
|
|
@@ -26,7 +26,7 @@ The tools and command are registered only when Pi is running inside Herdr with a
|
|
|
26
26
|
|
|
27
27
|
## Execution contract
|
|
28
28
|
|
|
29
|
-
`herdr_subagents` accepts an ordered, non-empty `tasks` array.
|
|
29
|
+
`herdr_subagents` accepts an ordered, non-empty `tasks` array. Every task must explicitly set `agent`; there is no default Persona.
|
|
30
30
|
|
|
31
31
|
- `concurrency: 1` runs tasks strictly in input order.
|
|
32
32
|
- `concurrency: N` runs at most N read-only tasks at once.
|
|
@@ -110,7 +110,7 @@ Starts one ordered batch and waits until every task reaches a terminal state. It
|
|
|
110
110
|
| `concurrency` | No | `defaultConcurrency` | Maximum number of simultaneously active read-only tasks. It is capped by `maxConcurrency`. |
|
|
111
111
|
| `tasks[].name` | Yes | — | Short pane/task name, at most 48 characters. |
|
|
112
112
|
| `tasks[].prompt` | Yes | — | Self-contained task instructions. |
|
|
113
|
-
| `tasks[].agent` |
|
|
113
|
+
| `tasks[].agent` | Yes | — | Effective Persona name; use control `list` to inspect available Personas. |
|
|
114
114
|
| `tasks[].model` | No | Persona/default model | Model override for this task. |
|
|
115
115
|
|
|
116
116
|
A batch containing a write-capable Persona must explicitly or effectively use `concurrency: 1`; an unsafe parallel request is rejected.
|
|
@@ -321,4 +321,4 @@ Use `/herdr-subagents agents`, `/herdr-subagents models`, and `/herdr-subagents
|
|
|
321
321
|
|
|
322
322
|
## Development and tests
|
|
323
323
|
|
|
324
|
-
See [TESTING.md](TESTING.md) for deterministic tests, extension loading, packaging, and real Herdr smoke requirements.
|
|
324
|
+
See [TESTING.md](TESTING.md) for deterministic tests, extension loading, packaging, and real Herdr smoke requirements. The real smoke preserves your configured `analyst` model, thinking level, and explicit extensions (including authentication providers) inside its isolated fixture; unrelated Persona settings remain isolated.
|
|
@@ -12,7 +12,9 @@ It verifies:
|
|
|
12
12
|
- bounded read-only parallelism;
|
|
13
13
|
- ordered results despite concurrent completion;
|
|
14
14
|
- sibling continuation after failure and all-failed aggregation;
|
|
15
|
-
- explicit
|
|
15
|
+
- required explicit Personas in the tool schema and execution, including legacy single-task argument normalization;
|
|
16
|
+
- rejection of missing, blank, invalid, and unknown Personas before creating runs, while custom Personas remain supported;
|
|
17
|
+
- explicit rejection of parallel write-capable tasks with the conflicting task, Persona, and corrective action;
|
|
16
18
|
- waiting through a delayed authoritative report after a prompt transport error while the Pi process is alive;
|
|
17
19
|
- explicit failure when the process exits without a report;
|
|
18
20
|
- parent cancellation of running and queued tasks;
|
|
@@ -20,7 +22,9 @@ It verifies:
|
|
|
20
22
|
- omitted exposure delegates both tools to Pi's default, while project configuration can consistently select model-only exposure;
|
|
21
23
|
- immutable terminal task records;
|
|
22
24
|
- idempotent duplicate reports and rejection of conflicting replacements;
|
|
23
|
-
- keeping a settled child alive for focus-aware cleanup instead of force-exiting its TUI
|
|
25
|
+
- keeping a settled child alive for focus-aware cleanup instead of force-exiting its TUI;
|
|
26
|
+
- preserving the configured smoke model, thinking level, and authentication extensions while isolating unrelated settings;
|
|
27
|
+
- project-over-global smoke configuration, explicit extension clearing, model overrides, and local-source paths after moving to a temporary directory.
|
|
24
28
|
|
|
25
29
|
The previous broad lifecycle suite remains available temporarily as `npm run test:legacy --workspace @maxiaochao/pi-herdr-subagents` for migration auditing. Scenarios for background queues, blocked/respond, and historical resume intentionally no longer describe the public contract.
|
|
26
30
|
|
|
@@ -36,4 +40,10 @@ Run the real Herdr smoke test from a Herdr session:
|
|
|
36
40
|
npm run test:real-herdr --workspace @maxiaochao/pi-herdr-subagents
|
|
37
41
|
```
|
|
38
42
|
|
|
43
|
+
The smoke reads the effective `analyst` model, thinking level, and explicit `extensions` from the invoking project's normal Herdr configuration (npm's `INIT_CWD`, or the current directory when run directly). It copies only those fields into its temporary configuration. It keeps the bundled read-only Persona, tool allowlist, and test concurrency; user skills, prompts, and unrelated settings are not copied. Local extension paths remain relative to the invoking project. The concurrent package-loading probe appends its test package rather than replacing the configured extensions.
|
|
44
|
+
|
|
45
|
+
This preserves authentication extensions such as `npm:@maxiaochao/pi-openai-account-pool` in both the single and concurrent cases. Children still use `--no-extensions`: only the mandatory child extensions, configured explicit sources, and test probe load. The smoke does not copy or edit credentials. If no Herdr model is configured, Pi's normal startup model selection still applies.
|
|
46
|
+
|
|
47
|
+
`HERDR_SUBAGENTS_E2E_MODEL=provider/model` overrides only the smoke model; configured extensions remain loaded. Set `HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP=1` to also verify deferred cleanup while focused and restoration of the original workspace/tab focus.
|
|
48
|
+
|
|
39
49
|
For real smoke testing, verify that each child appears in a visible pane and can be focused and inspected. An early-finishing child must remain visible while sibling tasks continue. After the whole run settles, a focused child must remain at its restored Pi UI until focus leaves the run tab; the tab must then close automatically. Verify that the parent returns only after all requested tasks settle. Also verify cancellation closes the run tab and does not overwrite a report already atomically published.
|
|
@@ -42,7 +42,7 @@ export function normalizeHerdrSubagentsArguments(args: unknown): HerdrSubagentsR
|
|
|
42
42
|
tasks: [{
|
|
43
43
|
name: label || "task",
|
|
44
44
|
prompt: legacy.prompt,
|
|
45
|
-
|
|
45
|
+
agent: legacy.agent as string,
|
|
46
46
|
...(typeof legacy.model === "string" ? { model: legacy.model } : {}),
|
|
47
47
|
}],
|
|
48
48
|
...(label ? { label } : {}),
|
|
@@ -148,7 +148,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
148
148
|
name: "herdr_subagents",
|
|
149
149
|
...(exposure ? { exposure } : {}),
|
|
150
150
|
label: "Herdr Subagents",
|
|
151
|
-
description: "Run
|
|
151
|
+
description: "Run ordered tasks in visible Herdr Pi panes, with an explicit agent for each task. concurrency 1 is serial; higher values require read-only Personas. Returns after all tasks complete, fail, or cancel; full results and sessions remain in project-local records.",
|
|
152
152
|
promptSnippet: "Run focused work in visible Herdr subagents",
|
|
153
153
|
parameters: HerdrSubagentsParams,
|
|
154
154
|
prepareArguments: normalizeHerdrSubagentsArguments,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-herdr-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Run inspectable interactive Pi subagent batches through Herdr.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"typebox": "*"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"test": "node --experimental-strip-types tests/core-state-machine.mjs",
|
|
40
|
+
"test": "node --experimental-strip-types tests/core-state-machine.mjs && node --experimental-strip-types tests/real-herdr-config.test.mjs",
|
|
41
41
|
"test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
|
|
42
42
|
"load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs",
|
|
43
43
|
"test:legacy": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs"
|
|
@@ -92,7 +92,8 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
92
92
|
const runId = options.runId ?? randomUUID();
|
|
93
93
|
const requestedConcurrency = Math.max(1, Math.floor(options.concurrency));
|
|
94
94
|
if (requestedConcurrency > 1 && tasks.some((task) => task.access === "write")) {
|
|
95
|
-
|
|
95
|
+
const writers = tasks.filter((task) => task.access === "write").map((task) => `${JSON.stringify(task.name)} (agent=${task.agent})`).join(", ");
|
|
96
|
+
throw new Error(`Write-capable tasks require concurrency 1: ${writers}. Use concurrency: 1, or select a read-only Persona for read-only work.`);
|
|
96
97
|
}
|
|
97
98
|
const effectiveConcurrency = Math.min(requestedConcurrency, options.maxConcurrency, tasks.length);
|
|
98
99
|
const runDirectory = join(options.cwd, ".pi", "herdr-subagents", "runs", `${timestamp().replace(/[-:.]/g, "").replace("Z", "Z-")}${runId.slice(0, 8)}`);
|
|
@@ -4,14 +4,14 @@ import { Type } from "typebox";
|
|
|
4
4
|
const HerdrSubagentsTaskParams = Type.Object({
|
|
5
5
|
name: Type.String({ minLength: 1, maxLength: 48, description: "Short task name shown in the pane and task list." }),
|
|
6
6
|
prompt: Type.String({ minLength: 1, description: "Self-contained prompt for this task." }),
|
|
7
|
-
agent: Type.
|
|
7
|
+
agent: Type.String({ minLength: 1, description: "Persona name, use control list for available Personas." }),
|
|
8
8
|
model: Type.Optional(Type.String({ minLength: 1, description: "Optional model override for this task." })),
|
|
9
9
|
}, { additionalProperties: false });
|
|
10
10
|
|
|
11
11
|
export const HerdrSubagentsParams = Type.Object({
|
|
12
12
|
tasks: Type.Array(HerdrSubagentsTaskParams, { minItems: 1, description: "Ordered tasks to execute. Use one item for a single task; independent read-only tasks may run concurrently." }),
|
|
13
13
|
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short Herdr run-tab label." })),
|
|
14
|
-
concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum
|
|
14
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum active Agents. Values above 1 require read-only Personas; write-capable Personas require 1 or the batch is rejected." })),
|
|
15
15
|
}, { additionalProperties: false });
|
|
16
16
|
|
|
17
17
|
export const HerdrSubagentsControlParams = Type.Object({
|
|
@@ -7,7 +7,7 @@ import type { OwnerIdentity } from "./ownership.ts";
|
|
|
7
7
|
import { cancelActiveSubagentRuns } from "./reconcile.ts";
|
|
8
8
|
import { getSubagentRunStatus, runStatusText } from "./status.ts";
|
|
9
9
|
|
|
10
|
-
export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent
|
|
10
|
+
export interface HerdrSubagentsTaskParams { name: string; prompt: string; agent: string; model?: string; }
|
|
11
11
|
|
|
12
12
|
export interface HerdrSubagentsRunParams {
|
|
13
13
|
tasks: HerdrSubagentsTaskParams[];
|
|
@@ -140,7 +140,7 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
140
140
|
const tasks = params.tasks as HerdrSubagentsTaskParams[];
|
|
141
141
|
const resolved: BatchTask[] = [];
|
|
142
142
|
for (const task of tasks) {
|
|
143
|
-
const personaName = task.agent
|
|
143
|
+
const personaName = task.agent.trim();
|
|
144
144
|
const persona = discovery.personas.find((candidate) => candidate.name === personaName);
|
|
145
145
|
if (!persona) return errorResponse(`Unknown persona '${personaName}'.\n\n${listText(discovery)}`);
|
|
146
146
|
resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, extensions: persona.extensions, systemPrompt: persona.systemPrompt });
|
|
@@ -10,6 +10,9 @@ export const HERDR_ACTIONS = {
|
|
|
10
10
|
|
|
11
11
|
export function validateRunParams(params: HerdrSubagentsRunParams): string | undefined {
|
|
12
12
|
if (!params.tasks?.length) return "Provide a non-empty tasks list.";
|
|
13
|
+
for (const [index, task] of params.tasks.entries()) {
|
|
14
|
+
if (typeof task.agent !== "string" || !task.agent.trim()) return `tasks[${index}].agent is required; select a Persona explicitly (use herdr_subagents_control action=list).`;
|
|
15
|
+
}
|
|
13
16
|
return undefined;
|
|
14
17
|
}
|
|
15
18
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { runHerdrSubagentsBatch } from "../src/batch-runner.ts";
|
|
6
7
|
import registerSubagentReport from "../extensions/subagent-report.ts";
|
|
7
|
-
import { registerHerdrSubagents } from "../extensions/herdr-subagents.ts";
|
|
8
|
+
import { normalizeHerdrSubagentsArguments, registerHerdrSubagents } from "../extensions/herdr-subagents.ts";
|
|
8
9
|
import { applyTaskStatus } from "../src/records.ts";
|
|
10
|
+
import { executeHerdrSubagents } from "../src/tool.ts";
|
|
9
11
|
|
|
10
12
|
process.env.HERDR_ENV = "1";
|
|
11
13
|
process.env.HERDR_WORKSPACE_ID = "test-workspace";
|
|
@@ -92,6 +94,45 @@ async function run(tasks, concurrency, herdr = fakeHerdr(), signal) {
|
|
|
92
94
|
return { result, herdr };
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
{
|
|
98
|
+
const herdr = fakeHerdr();
|
|
99
|
+
const execute = (params) => executeHerdrSubagents(params, undefined, undefined, { cwd: root }, {
|
|
100
|
+
agentsDirectory: fileURLToPath(new URL("../agents/", import.meta.url)),
|
|
101
|
+
configurationPaths: { globalConfig: join(root, "global-config.json") },
|
|
102
|
+
herdr,
|
|
103
|
+
});
|
|
104
|
+
const missing = { name: "review", prompt: "Read-only review" };
|
|
105
|
+
for (const params of [
|
|
106
|
+
{ tasks: [missing] },
|
|
107
|
+
...["", " \t", null, 42].map((agent) => ({ tasks: [{ ...missing, agent }] })),
|
|
108
|
+
normalizeHerdrSubagentsArguments({ prompt: "Read-only review" }),
|
|
109
|
+
normalizeHerdrSubagentsArguments({ prompt: "Read-only review", agent: 42 }),
|
|
110
|
+
]) {
|
|
111
|
+
const response = await execute(params);
|
|
112
|
+
assert.equal(response.isError, true, "Missing or invalid agent must not silently select worker");
|
|
113
|
+
assert.match(response.content[0].text, /tasks\[0\]\.agent.*required/);
|
|
114
|
+
}
|
|
115
|
+
const laterMissing = await execute({ tasks: [{ ...missing, agent: "analyst" }, missing], concurrency: 2 });
|
|
116
|
+
assert.match(laterMissing.content[0].text, /tasks\[1\]\.agent.*required/);
|
|
117
|
+
const unknown = await execute({ tasks: [{ ...missing, agent: "unknown-persona" }] });
|
|
118
|
+
assert.equal(unknown.isError, true);
|
|
119
|
+
assert.match(unknown.content[0].text, /Unknown persona 'unknown-persona'/);
|
|
120
|
+
assert.deepEqual(herdr.starts, [], "Invalid requests must not start an Agent");
|
|
121
|
+
assert.equal(existsSync(join(root, ".pi", "herdr-subagents", "runs")), false, "Invalid requests must not create run records");
|
|
122
|
+
|
|
123
|
+
const serial = await execute(normalizeHerdrSubagentsArguments({ prompt: "make change", agent: "worker" }));
|
|
124
|
+
assert.equal(serial.details.status, "completed", "Explicit worker must still run serially through legacy argument normalization");
|
|
125
|
+
const parallel = await execute({ tasks: ["analyst", "reviewer", "explorer"].map((agent) => ({ ...missing, agent })), concurrency: 2 });
|
|
126
|
+
assert.equal(parallel.details.status, "completed");
|
|
127
|
+
assert.equal(parallel.details.effectiveConcurrency, 2);
|
|
128
|
+
|
|
129
|
+
const projectAgents = join(root, ".pi", "herdr-subagents", "agents");
|
|
130
|
+
mkdirSync(projectAgents, { recursive: true });
|
|
131
|
+
writeFileSync(join(projectAgents, "auditor.md"), "---\nname: auditor\ndescription: Custom read-only reviewer\naccess: read\n---\nReview the task.\n");
|
|
132
|
+
const custom = await execute({ tasks: [{ ...missing, agent: " auditor " }], concurrency: 2 });
|
|
133
|
+
assert.equal(custom.details.status, "completed", "Custom Persona names must remain valid and trim whitespace");
|
|
134
|
+
}
|
|
135
|
+
|
|
95
136
|
{
|
|
96
137
|
const { result, herdr } = await run([task("one", "first slow"), task("two", "second")], 1);
|
|
97
138
|
assert.equal(result.status, "completed");
|
|
@@ -146,7 +187,12 @@ async function run(tasks, concurrency, herdr = fakeHerdr(), signal) {
|
|
|
146
187
|
assert.deepEqual(result.activity.map((entry) => entry.status), ["cancelled", "cancelled"]);
|
|
147
188
|
}
|
|
148
189
|
|
|
149
|
-
await assert.rejects(() => run([task("writer", "edit", "write"), task("reader", "read")], 2),
|
|
190
|
+
await assert.rejects(() => run([task("writer", "edit", "write"), task("reader", "read")], 2), (error) => {
|
|
191
|
+
assert.match(error.message, /writer.*worker/);
|
|
192
|
+
assert.match(error.message, /concurrency: 1/);
|
|
193
|
+
assert.match(error.message, /read-only Persona/);
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
150
196
|
|
|
151
197
|
const terminal = { id: "task", runId: "run", order: 1, status: "completed" };
|
|
152
198
|
assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are immutable/);
|
|
@@ -159,6 +205,7 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
|
|
|
159
205
|
const historicalRun = { id: "historical-run", label: "historical", status: "completed", ownerSessionId: "old-session", ownerProcessId: 99999999, tabId: "w1:t-reused", taskIds: ["historical-task"], completedAt: "2026-10-01T00:00:00.000Z" };
|
|
160
206
|
writeFileSync(join(runDirectory, "run.json"), JSON.stringify(historicalRun));
|
|
161
207
|
writeFileSync(join(taskDirectory, "task.json"), JSON.stringify({ id: "historical-task", runId: "historical-run", order: 1, status: "completed", completedAt: historicalRun.completedAt }));
|
|
208
|
+
const configurationPaths = { globalConfig: join(lifecycleRoot, "global-config.json") };
|
|
162
209
|
const events = new Map();
|
|
163
210
|
const registeredTools = new Map();
|
|
164
211
|
const herdrCalls = [];
|
|
@@ -172,6 +219,8 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
|
|
|
172
219
|
async closeTab(tabId) { herdrCalls.push(["closeTab", tabId]); },
|
|
173
220
|
},
|
|
174
221
|
isEditor: () => false,
|
|
222
|
+
configurationCwd: lifecycleRoot,
|
|
223
|
+
configurationPaths,
|
|
175
224
|
});
|
|
176
225
|
await events.get("session_start")?.({}, {
|
|
177
226
|
cwd: lifecycleRoot,
|
|
@@ -181,6 +230,9 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
|
|
|
181
230
|
});
|
|
182
231
|
assert.deepEqual(herdrCalls, [], "Pi startup must not inspect or close historical Subagent tabs");
|
|
183
232
|
assert.deepEqual(JSON.parse(readFileSync(join(runDirectory, "run.json"), "utf8")), historicalRun, "Pi startup must not mutate historical Subagent records");
|
|
233
|
+
const taskSchema = registeredTools.get("herdr_subagents").parameters.properties.tasks.items;
|
|
234
|
+
assert.ok(taskSchema.required.includes("agent"), "The model-facing schema must require agent");
|
|
235
|
+
assert.equal(taskSchema.properties.agent.enum, undefined, "Custom Persona names must remain valid");
|
|
184
236
|
assert.equal(registeredTools.get("herdr_subagents")?.exposure, undefined, "Unconfigured Subagent execution must inherit Pi's exposure default");
|
|
185
237
|
assert.equal(registeredTools.get("herdr_subagents_control")?.exposure, undefined, "Unconfigured Subagent control must inherit Pi's exposure default");
|
|
186
238
|
mkdirSync(join(lifecycleRoot, ".pi"), { recursive: true });
|
|
@@ -190,7 +242,7 @@ assert.throws(() => applyTaskStatus(terminal, "running"), /terminal tasks are im
|
|
|
190
242
|
on() {},
|
|
191
243
|
registerTool(tool) { configuredTools.set(tool.name, tool); },
|
|
192
244
|
registerCommand() {},
|
|
193
|
-
}, { herdr: {}, isEditor: () => false, configurationCwd: lifecycleRoot });
|
|
245
|
+
}, { herdr: {}, isEditor: () => false, configurationCwd: lifecycleRoot, configurationPaths });
|
|
194
246
|
assert.equal(configuredTools.get("herdr_subagents")?.exposure, "model-only", "Project configuration can exclude Subagent execution from Codemode");
|
|
195
247
|
assert.equal(configuredTools.get("herdr_subagents_control")?.exposure, "model-only", "Project configuration applies consistently to both Subagent tools");
|
|
196
248
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { loadSubagentConfiguration } from "../src/config.ts";
|
|
3
|
+
|
|
4
|
+
export function smokePersona(cwd, agentsDirectory, model, paths) {
|
|
5
|
+
const discovery = loadSubagentConfiguration(cwd, agentsDirectory, undefined, paths);
|
|
6
|
+
const analyst = discovery.personas.find(({ name }) => name === "analyst");
|
|
7
|
+
if (!analyst) throw new Error("Real Herdr smoke requires an analyst Persona.");
|
|
8
|
+
return {
|
|
9
|
+
model: model?.trim() || analyst.model,
|
|
10
|
+
thinking: analyst.thinking,
|
|
11
|
+
// Local sources must still resolve from the caller's project after moving to the fixture cwd.
|
|
12
|
+
extensions: analyst.extensions.map((source) => /^(?:[a-z][a-z\d+.-]*:|~(?:\/|$)|git@)/i.test(source) ? source : resolve(cwd, source)),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadSubagentConfiguration } from "../src/config.ts";
|
|
7
|
+
import { childPiArgs } from "../src/live-agent.ts";
|
|
8
|
+
import { smokePersona } from "./real-herdr-config.mjs";
|
|
9
|
+
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "herdr-smoke-config-"));
|
|
11
|
+
const agents = fileURLToPath(new URL("../agents", import.meta.url));
|
|
12
|
+
const paths = {
|
|
13
|
+
globalConfig: join(root, "global.json"),
|
|
14
|
+
projectConfig: join(root, "project.json"),
|
|
15
|
+
globalAgents: join(root, "global-agents"),
|
|
16
|
+
projectAgents: join(root, "project-agents"),
|
|
17
|
+
};
|
|
18
|
+
const write = (path, value) => writeFileSync(path, JSON.stringify(value));
|
|
19
|
+
try {
|
|
20
|
+
write(paths.globalConfig, {
|
|
21
|
+
maxConcurrency: 1,
|
|
22
|
+
defaultModel: "openai-codex/test-model",
|
|
23
|
+
defaultThinking: "high",
|
|
24
|
+
personas: { analyst: {
|
|
25
|
+
extensions: ["npm:@maxiaochao/pi-openai-account-pool"],
|
|
26
|
+
skills: ["unrelated-skill"],
|
|
27
|
+
} },
|
|
28
|
+
});
|
|
29
|
+
const before = readFileSync(paths.globalConfig, "utf8");
|
|
30
|
+
const persona = smokePersona(root, agents, undefined, paths);
|
|
31
|
+
assert.deepEqual(persona, {
|
|
32
|
+
model: "openai-codex/test-model",
|
|
33
|
+
thinking: "high",
|
|
34
|
+
extensions: ["npm:@maxiaochao/pi-openai-account-pool"],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const fixture = join(root, "fixture.json");
|
|
38
|
+
const probe = join(root, "local-package");
|
|
39
|
+
write(fixture, { personas: { analyst: { ...persona, extensions: [...persona.extensions, probe] } } });
|
|
40
|
+
const isolated = loadSubagentConfiguration(root, agents, undefined, { ...paths, globalConfig: fixture });
|
|
41
|
+
const analyst = isolated.personas.find(({ name }) => name === "analyst");
|
|
42
|
+
assert.equal(isolated.settings.maxConcurrency, 4);
|
|
43
|
+
assert.equal(analyst.access, "read");
|
|
44
|
+
assert.deepEqual(analyst.skills, []);
|
|
45
|
+
assert.deepEqual(analyst.extensions, ["npm:@maxiaochao/pi-openai-account-pool", probe]);
|
|
46
|
+
const args = childPiArgs({ ...analyst, sessionFile: join(root, "session.jsonl") }, join(root, "system.md"));
|
|
47
|
+
assert.equal(args[args.indexOf("--model") + 1], "openai-codex/test-model");
|
|
48
|
+
assert.ok(args.includes("--no-extensions"));
|
|
49
|
+
assert.ok(args.includes("npm:@maxiaochao/pi-openai-account-pool"));
|
|
50
|
+
assert.ok(args.includes(probe));
|
|
51
|
+
assert.equal(readFileSync(paths.globalConfig, "utf8"), before);
|
|
52
|
+
console.log("PASS smoke preserves configured model and authentication extensions without copying unrelated settings");
|
|
53
|
+
|
|
54
|
+
write(paths.projectConfig, { personas: { analyst: {
|
|
55
|
+
model: "custom/project-model",
|
|
56
|
+
thinking: "low",
|
|
57
|
+
extensions: ["./provider with spaces.ts", "../provider-package", "~/provider.ts", "npm:provider@1.0.0", "git:github.com/example/provider@v1", "builtin:codemode", "https://example.test/provider.git"],
|
|
58
|
+
} } });
|
|
59
|
+
assert.deepEqual(smokePersona(root, agents, undefined, paths), {
|
|
60
|
+
model: "custom/project-model",
|
|
61
|
+
thinking: "low",
|
|
62
|
+
extensions: [join(root, "provider with spaces.ts"), join(root, "..", "provider-package"), "~/provider.ts", "npm:provider@1.0.0", "git:github.com/example/provider@v1", "builtin:codemode", "https://example.test/provider.git"],
|
|
63
|
+
});
|
|
64
|
+
console.log("PASS smoke honors project precedence and keeps local and native package sources valid in a temporary cwd");
|
|
65
|
+
|
|
66
|
+
write(paths.projectConfig, { personas: { analyst: { extensions: [] } } });
|
|
67
|
+
assert.deepEqual(smokePersona(root, agents, " custom/override-model ", paths), {
|
|
68
|
+
model: "custom/override-model",
|
|
69
|
+
thinking: "high",
|
|
70
|
+
extensions: [],
|
|
71
|
+
});
|
|
72
|
+
mkdirSync(paths.globalAgents, { recursive: true });
|
|
73
|
+
writeFileSync(join(paths.globalAgents, "analyst.md"), "---\nname: analyst\ndescription: Custom user analyst\naccess: write\ntools: write\n---\nUnrelated user prompt.\n");
|
|
74
|
+
const selected = smokePersona(root, agents, undefined, paths);
|
|
75
|
+
assert.deepEqual(Object.keys(selected).sort(), ["extensions", "model", "thinking"]);
|
|
76
|
+
assert.equal(selected.model, "openai-codex/test-model");
|
|
77
|
+
console.log("PASS smoke supports model overrides and explicit extension clearing without copying Persona tools or prompts");
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(root, { recursive: true, force: true });
|
|
80
|
+
}
|
|
@@ -7,12 +7,14 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { executeHerdrSubagents } from "../src/tool.ts";
|
|
8
8
|
import { CliHerdrAutomation } from "../src/herdr.ts";
|
|
9
9
|
import { waitFor } from "./wait-for.mjs";
|
|
10
|
+
import { smokePersona } from "./real-herdr-config.mjs";
|
|
10
11
|
|
|
11
12
|
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) {
|
|
12
13
|
throw new Error("The real Herdr smoke test must run from a Pi session inside Herdr.");
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const packageDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const runtimePersona = smokePersona(process.env.INIT_CWD || process.cwd(), join(packageDirectory, "agents"), process.env.HERDR_SUBAGENTS_E2E_MODEL);
|
|
16
18
|
const root = mkdtempSync(join(tmpdir(), "pi-herdr-subagents-real-"));
|
|
17
19
|
const herdr = new CliHerdrAutomation();
|
|
18
20
|
const tabIds = new Set();
|
|
@@ -47,7 +49,6 @@ for (const method of ["createTab", "splitPane", "startAgent", "isTabFocused", "w
|
|
|
47
49
|
}
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
|
-
const model = process.env.HERDR_SUBAGENTS_E2E_MODEL?.trim();
|
|
51
52
|
const configurationPaths = {
|
|
52
53
|
globalConfig: join(root, "global-config.json"),
|
|
53
54
|
projectConfig: join(root, "project-config.json"),
|
|
@@ -94,7 +95,9 @@ async function waitForCleanup(result) {
|
|
|
94
95
|
}, `cleanup of ${result.tabId}`, 5_000);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
async function runCase(label, names, concurrency,
|
|
98
|
+
async function runCase(label, names, concurrency, extraExtensions = []) {
|
|
99
|
+
const extensions = [...runtimePersona.extensions, ...extraExtensions];
|
|
100
|
+
writeFileSync(configurationPaths.globalConfig, JSON.stringify({ personas: { analyst: { ...runtimePersona, extensions } } }));
|
|
98
101
|
if (concurrency > 1 && process.env.HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP === "1") {
|
|
99
102
|
const workspaces = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "list"], { encoding: "utf8", timeout: 10_000 });
|
|
100
103
|
assert.equal(workspaces.status, 0, workspaces.stderr);
|
|
@@ -108,7 +111,6 @@ async function runCase(label, names, concurrency, extensions = []) {
|
|
|
108
111
|
tasks: names.map((name) => ({
|
|
109
112
|
name,
|
|
110
113
|
agent: "analyst",
|
|
111
|
-
...(model ? { model } : {}),
|
|
112
114
|
prompt: `Do not inspect files. Call subagent_report with status completed, summary '${name} completed', and result 'hello'.`,
|
|
113
115
|
})),
|
|
114
116
|
}, timeout, (update) => {
|
|
@@ -145,7 +147,8 @@ async function runCase(label, names, concurrency, extensions = []) {
|
|
|
145
147
|
const report = JSON.parse(readFileSync(join(taskResult.recordDirectory, "report.json"), "utf8"));
|
|
146
148
|
assert.equal(task.status, "completed");
|
|
147
149
|
assert.deepEqual(task.extensions, extensions);
|
|
148
|
-
if (
|
|
150
|
+
if (runtimePersona.model) assert.equal(task.model, runtimePersona.model);
|
|
151
|
+
if (extraExtensions.length) {
|
|
149
152
|
const commands = JSON.parse(readFileSync(join(taskResult.recordDirectory, "extension-loaded.json"), "utf8"));
|
|
150
153
|
assert.ok(commands.includes("skill:smoke-package-skill"), "Explicit package skill did not load in the real child");
|
|
151
154
|
}
|
|
@@ -168,7 +171,6 @@ try {
|
|
|
168
171
|
export default function (pi) {
|
|
169
172
|
pi.on("session_start", () => writeFileSync(join(process.env.PI_HERDR_SUBAGENTS_TASK_DIR, "extension-loaded.json"), JSON.stringify(pi.getCommands().map((command) => command.name))));
|
|
170
173
|
}`);
|
|
171
|
-
writeFileSync(configurationPaths.globalConfig, JSON.stringify({ personas: { analyst: { extensions: [packageSource] } } }));
|
|
172
174
|
await runCase("real-concurrent", ["hello-one", "hello-two"], 2, [packageSource]);
|
|
173
175
|
} catch (error) {
|
|
174
176
|
failed = true;
|