@gotgenes/pi-subagents 16.3.1 → 16.4.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 CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [16.4.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.3.1...pi-subagents-v16.4.0) (2026-06-16)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-subagents:** add loadLayeredSettings layered config loader ([2eeba78](https://github.com/gotgenes/pi-packages/commit/2eeba78230bd4537fa568641d4a77a6f1824271c))
14
+ * **pi-subagents:** export loadLayeredSettings via ./settings subpath ([cefe7f6](https://github.com/gotgenes/pi-packages/commit/cefe7f6b7a9133cd0bbcc523ac8b34e48fce0a58))
15
+
8
16
  ## [16.3.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.3.0...pi-subagents-v16.3.1) (2026-06-16)
9
17
 
10
18
 
package/README.md CHANGED
@@ -360,6 +360,53 @@ When [`@gotgenes/pi-permission-system`](https://github.com/gotgenes/pi-permissio
360
360
  No configuration is required.
361
361
  When `@gotgenes/pi-permission-system` is not installed, the lifecycle events have no subscriber — a harmless no-op.
362
362
 
363
+ ## For Extension Authors
364
+
365
+ This package exposes two public subpath exports for companion extensions to import from the published tarball.
366
+
367
+ ### `@gotgenes/pi-subagents` — cross-extension service contract
368
+
369
+ Access the subagent service from another extension at runtime:
370
+
371
+ ```typescript
372
+ const { getSubagentsService } = await import("@gotgenes/pi-subagents");
373
+ const svc = getSubagentsService();
374
+ svc?.spawn("Explore", "Check for stale TODOs");
375
+ ```
376
+
377
+ Declare this package as an optional peer dependency.
378
+ See `src/service/service.ts` for the full `SubagentsService` interface and the `WorkspaceProvider` seam.
379
+
380
+ ### `@gotgenes/pi-subagents/settings` — layered config loader
381
+
382
+ Extensions that store configuration in JSON files can use the shared layered loader, which reads a global file (`<agentDir>/<filename>`) and a project file (`<cwd>/.pi/<filename>`) and merges them — project wins on conflicts, missing files are silent, malformed files warn and fall back:
383
+
384
+ ```typescript
385
+ import { loadLayeredSettings, type LayeredSettingsSource } from "@gotgenes/pi-subagents/settings";
386
+
387
+ interface MyConfig { enabled?: boolean; limit?: number }
388
+
389
+ function sanitize(raw: unknown): Partial<MyConfig> {
390
+ if (!raw || typeof raw !== "object") return {};
391
+ const r = raw as Record<string, unknown>;
392
+ const out: Partial<MyConfig> = {};
393
+ if (typeof r.enabled === "boolean") out.enabled = r.enabled;
394
+ if (typeof r.limit === "number") out.limit = r.limit;
395
+ return out;
396
+ }
397
+
398
+ const config = loadLayeredSettings<MyConfig>({
399
+ agentDir, // Pi runtime agent home directory
400
+ cwd, // project root — project file lives at <cwd>/.pi/<filename>
401
+ filename: "my-extension.json",
402
+ sanitize,
403
+ warnLabel: "my-extension", // prefix for the malformed-file stderr warning
404
+ });
405
+ ```
406
+
407
+ `loadLayeredSettings` returns `Partial<T>` (all fields optional); apply your defaults after the call.
408
+ It never throws — all error conditions produce a `console.warn` and return `{}`.
409
+
363
410
  ## Architecture
364
411
 
365
412
  This extension is a minimal, composable core: it owns agent spawning, execution, and result retrieval, and exposes a typed `SubagentsService` plus lifecycle events that other extensions build on.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Generic layered settings loader for `@gotgenes/pi-*` extensions.
3
+ *
4
+ * Extensions that store configuration in JSON files under a global agent
5
+ * directory and a per-project `.pi/` folder share the same three-step idiom:
6
+ *
7
+ * 1. Read the global file (`<agentDir>/<filename>`).
8
+ * 2. Read the project file (`<cwd>/.pi/<filename>`).
9
+ * 3. Merge them — project wins on conflicts — and return the result.
10
+ *
11
+ * Both layers are optional: a missing file is silent (`{}`), and a file that
12
+ * cannot be parsed warns to stderr and is treated as absent so startup
13
+ * proceeds normally.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```typescript
18
+ * import { loadLayeredSettings, type LayeredSettingsSource } from "@gotgenes/pi-subagents/settings";
19
+ *
20
+ * interface MyConfig { enabled?: boolean; limit?: number }
21
+ *
22
+ * function sanitize(raw: unknown): Partial<MyConfig> {
23
+ * if (!raw || typeof raw !== "object") return {};
24
+ * const r = raw as Record<string, unknown>;
25
+ * const out: Partial<MyConfig> = {};
26
+ * if (typeof r.enabled === "boolean") out.enabled = r.enabled;
27
+ * if (typeof r.limit === "number") out.limit = r.limit;
28
+ * return out;
29
+ * }
30
+ *
31
+ * const config = loadLayeredSettings<MyConfig>({
32
+ * agentDir, // e.g. from the Pi runtime env — the agent home directory
33
+ * cwd, // project root — project file is at <cwd>/.pi/<filename>
34
+ * filename: "my-extension.json",
35
+ * sanitize,
36
+ * warnLabel: "my-extension",
37
+ * });
38
+ * ```
39
+ *
40
+ * @public
41
+ */
42
+ /**
43
+ * Parameters for one layered settings load: describes where the files live,
44
+ * how to validate their contents, and what label to use in warnings.
45
+ *
46
+ * @public
47
+ */
48
+ interface LayeredSettingsSource<T> {
49
+ /** Directory holding the global settings file (typically the Pi agent dir). */
50
+ agentDir: string;
51
+ /** Project root; the project file lives at `<cwd>/.pi/<filename>`. */
52
+ cwd: string;
53
+ /** Base filename for both layers, e.g. `"subagents.json"`. */
54
+ filename: string;
55
+ /**
56
+ * Validate and coerce parsed JSON into a partial settings object.
57
+ * Unknown or invalid fields should be silently dropped — return `{}` for
58
+ * unrecognised shapes. Never throw.
59
+ */
60
+ sanitize: (raw: unknown) => Partial<T>;
61
+ /**
62
+ * Short label used in the malformed-file warning prefix,
63
+ * e.g. `"pi-subagents"` → `"[pi-subagents] Ignoring malformed settings at …"`.
64
+ */
65
+ warnLabel: string;
66
+ }
67
+ /**
68
+ * Load merged layered settings: global provides defaults, project overrides.
69
+ *
70
+ * - A missing file is silent — returns `{}` for that layer.
71
+ * - A file that exists but cannot be parsed warns to stderr and returns `{}` for
72
+ * that layer, so startup proceeds normally.
73
+ * - The two layers are merged with a shallow spread; project keys win.
74
+ *
75
+ * Throws nothing. All error conditions produce a warning and fall back to `{}`.
76
+ *
77
+ * @public
78
+ */
79
+ declare function loadLayeredSettings<T>(source: LayeredSettingsSource<T>): Partial<T>;
80
+
81
+ export { loadLayeredSettings };
82
+ export type { LayeredSettingsSource };
@@ -643,17 +643,17 @@ That method — testability friction as a boundary probe, with its limits — is
643
643
 
644
644
  ### Health metrics
645
645
 
646
- | Metric | Value |
647
- | -------------------------- | --------------------------------------- |
648
- | Health score | 78/100 (B) |
649
- | Total LOC | 8,356 (61 files, as of Phase 17 Step 5) |
650
- | Dead code | 0 files, 0 exports |
651
- | Maintainability index | 90.8 (good) |
652
- | Avg cyclomatic complexity | 1.4 |
653
- | P90 cyclomatic complexity | 2 |
654
- | Production duplication | 11 lines (1 internal clone group) |
655
- | Test duplication | 42 clone groups, 661 lines |
656
- | Fallow refactoring targets | 0 |
646
+ | Metric | Value |
647
+ | -------------------------- | -------------------------------------------- |
648
+ | Health score | 78/100 (B) |
649
+ | Total LOC | 8,356 (61 files, as of Phase 17 Step 5) |
650
+ | Dead code | 0 files, 0 exports |
651
+ | Maintainability index | 90.8 (good) |
652
+ | Avg cyclomatic complexity | 1.4 |
653
+ | P90 cyclomatic complexity | 2 |
654
+ | Production duplication | 11 lines (1 internal clone group) |
655
+ | Test duplication | 24 clone groups, 355 lines (Phase 17 Step 8) |
656
+ | Fallow refactoring targets | 0 |
657
657
 
658
658
  ### Dependency bag inventory
659
659
 
@@ -890,17 +890,17 @@ The fuller four-domain split — metrics as a projection, result delivery as its
890
890
 
891
891
  Updated health metrics (fallow, package-wide including tests):
892
892
 
893
- | Metric | Phase 16 baseline | Current |
894
- | -------------------------- | ------------------------------ | --------------------------------------------- |
895
- | Health score | 78/100 (B) | 78/100 (B) |
896
- | Source LOC | 7,778 (57 files) | 8,356 (61 files, landed Phase 17 Step 5) |
897
- | Dead code | 0 files, 0 exports | 0 files, 0 exports |
898
- | Maintainability index | 90.8 (good) | 90.8 (good) |
899
- | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
900
- | Production duplication | 11 lines (1 internal group) | 34 lines (1 internal + 1 cross-package group) |
901
- | Test duplication | 42 groups, 661 lines | 44 groups, ~750 lines |
902
- | Fallow refactoring targets | 0 | 0 |
903
- | Top churn hotspot | `index.ts` 65.0 ▲ accelerating | `index.ts` 31.3 ▼ cooling |
893
+ | Metric | Phase 16 baseline | Current |
894
+ | -------------------------- | ------------------------------ | ------------------------------------------------------------------ |
895
+ | Health score | 78/100 (B) | 78/100 (B) |
896
+ | Source LOC | 7,778 (57 files) | 8,356 (61 files, landed Phase 17 Step 5) |
897
+ | Dead code | 0 files, 0 exports | 0 files, 0 exports |
898
+ | Maintainability index | 90.8 (good) | 90.8 (good) |
899
+ | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
900
+ | Production duplication | 11 lines (1 internal group) | 11 lines (1 internal group; cross-package pair resolved in Step 9) |
901
+ | Test duplication | 42 groups, 661 lines | 44 groups, ~750 lines |
902
+ | Fallow refactoring targets | 0 | 0 |
903
+ | Top churn hotspot | `index.ts` 65.0 ▲ accelerating | `index.ts` 31.3 ▼ cooling |
904
904
 
905
905
  The syntactic metrics are healthy and stable — the remaining debt is structural, mostly invisible to fallow, and concentrated in three places:
906
906
 
@@ -1002,27 +1002,38 @@ Priority = Impact × (6 − Risk).
1002
1002
  Behavior-preserving: the widget timer runs through every background completion, so self-seeding lands ≤80ms later within the same turn (linger is turn-based).
1003
1003
  Test count: 1009 → 1005 (+3 widget self-seed tests, −7 removed relay/field tests).
1004
1004
 
1005
- #### Step 7 — Consolidate lifecycle test fixtures ([#378])
1005
+ #### Step 7 — Consolidate lifecycle test fixtures ([#378]) ✅ Complete
1006
1006
 
1007
- - Targets: `test/lifecycle/subagent-manager.test.ts` (766 LOC), `test/lifecycle/subagent.test.ts`, `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/create-subagent-session.test.ts`, `test/lifecycle/create-subagent-session-extension-tools.test.ts`, `test/lifecycle/concurrency-limiter.test.ts`, `test/helpers/`.
1008
- - Smell: Category D — fallow reports five clone families across the lifecycle tests.
1007
+ - Targets: `test/lifecycle/subagent-manager.test.ts`, `test/lifecycle/subagent-session.test.ts`, `test/lifecycle/create-subagent-session.test.ts`, `test/lifecycle/create-subagent-session-extension-tools.test.ts` (deleted), `test/helpers/subagent-session-io.ts`.
1008
+ - Smell: Category D — at planning time fallow reported four clone families across the lifecycle tests (the issue's "five" predated Steps 1–6, which renamed the queue and dissolved the `subagent.test.ts`/`concurrency-limiter.test.ts` families).
1009
1009
  - Change: extract the repeated spawn/run/factory arrangements into shared helpers, migrating incrementally (lift-and-shift, never a single-step rewrite of a large test file).
1010
- - Outcome: lifecycle clone families 5 1; package test duplication below 600 lines.
1010
+ - Outcome: package test duplication below 600 lines; lifecycle clone families reduced, with the residual deliberately left as the visible test subject (see Landed).
1011
+ - Landed: added a shared `createFactorySession` mock-session builder (`test/helpers/subagent-session-io.ts`); grouped the `createSubagentSession` arrange into describe-scoped `beforeEach` hooks with the `createSubagentSession(...)` act kept explicit per test (AAA); extracted a `programTurns` arrange helper for the turn-limit tests; consolidated the manager spawn/queued-pair arrangements; and folded `create-subagent-session-extension-tools.test.ts` (4 post-bind guard tests) into `create-subagent-session.test.ts`, deleting the file and its duplicated scaffolding.
1012
+ Package test duplication: 669 → 512 lines; test files 64 → 63; test count 1005 → 1010 (+5 `createFactorySession` self-tests).
1013
+ Two lifecycle clone families remain (`create-subagent-session.test.ts` ~29 lines, `subagent-manager.test.ts` ~23 lines): both are the repeated `await createSubagentSession(...)` / `spawn(...)` **act** with test-specific arrange, intentionally not extracted because hiding the system-under-test behind a helper is the wrong abstraction for test code (Sandi Metz: "duplication is far cheaper than the wrong abstraction").
1014
+ Lesson: the original "families ≤ 1" target was a weak signal for _test_ code — an early act-wrapping helper that hit the metric was reverted in favour of the AAA structure above; the metric was relaxed deliberately.
1015
+ The three overlapping session-mock builders this surfaced are tracked separately ([#412]).
1011
1016
 
1012
- #### Step 8 — Consolidate UI and tools test fixtures ([#379])
1017
+ #### Step 8 — Consolidate UI and tools test fixtures ([#379]) ✅ Complete
1013
1018
 
1014
- - Targets: `test/ui/agent-creation-wizard.test.ts`, `test/ui/agent-config-editor.test.ts`, `test/ui/ui-observer.test.ts`, `test/tools/foreground-runner.test.ts`, `test/tools/background-spawner.test.ts`, `test/session/session-config.test.ts`.
1019
+ - Targets: `test/ui/agent-creation-wizard.test.ts`, `test/ui/agent-config-editor.test.ts`, `test/ui/ui-observer.test.ts`, `test/tools/foreground-runner.test.ts`, `test/tools/background-spawner.test.ts`, `test/session/session-config.test.ts`, and `test/service/service-adapter.test.ts` (a seventh non-lifecycle family fallow reports; added to scope in planning).
1015
1020
  - Smell: Category D — remaining clone families outside the lifecycle tree.
1016
1021
  - Change: extract per-file repeated arrangements into local helpers or `test/helpers/` where shared across files.
1017
- - Outcome: package clone groups 44 → ≤ 25; overall duplication ≤ 0.6%.
1022
+ - Outcome: each named family's extractable arrange consolidated; resulting fallow figures reported (the roadmap's original "44 → ≤ 25 groups; ≤ 0.6%" target predated Steps 1–7 and used a different baseline, so acceptance was "consolidate and measure," not a binding number).
1023
+ - Landed: promoted the cross-file `ResolvedSpawnConfig` builder to `test/helpers/make-spawn-config.ts` (`createResolvedSpawnConfig`, flat options that derive the mirrored `agentInvocation`/`presentation.detailBase` regions) with a 5-test companion; consolidated the remaining six families with file-local, value-returning arrange builders and `beforeEach` setup (`spawnAndWaitRegistering`, `ui-observer` session/tracker `beforeEach` with `vi.fn()` onUpdate counters, `generateUI`/`manualUI`/`withInputs`, `disabledConfig`, `exploreConfig`, `createManagerStub`).
1024
+ Package test duplication: 512 → 355 lines; clone groups 32 → 24; duplication 2.49% → 1.73%; test files 63 → 64; test count 1010 → 1015 (+5 `createResolvedSpawnConfig` self-tests).
1025
+ Three multi-group families remain: the two lifecycle residuals from Step 7 (`create-subagent-session.test.ts`, `subagent-manager.test.ts`) and `agent-config-editor.test.ts`, whose residual clones are the repeated `await editor.showAgentDetail(...)` **act** plus its `setupDetail` arrange and `ui.select.mock.calls` menu assertion — left inline because wrapping the system-under-test is the wrong abstraction for test code (Step 7 lesson; Sandi Metz: "duplication is far cheaper than the wrong abstraction").
1018
1026
 
1019
- #### Step 9 — Resolve the cross-package settings-loader duplication ([#380])
1027
+ #### Step 9 — Resolve the cross-package settings-loader duplication ([#380])
1020
1028
 
1021
1029
  - Targets: `src/settings.ts:198-211`, `packages/pi-subagents-worktrees/src/config.ts:51-73`.
1022
1030
  - Smell: Category A — 23-line production clone: the layered global/project JSON read-sanitize-warn-merge loader.
1023
- - Change: decide explicitly between (a) exporting a small `loadLayeredSettings` helper from pi-subagents' public surface for worktrees to consume, and (b) documenting the duplication as intentional (separate release cadences, registry-resolved dependency) with a recorded fallow suppression.
1024
- The issue weighs the public-API cost (type bundle, `verify:public-types`, docs for third-party authors) against living with the flag.
1025
- - Outcome: `pnpm fallow:dupes` no longer reports the pair, via extraction or recorded suppression.
1031
+ - Decision: **extract** `loadLayeredSettings<T>` added to `src/layered-settings.ts` and published via the `@gotgenes/pi-subagents/settings` dedicated subpath export (`dist/settings.d.ts`); `loadSettings` in `settings.ts` delegates to it internally.
1032
+ Option 2 (fallow suppression) was rejected; the public-API cost is accepted as the right long-term tradeoff for a shared convention in the `@gotgenes/pi-*` family.
1033
+ Worktrees migration (swap `config.ts`'s inlined loader for the shared helper) is deferred to [#415] — worktrees resolves pi-subagents from the registry, so it must wait for a published release carrying the helper.
1034
+ - Outcome: `pnpm fallow:dupes --skip-local` no longer reports the `settings.ts` ↔ `config.ts` pair.
1035
+ The parametrised helper's token sequence diverged sufficiently that the contiguous identical run dropped below the reporting threshold even before the worktrees migration lands.
1036
+ Definitive semantic elimination completes when worktrees adopts the helper in the follow-up.
1026
1037
 
1027
1038
  ### Step dependencies
1028
1039
 
@@ -1159,4 +1170,6 @@ The upstream test suite is run periodically as a regression canary for the sessi
1159
1170
  [#379]: https://github.com/gotgenes/pi-packages/issues/379
1160
1171
  [#380]: https://github.com/gotgenes/pi-packages/issues/380
1161
1172
  [#381]: https://github.com/gotgenes/pi-packages/issues/381
1173
+ [#412]: https://github.com/gotgenes/pi-packages/issues/412
1174
+ [#415]: https://github.com/gotgenes/pi-packages/issues/415
1162
1175
  [ADR-0002]: ../decisions/0002-extensions-on-a-minimal-core.md
@@ -0,0 +1,243 @@
1
+ ---
2
+ issue: 378
3
+ issue_title: "Consolidate lifecycle test fixtures"
4
+ ---
5
+
6
+ # Consolidate lifecycle test fixtures
7
+
8
+ ## Problem Statement
9
+
10
+ `pnpm fallow:dupes` reports clone families concentrated in `test/lifecycle/`: repeated spawn-and-await arrangements and session-factory stubs copy-pasted across files and within them.
11
+ This is Phase 17 Step 7 (core consolidation), sequenced after Steps 2–6 reshaped `Subagent` construction so the lifecycle tests had already churned.
12
+ The roadmap Outcome is: lifecycle clone families 5 → ≤ 1; package test duplication below 600 lines.
13
+
14
+ The issue body cites five families across six files (including `concurrency-queue.test.ts` and `subagent.test.ts` at 766 LOC).
15
+ That snapshot predates Steps 1–6.
16
+ The current state — measured with `fallow dupes -r packages/pi-subagents` against today's `main` — is **four** lifecycle clone families, because the queue was renamed to `concurrency-limiter.test.ts` ([#381]) and `subagent.test.ts`/`concurrency-limiter.test.ts` no longer report families after the Step 2–4 reshaping.
17
+
18
+ ## Goals
19
+
20
+ - Consolidate the four current lifecycle clone families into shared or file-local helpers so the lifecycle tree reports ≤ 1 family.
21
+ - Bring package-wide test duplication below 600 lines (current baseline: 669 lines / 3.3% across 20 files; the four lifecycle families total ~122 lines).
22
+ - Preserve every existing test assertion — the existing suite is the regression guard for this refactor.
23
+ - Keep the change non-breaking: test-only, no production-code, public-surface, or behavior change.
24
+
25
+ ## Non-Goals
26
+
27
+ - Step 8 ([#379]) — UI and tools clone families (`agent-config-editor`, `agent-creation-wizard`, `ui-observer`, `foreground-runner`, `service-adapter`).
28
+ These are separate families outside the lifecycle tree and are deferred to their own issue.
29
+ - Step 9 ([#380]) — the cross-package settings-loader production clone.
30
+ - Any production-code change in `src/`.
31
+ - Adding new behavioral test cases — this is duplication removal, not coverage expansion (helper self-tests are the only new `it` blocks).
32
+
33
+ ## Background
34
+
35
+ The lifecycle suite already has substantial shared scaffolding under `test/helpers/`:
36
+
37
+ - `subagent-session-io.ts` — `createSubagentSessionIO()`, `createAgentLookup()`, `createSubagentSessionDeps()`, `createChildLifecycleMock()`.
38
+ The natural home for shared `createSubagentSession`-test scaffolding.
39
+ - `manager-stubs.ts` — `createBlockingFactory()`, `createSessionFactory()`.
40
+ - `mock-session.ts` — `createMockSession()`, `createSubagentSessionStub()`, `toSubagentSession()`, `toAgentSession()`.
41
+ - `make-subagent.ts` — `createTestSubagent()`, `makeStubExecution()`.
42
+
43
+ Convention: every shared helper module under `test/helpers/` has a companion `*.test.ts` (e.g. `subagent-session-io.test.ts`, `make-subagent.test.ts`).
44
+ New or extended shared helpers must extend that companion test.
45
+
46
+ The four current lifecycle clone families and the repeated blocks driving them:
47
+
48
+ | File | Family | Repeated block |
49
+ | ------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | `create-subagent-session.test.ts` | 3 groups, 31 lines | A file-local `createSession()` mock-session builder, then `io.createSession.mockResolvedValue({ session })` + `await createSubagentSession({ snapshot, type }, createSubagentSessionDeps({ io, exec, registry[, lifecycle] }))` |
51
+ | `create-subagent-session-extension-tools.test.ts` | 3 groups, 29 lines | A near-identical file-local `createSessionWithExtensionToolRegistration()` builder, then the same `io.createSession.mockResolvedValue` + `createSubagentSession(...)` invoke block, plus post-bind `setActiveToolsByName` assertions |
52
+ | `subagent-manager.test.ts` | 4 groups, 40 lines | `spawn(STUB_SNAPSHOT, "general-purpose", "test", { description, isBackground: true, parentSession: { toolCallId } })` and the "spawn two bg agents under limit 1, assert one queued" arrange |
53
+ | `subagent-session.test.ts` | 3 groups, 22 lines | Repeated arrange blocks around the already-local `createSession(finalText)` / `makeSubagentSession()` / `emitTurnEnd()` helpers |
54
+
55
+ The two `createSubagentSession`-test session builders (`createSession` and `createSessionWithExtensionToolRegistration`) build near-identical mock sessions — the same eight `vi.fn()` methods — differing only in that the extension-tools variant flips `getActiveToolNames` between a before-bind and after-bind set.
56
+ That overlap is the one genuinely cross-file duplication and the strongest candidate for promotion to `test/helpers/`.
57
+ The manager and `subagent-session` families are intra-file.
58
+
59
+ AGENTS.md / testing-skill constraints that apply:
60
+
61
+ - When consolidating duplicate test factories, diff the default values across all copies before writing the shared factory — different defaults cause cascading assertion failures during migration.
62
+ - When a TDD step deletes a test helper, re-check the file's remaining imports for orphans — Biome's `noUnusedImports` is warning-level (exit 0), so it will not fail `pnpm run lint`.
63
+ - Run the full suite (not just the touched file) after each shared-helper change.
64
+
65
+ ## Design Overview
66
+
67
+ The guiding distinction: **promote to `test/helpers/` only what is genuinely shared across files; keep intra-file families as file-local helpers.**
68
+ Fallow's own recommendation for three of the four families is "extract shared function from `<file>`, `<file>`" (same file on both sides) — promoting those to `test/helpers/` would manufacture cross-file coupling that does not exist.
69
+
70
+ ### 1. Shared: a mock-session builder for `createSubagentSession` tests
71
+
72
+ Add `createFactorySession(overrides?)` to `test/helpers/subagent-session-io.ts`.
73
+ It returns the eight-method mock session both `createSubagentSession` test files build by hand today, with `getActiveToolNames` supporting a static set or a before/after-bind flip:
74
+
75
+ ```typescript
76
+ export interface FactorySessionOptions {
77
+ /** Tools active before bindExtensions(). Default ["read"]. */
78
+ toolsBeforeBind?: string[];
79
+ /** Tools active after bindExtensions(). Defaults to toolsBeforeBind (no extension registration). */
80
+ toolsAfterBind?: string[];
81
+ }
82
+
83
+ export function createFactorySession(options: FactorySessionOptions = {}) {
84
+ const before = options.toolsBeforeBind ?? ["read"];
85
+ const after = options.toolsAfterBind ?? before;
86
+ let bound = false;
87
+ return {
88
+ messages: [] as unknown[],
89
+ subscribe: vi.fn(() => () => {}),
90
+ prompt: vi.fn().mockResolvedValue(undefined),
91
+ abort: vi.fn(),
92
+ steer: vi.fn().mockResolvedValue(undefined),
93
+ dispose: vi.fn(),
94
+ getActiveToolNames: vi.fn(() => (bound ? after : before)),
95
+ setActiveToolsByName: vi.fn(),
96
+ bindExtensions: vi.fn(async () => { bound = true; }),
97
+ };
98
+ }
99
+ ```
100
+
101
+ Return type is deliberately unannotated so callers retain the `Mock<...>` methods (`mock.calls`, `mockResolvedValue`) — matching the existing `createSubagentSessionIO()` convention in this file.
102
+
103
+ `create-subagent-session.test.ts` replaces `createSession()` with `createFactorySession()`; the existing static `getActiveToolNames: () => ["read"]` becomes the default branch (`bound === after === before === ["read"]`), so the lone `bindExtensions` flip is inert there — behavior-preserving.
104
+ `create-subagent-session-extension-tools.test.ts` replaces `createSessionWithExtensionToolRegistration(before, after)` with `createFactorySession({ toolsBeforeBind: before, toolsAfterBind: after })`.
105
+
106
+ Consumer call site (verifies Tell-Don't-Ask: the helper returns a ready mock; the test arranges and asserts on it):
107
+
108
+ ```typescript
109
+ const session = createFactorySession({ toolsBeforeBind: ["read"], toolsAfterBind: ["read", "extension_tool"] });
110
+ io.createSession.mockResolvedValue({ session });
111
+ await createSubagentSession({ snapshot: STUB_SNAPSHOT, type: "test-agent" }, createSubagentSessionDeps({ io, exec, registry }));
112
+ expect(session.setActiveToolsByName.mock.calls[0][0]).toContain("extension_tool");
113
+ ```
114
+
115
+ The repeated `io.createSession.mockResolvedValue({ session })` + `createSubagentSession(...)` invoke pair is left in the tests rather than wrapped in an `invokeCreate()` helper: it is two lines, the `params` and `deps` overrides vary per test, and wrapping it would hide the system-under-test call behind a helper (procedure-splitting, not design improvement).
116
+ Extracting only the session builder removes the bulk of both families because the builder is the larger half of each clone.
117
+
118
+ ### 2. File-local: manager spawn / queued-pair arrangements
119
+
120
+ `subagent-manager.test.ts` already has file-local `spawnBg`/`spawnFg`/`createManager`/`defaultFactory`.
121
+ The remaining 40 lines are two intra-file patterns; add two file-local helpers:
122
+
123
+ ```typescript
124
+ /** Spawn a background agent carrying a parentSession.toolCallId (notification path). */
125
+ function spawnBgWithToolCall(mgr: SubagentManager, toolCallId: string, prompt = "test") {
126
+ return mgr.spawn(STUB_SNAPSHOT, "general-purpose", prompt, {
127
+ description: prompt,
128
+ isBackground: true,
129
+ parentSession: { toolCallId },
130
+ });
131
+ }
132
+
133
+ /** Arrange a manager at limit 1 with two bg agents: first runs, second queues. */
134
+ function arrangeQueuedPair() {
135
+ const { manager } = createManager({ createSubagentSession: createBlockingFactory(), getMaxConcurrent: () => 1 });
136
+ const running = spawnBg(manager, "a");
137
+ const queued = spawnBg(manager, "b");
138
+ return { manager, running, queued };
139
+ }
140
+ ```
141
+
142
+ These stay file-local: they reference manager-specific concerns and fallow scores the family as same-file.
143
+
144
+ ### 3. File-local: `subagent-session.test.ts` arrange blocks
145
+
146
+ The three small groups (22 lines) sit around the existing file-local `createSession(finalText)`, `makeSubagentSession()`, and `emitTurnEnd()` helpers.
147
+ Fold the repeated "build session, build SubagentSession, run/emit" arrange into one file-local helper (e.g. `arrangeSession(finalText, metaOverrides?)` returning `{ session, listeners, sub }`), keeping the per-test assertions inline.
148
+ No promotion to `test/helpers/` — this builder is specific to `subagent-session.test.ts`'s turn-loop assertions and is not used elsewhere.
149
+
150
+ ## Module-Level Changes
151
+
152
+ - `test/helpers/subagent-session-io.ts` — add `createFactorySession()` and `FactorySessionOptions`.
153
+ - `test/helpers/subagent-session-io.test.ts` — add a `describe("createFactorySession")` block: default tool set, before/after-bind flip, all eight methods present.
154
+ - `test/lifecycle/create-subagent-session.test.ts` — delete local `createSession()`; import and use `createFactorySession()`; re-check imports for orphans.
155
+ - `test/lifecycle/create-subagent-session-extension-tools.test.ts` — delete local `createSessionWithExtensionToolRegistration()`; use `createFactorySession({ toolsBeforeBind, toolsAfterBind })`; re-check imports.
156
+ - `test/lifecycle/subagent-manager.test.ts` — add file-local `spawnBgWithToolCall()` and `arrangeQueuedPair()`; route the cloned spawn/queued-pair sites through them.
157
+ - `test/lifecycle/subagent-session.test.ts` — add a file-local `arrangeSession()` helper; route the cloned arrange blocks through it.
158
+ - `docs/architecture/architecture.md` — mark Step 7 ✅ Complete and add a `Landed:` bullet (matching Steps 1–6), updating the families and duplication figures.
159
+
160
+ No `src/` files change.
161
+ No public surface changes, so `verify:public-types` is unaffected.
162
+ The `package-pi-subagents` SKILL.md's test count line ("994 tests across 63 files as of Phase 17 Step 4") is a point-in-time figure, not a Step-7 reference, and is left untouched.
163
+
164
+ ## Test Impact Analysis
165
+
166
+ This is a test-refactoring issue, so the lens is inverted from a production extraction:
167
+
168
+ 1. **New tests the change enables.**
169
+ Only helper self-tests: a `createFactorySession` block in `subagent-session-io.test.ts` (per the `test/helpers/` companion-test convention).
170
+ No new behavioral tests — the refactor must not change coverage.
171
+ 2. **Existing tests that become redundant.**
172
+ None are removed.
173
+ The duplication being removed is *arrange-block* duplication inside `it()` bodies, not duplicate `it()` cases.
174
+ Every existing assertion stays.
175
+ 3. **Tests that must stay as-is.**
176
+ All 228 lifecycle test cases keep their assertions; they are the regression guard.
177
+ Migration is green-to-green: after each file's migration the full suite must stay passing.
178
+ In particular, the post-bind `setActiveToolsByName` assertions in the extension-tools file and the turn-limit/`emitTurnEnd` assertions in `subagent-session.test.ts` are unchanged — only their session-construction arrange is swapped.
179
+
180
+ ## Invariants at risk
181
+
182
+ This step touches `subagent-manager.test.ts`, which pins invariants from earlier Phase 17 steps.
183
+ The `arrangeQueuedPair()` helper must not swallow these:
184
+
185
+ - **Step 1 ([#381]) / Step 3 ([#374]) — "every spawned agent has a `promise` at spawn, even while queued."**
186
+ Pinned by `subagent-manager.test.ts` → `it("gives a queued agent an awaitable promise at spawn (before its slot opens)")`, which asserts `getRecord(queuedId)!.promise` is a `Promise` while status is `"queued"`.
187
+ When this test adopts `arrangeQueuedPair()`, the helper must still return the queued id so the test can assert on `.promise` directly.
188
+ - **Step 3 ([#374]) — "zero external writes to `Subagent.promise`/`.notification` outside `subagent.ts`" (grep-verifiable).**
189
+ The helper migration must not reintroduce `record.promise =` or `record.notification =`.
190
+ The existing `record.notification!.markConsumed()` sites are method calls, not writes, and stay as-is.
191
+ Re-grep `test/lifecycle/` for `\.promise =` and `\.notification =` after the manager migration to confirm none were added.
192
+
193
+ ## TDD Order
194
+
195
+ This is a lift-and-shift refactor of tests; the suite is green at every step (no red phase — the existing assertions are the spec).
196
+ Each migration step runs the full package suite before committing.
197
+
198
+ 1. **Add shared `createFactorySession` + helper test.**
199
+ Surface: `test/helpers/subagent-session-io.ts`, `test/helpers/subagent-session-io.test.ts`.
200
+ Covers: default tool set, before/after-bind flip, method presence.
201
+ Run `pnpm --filter @gotgenes/pi-subagents exec vitest run test/helpers/subagent-session-io.test.ts` then `pnpm run check`.
202
+ Commit: `test: add shared createFactorySession mock-session builder (#378)`.
203
+ 2. **Migrate `create-subagent-session.test.ts` to `createFactorySession`.**
204
+ Delete local `createSession()`, re-check imports, run full suite green.
205
+ Commit: `test: use shared factory session in create-subagent-session tests (#378)`.
206
+ 3. **Migrate `create-subagent-session-extension-tools.test.ts` to `createFactorySession({ toolsBeforeBind, toolsAfterBind })`.**
207
+ Delete local `createSessionWithExtensionToolRegistration()`, re-check imports, run full suite green.
208
+ Commit: `test: use shared factory session in extension-tools tests (#378)`.
209
+ 4. **Consolidate `subagent-manager.test.ts` spawn/queued-pair arrangements.**
210
+ Add `spawnBgWithToolCall()` and `arrangeQueuedPair()`; route the cloned sites through them; re-grep for `.promise =`/`.notification =`; run full suite green.
211
+ Commit: `test: consolidate manager spawn arrangements (#378)`.
212
+ 5. **Consolidate `subagent-session.test.ts` arrange blocks.**
213
+ Add `arrangeSession()`; route cloned sites; run full suite green.
214
+ Commit: `test: consolidate subagent-session arrange blocks (#378)`.
215
+ 6. **Verify and record outcome.**
216
+ Run `pnpm exec fallow dupes -r packages/pi-subagents` and confirm lifecycle families ≤ 1 and package duplication < 600 lines; run `pnpm run check && pnpm run lint && pnpm fallow dead-code`.
217
+ Update `docs/architecture/architecture.md` Step 7 to ✅ Complete with a `Landed:` bullet and refreshed figures.
218
+ Commit: `docs: mark Phase 17 Step 7 complete (#378)`.
219
+
220
+ Steps 2–5 are independent of each other and order-insensitive; each only depends on Step 1's helper.
221
+
222
+ ## Risks and Mitigations
223
+
224
+ - **Divergent defaults between the two session builders cause assertion failures.**
225
+ Mitigation: the two builders are diffed in Background — they share eight identical methods and differ only in `getActiveToolNames`.
226
+ `createFactorySession` defaults `toolsAfterBind` to `toolsBeforeBind` so the non-extension file's static behavior is preserved exactly.
227
+ - **Over-extraction / procedure-splitting to chase the metric.**
228
+ Mitigation: the invoke pair (`mockResolvedValue` + `createSubagentSession(...)`) and the per-test assertions are left inline; only value-returning builders are extracted.
229
+ File-local families stay file-local rather than being force-promoted to `test/helpers/`.
230
+ - **Orphaned imports after deleting local builders (Biome `noUnusedImports` is warning-level, exit 0).**
231
+ Mitigation: re-check each migrated file's imports as part of its step; the Step 6 `pnpm run lint` + dead-code pass is the backstop.
232
+ - **Regressing a cross-step invariant with a green suite (the Step 3 lesson).**
233
+ Mitigation: the "Invariants at risk" section names the pinning tests and the grep checks folded into Step 4.
234
+
235
+ ## Open Questions
236
+
237
+ - Whether package-wide duplication lands below 600 lines from Step 7 alone or needs Step 8 ([#379]).
238
+ Arithmetic says yes (669 − ~122 ≈ 547), but the Step 6 `fallow dupes` measurement is the authority; if it lands above 600, note it and confirm the lifecycle-families target (≤ 1) is still met rather than pulling Step 8 work forward.
239
+
240
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
241
+ [#379]: https://github.com/gotgenes/pi-packages/issues/379
242
+ [#380]: https://github.com/gotgenes/pi-packages/issues/380
243
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381