@gotgenes/pi-subagents 6.9.3 → 6.10.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,27 @@ 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
+ ## [6.10.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.9.4...pi-subagents-v6.10.0) (2026-05-22)
9
+
10
+
11
+ ### Features
12
+
13
+ * inject IO collaborators into assembleSessionConfig ([#132](https://github.com/gotgenes/pi-packages/issues/132)) ([74d3dbf](https://github.com/gotgenes/pi-packages/commit/74d3dbf5e67cf28f75683e55240719ad2be86490))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * mark Step G complete in Phase 8 roadmap ([#132](https://github.com/gotgenes/pi-packages/issues/132)) ([95512bd](https://github.com/gotgenes/pi-packages/commit/95512bdec3757d5955d13e22261a90da41cea40e))
19
+ * plan IO collaborator injection into assembleSessionConfig ([#132](https://github.com/gotgenes/pi-packages/issues/132)) ([23c3b62](https://github.com/gotgenes/pi-packages/commit/23c3b624e8c0afb8fda72c1b5fba86cb165f78dd))
20
+ * **retro:** add retro notes for issue [#131](https://github.com/gotgenes/pi-packages/issues/131) ([b91cee9](https://github.com/gotgenes/pi-packages/commit/b91cee9ef69f8b1ab41be986663bad22e77a8c67))
21
+
22
+ ## [6.9.4](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.9.3...pi-subagents-v6.9.4) (2026-05-22)
23
+
24
+
25
+ ### Documentation
26
+
27
+ * plan consolidate shared test fixtures ([#131](https://github.com/gotgenes/pi-packages/issues/131)) ([2fe1e65](https://github.com/gotgenes/pi-packages/commit/2fe1e65024743384981c057b405f97f9c76f9b05))
28
+
8
29
  ## [6.9.3](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.9.2...pi-subagents-v6.9.3) (2026-05-22)
9
30
 
10
31
 
@@ -506,8 +506,8 @@ Phase 7 eliminated all structural smells (mutable state, closure bags, callback
506
506
  Phase 8 targets the next layer: testability friction, display module cohesion, and menu decomposition.
507
507
 
508
508
  The test suite (690 tests, 1.4:1 test-to-code ratio) is comprehensive but uneven in quality.
509
- Two files — `session-config.test.ts` and `agent-runner.test.ts` account for 11 of 12 total `vi.mock()` calls and rely heavily on verifying internal call sequences rather than observable outputs.
510
- This fragility is a symptom of production code that imports IO-touching collaborators directly instead of receiving them through injection.
509
+ `agent-runner.test.ts` accounts for 7 of 8 remaining `vi.mock()` calls and relies heavily on verifying internal call sequences rather than observable outputs.
510
+ This fragility is a symptom of production code that imports IO-touching collaborators directly instead of receiving them through injection. (Step G resolved `session-config.test.ts`, which previously held 4 of the 12 total mocks.)
511
511
 
512
512
  The display and menu improvements were identified during Phase 7 but deferred because they don't gate encapsulation work.
513
513
  They are included here because the display extraction unblocks menu decomposition.
@@ -517,7 +517,7 @@ They are included here because the display extraction unblocks menu decompositio
517
517
  | Symptom | Location | Root cause |
518
518
  | ----------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
519
519
  | 7 `vi.mock()` calls | `agent-runner.test.ts` | Runner imports prompts, memory, skills, env, session-dir directly |
520
- | 4 `vi.mock()` calls | `session-config.test.ts` | Assembler imports prompts, memory, skills directly |
520
+ | 7 `vi.mock()` calls | `agent-runner.test.ts` | Runner imports prompts, memory, skills, env, session-dir directly |
521
521
  | 52 `as any` casts | Across test suite | SDK session/context interfaces too wide to construct in tests |
522
522
  | 3× duplicated `mockSession()` | agent-manager, record-observer, ui-observer tests | No shared test fixture |
523
523
  | 3× duplicated `makeDeps()` | agent-tool, background-spawner, foreground-runner tests | No shared tool-deps fixture |
@@ -535,26 +535,11 @@ Consolidate duplicated mock factories into `test/helpers/`.
535
535
 
536
536
  Impact: reduces test boilerplate; single source of truth for mock shapes; changes to dep interfaces propagate automatically.
537
537
 
538
- ### Step G: Inject IO collaborators into session-config (#132)
539
-
540
- `assembleSessionConfig` is described as a pure assembler, but it directly imports three IO-touching functions: `preloadSkills` (reads `.pi/skills` files), `buildMemoryBlock` (reads `MEMORY.md`), and `buildReadOnlyMemoryBlock` (reads `MEMORY.md`).
541
- It also imports `buildAgentPrompt`, which is pure but mocked anyway because tests verify call arguments instead of output properties.
542
-
543
- Inject these as an `AssemblerIO` parameter:
544
-
545
- ```typescript
546
- export interface AssemblerIO {
547
- preloadSkills: (skills: string[], cwd: string) => PreloadedSkill[];
548
- buildMemoryBlock: (name: string, scope: MemoryScope, cwd: string) => string;
549
- buildReadOnlyMemoryBlock: (name: string, scope: MemoryScope, cwd: string) => string;
550
- buildAgentPrompt: (config: AgentPromptConfig, cwd: string, env: EnvInfo, parentPrompt: string, extras: PromptExtras) => string;
551
- }
552
- ```
553
-
554
- The production call site in `agent-runner.ts` passes the real implementations.
555
- Tests pass stubs or let real implementations run against controlled inputs.
538
+ ### Step G: Inject IO collaborators into session-config (#132) ✓ done
556
539
 
557
- Impact: eliminates all 4 `vi.mock()` calls in `session-config.test.ts`; tests verify `SessionConfig` output properties instead of mock call arguments; the assembler becomes truly pure.
540
+ `assembleSessionConfig` now accepts `io: AssemblerIO` as a required parameter.
541
+ `agent-runner.ts` constructs the real `AssemblerIO` from direct imports and passes it through.
542
+ `session-config.test.ts` injects stubs — all 4 `vi.mock()` calls eliminated, assertions shifted to `SessionConfig` output properties.
558
543
 
559
544
  ### Step H: Inject SDK boundary into agent-runner (#133)
560
545
 
@@ -0,0 +1,207 @@
1
+ ---
2
+ issue: 131
3
+ issue_title: Consolidate shared test fixtures
4
+ ---
5
+
6
+ # Consolidate shared test fixtures
7
+
8
+ ## Problem Statement
9
+
10
+ Three `mockSession()` factories and three `makeDeps()` factories are duplicated across the test suite.
11
+ Each copy drifts independently when production interfaces change, creating maintenance burden and inconsistent mock shapes.
12
+ The architecture doc (Phase 8, Step F) identifies this as the first testability improvement before the IO-injection steps (G and H).
13
+
14
+ ## Goals
15
+
16
+ - Extract `createMockSession()` into `test/helpers/mock-session.ts` — single source of truth for the subscribable session mock.
17
+ - Extract `createToolDeps()` into `test/helpers/make-deps.ts` — builds `AgentToolDeps` with sensible defaults and override support.
18
+ - Update all six test files to use the shared factories and remove their local copies.
19
+ - Keep existing test behavior unchanged — this is a pure refactor with no production code changes.
20
+
21
+ ## Non-Goals
22
+
23
+ - IO injection into `session-config` (Step G, #132) — deferred.
24
+ - SDK boundary injection into `agent-runner` (Step H, #133) — deferred.
25
+ - Consolidating `makeCtx()` or `makeParams()` helpers — those are specific to each tool's parameter shape and do not share enough structure to justify extraction.
26
+
27
+ ## Background
28
+
29
+ ### Existing helper
30
+
31
+ `test/helpers/make-record.ts` exports `createTestRecord()`, which builds an `AgentRecord` with sensible defaults and override support.
32
+ It has its own unit test file (`test/helpers/make-record.test.ts`).
33
+ The two new factories follow the same pattern.
34
+
35
+ ### `mockSession()` — 3 copies
36
+
37
+ | File | Shape |
38
+ | ------------------------- | ------------------------------------------------------------------------------------------------- |
39
+ | `agent-manager.test.ts` | `subscribe` (vi.fn), `emit`, `dispose` (vi.fn), `steer` (vi.fn), `sessionManager` — cast as `any` |
40
+ | `record-observer.test.ts` | `subscribe` (vi.fn), `emit` |
41
+ | `ui/ui-observer.test.ts` | `subscribe` (plain fn), `emit` |
42
+
43
+ The common core is `subscribe` + `emit` (the subscribable event bus).
44
+ The `agent-manager` copy adds extra properties the other two don't need.
45
+
46
+ ### `makeDeps()` — 3 copies
47
+
48
+ | File | Type | Manager methods | Widget methods | Extra fields |
49
+ | ---------------------------------- | ---------------- | -------------------------------------------------------- | ------------------------------------------- | ---------------------------- |
50
+ | `tools/agent-tool.test.ts` | `AgentToolDeps` | spawn, spawnAndWait, resume, getRecord, getMaxConcurrent | setUICtx, ensureTimer, update, markFinished | registry, agentDir, settings |
51
+ | `tools/background-spawner.test.ts` | `BackgroundDeps` | spawn, getRecord, getMaxConcurrent | ensureTimer, update | — |
52
+ | `tools/foreground-runner.test.ts` | `ForegroundDeps` | spawnAndWait | ensureTimer, markFinished | — |
53
+
54
+ `AgentToolDeps` is a structural superset of both `BackgroundDeps` and `ForegroundDeps`.
55
+ TypeScript's structural type system allows an `AgentToolDeps` value to be passed where `BackgroundDeps` or `ForegroundDeps` is expected — the narrower interfaces require a strict subset of the methods present on the wider one.
56
+
57
+ ## Design Overview
58
+
59
+ ### `createMockSession(overrides?)`
60
+
61
+ Returns the subscribable event bus (core shape) merged with optional overrides.
62
+ The core shape includes:
63
+
64
+ ```typescript
65
+ interface MockSession {
66
+ subscribe: Mock<[fn: (event: any) => void], () => void>;
67
+ emit(event: any): void; // test-only helper, not on production Session
68
+ dispose: Mock;
69
+ steer: Mock;
70
+ sessionManager: { getSessionFile: Mock };
71
+ }
72
+ ```
73
+
74
+ All fields are present in every call — callers that don't need `dispose` or `steer` simply ignore them.
75
+ This avoids a discriminated "minimal vs. full" shape that would reintroduce the divergence problem.
76
+ The `subscribe` spy is wired to a `Set<fn>` internally so `emit()` broadcasts to all subscribers, matching the existing hand-rolled pattern.
77
+ The return type is `MockSession & Record<string, unknown>` so call sites can pass it as `any`-typed session parameters without explicit casts.
78
+
79
+ Override support lets `agent-manager.test.ts` customize `steer` behavior or add fields:
80
+
81
+ ```typescript
82
+ const session = createMockSession({ steer: vi.fn().mockRejectedValue(new Error("fail")) });
83
+ ```
84
+
85
+ ### `createToolDeps(overrides?)`
86
+
87
+ Builds a full `AgentToolDeps` with mock manager, widget, activity map, registry, agent dir, and settings.
88
+ Accepts `Partial<AgentToolDeps>` for overrides, following the same pattern as `createTestRecord()`.
89
+
90
+ ```typescript
91
+ function createToolDeps(overrides?: Partial<AgentToolDeps>): AgentToolDeps;
92
+ ```
93
+
94
+ Consumer call sites:
95
+
96
+ ```typescript
97
+ // agent-tool.test.ts — uses the full type directly
98
+ const deps = createToolDeps();
99
+ const tool = createAgentTool(deps);
100
+
101
+ // background-spawner.test.ts — structural typing narrows automatically
102
+ const deps = createToolDeps();
103
+ spawnBackground(deps, makeParams());
104
+
105
+ // foreground-runner.test.ts — same structural narrowing
106
+ const deps = createToolDeps({ manager: { spawnAndWait: vi.fn().mockResolvedValue(customRecord) } });
107
+ await runForeground(deps, makeParams(), undefined, undefined);
108
+ ```
109
+
110
+ The background and foreground tests gain unused mock methods on `manager` and `widget`, but this is harmless — the production code's ISP compliance ensures only the narrow interface methods are called.
111
+ Tests that assert specific mock interactions (e.g., `expect(deps.manager.spawn).toHaveBeenCalled()`) continue to work because every method is a distinct `vi.fn()`.
112
+
113
+ When a test needs to override a single manager method, it spreads into the nested object:
114
+
115
+ ```typescript
116
+ createToolDeps({
117
+ manager: { ...createToolDeps().manager, spawnAndWait: vi.fn().mockRejectedValue(err) },
118
+ });
119
+ ```
120
+
121
+ This is slightly more verbose than today's flat override, but it happens rarely and the tradeoff is worthwhile for a single source of truth.
122
+
123
+ Alternatively, `createToolDeps` can accept a `managerOverrides` shorthand if the nested-spread pattern proves too noisy during implementation.
124
+
125
+ ## Module-Level Changes
126
+
127
+ ### New files
128
+
129
+ 1. `test/helpers/mock-session.ts` — exports `createMockSession(overrides?)`.
130
+ 2. `test/helpers/mock-session.test.ts` — unit tests for `createMockSession`: verifies event broadcasting, subscribe/unsubscribe, and override merging.
131
+ 3. `test/helpers/make-deps.ts` — exports `createToolDeps(overrides?)`.
132
+ 4. `test/helpers/make-deps.test.ts` — unit tests for `createToolDeps`: verifies default shape satisfies `AgentToolDeps`, `BackgroundDeps`, and `ForegroundDeps`; verifies override merging.
133
+
134
+ ### Modified files
135
+
136
+ 1. `test/agent-manager.test.ts` — remove local `mockSession()`, import `createMockSession` from helpers.
137
+ 2. `test/record-observer.test.ts` — remove local `mockSession()`, import `createMockSession` from helpers.
138
+ 3. `test/ui/ui-observer.test.ts` — remove local `mockSession()`, import `createMockSession` from helpers.
139
+ 4. `test/tools/agent-tool.test.ts` — remove local `makeDeps()`, import `createToolDeps` from helpers.
140
+ 5. `test/tools/background-spawner.test.ts` — remove local `makeDeps()`, import `createToolDeps` from helpers.
141
+ 6. `test/tools/foreground-runner.test.ts` — remove local `makeDeps()`, import `createToolDeps` from helpers.
142
+
143
+ ## Test Impact Analysis
144
+
145
+ 1. The new factory unit tests (`mock-session.test.ts`, `make-deps.test.ts`) verify the shared fixture behavior that was previously only implicitly tested through the consumer test files.
146
+ This enables targeted debugging when a mock shape drifts from the production interface.
147
+ 2. No existing tests become redundant — the consumer tests exercise distinct production behavior that the factory tests do not cover.
148
+ 3. All existing tests stay as-is in terms of assertions.
149
+ Only the setup code (local factory → shared import) changes.
150
+
151
+ ## TDD Order
152
+
153
+ 1. **Red → Green: `createMockSession` factory.**
154
+ Write `test/helpers/mock-session.test.ts` — verify subscribe/emit broadcasting, unsubscribe, dispose/steer are vi.fn stubs, override merging.
155
+ Implement `test/helpers/mock-session.ts`.
156
+ Commit: `test: add createMockSession shared test fixture`
157
+
158
+ 2. **Green: migrate `record-observer.test.ts` to `createMockSession`.**
159
+ Replace local `mockSession()` with import from helpers.
160
+ Run test file — all tests pass unchanged.
161
+ Commit: `test: use createMockSession in record-observer tests`
162
+
163
+ 3. **Green: migrate `ui/ui-observer.test.ts` to `createMockSession`.**
164
+ Replace local `mockSession()` with import from helpers.
165
+ Run test file — all tests pass unchanged.
166
+ Commit: `test: use createMockSession in ui-observer tests`
167
+
168
+ 4. **Green: migrate `agent-manager.test.ts` to `createMockSession`.**
169
+ Replace local `mockSession()` with import from helpers.
170
+ This file uses extra fields (`sessionManager`, `steer`, `dispose`) — verify overrides or defaults cover them.
171
+ Run test file — all tests pass unchanged.
172
+ Commit: `test: use createMockSession in agent-manager tests`
173
+
174
+ 5. **Red → Green: `createToolDeps` factory.**
175
+ Write `test/helpers/make-deps.test.ts` — verify default shape, override merging, structural compatibility with `BackgroundDeps` and `ForegroundDeps`.
176
+ Implement `test/helpers/make-deps.ts`.
177
+ Commit: `test: add createToolDeps shared test fixture`
178
+
179
+ 6. **Green: migrate `tools/agent-tool.test.ts` to `createToolDeps`.**
180
+ Replace local `makeDeps()` with import from helpers.
181
+ Run test file — all tests pass unchanged.
182
+ Commit: `test: use createToolDeps in agent-tool tests`
183
+
184
+ 7. **Green: migrate `tools/background-spawner.test.ts` to `createToolDeps`.**
185
+ Replace local `makeDeps()` with import from helpers.
186
+ Adjust any override patterns for the wider type.
187
+ Run test file — all tests pass unchanged.
188
+ Commit: `test: use createToolDeps in background-spawner tests`
189
+
190
+ 8. **Green: migrate `tools/foreground-runner.test.ts` to `createToolDeps`.**
191
+ Replace local `makeDeps()` with import from helpers.
192
+ Adjust any override patterns for the wider type.
193
+ Run test file — all tests pass unchanged.
194
+ Commit: `test: use createToolDeps in foreground-runner tests`
195
+
196
+ ## Risks and Mitigations
197
+
198
+ | Risk | Mitigation |
199
+ | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
200
+ | Wider mock shape causes false-positive tests (tests pass even when production code calls wrong method) | The production interfaces are already ISP-narrow; the mock width only affects tests. Existing assertions on specific mock calls catch regressions. |
201
+ | Override merging doesn't handle nested objects (e.g., overriding a single manager method) | Factory uses shallow merge for top-level fields; document that nested overrides require spreading the default nested object. Evaluate a `managerOverrides` shorthand during implementation if the pattern is too noisy. |
202
+ | `createMockSession` return type is too loose (`any`) and hides type errors in tests | Return a named `MockSession` interface rather than `any`. Consumer sites that pass the mock as `any`-typed SDK parameters are already untyped at that boundary. |
203
+
204
+ ## Open Questions
205
+
206
+ - Should `createToolDeps` accept a flat `managerOverrides` shorthand or require the caller to spread the nested object?
207
+ Decide during step 5 based on how verbose the migration turns out in steps 6–8.
@@ -0,0 +1,219 @@
1
+ ---
2
+ issue: 132
3
+ issue_title: "Inject IO collaborators into `assembleSessionConfig`"
4
+ ---
5
+
6
+ # Inject IO collaborators into session-config
7
+
8
+ ## Problem Statement
9
+
10
+ `assembleSessionConfig` is described as a pure configuration assembler, but it directly imports three IO-touching functions (`preloadSkills`, `buildMemoryBlock`, `buildReadOnlyMemoryBlock`) and one pure function (`buildAgentPrompt`).
11
+ This forces `session-config.test.ts` to use 4 `vi.mock()` calls, 8 hoisted mock functions, and assertions that verify internal call sequences rather than output properties.
12
+ The result is fragile tests that break on any internal restructuring even when observable behavior is unchanged.
13
+
14
+ ## Goals
15
+
16
+ - Define an `AssemblerIO` interface bundling the four collaborators.
17
+ - Add `io: AssemblerIO` as a parameter to `assembleSessionConfig()`.
18
+ - Replace direct imports of the four functions with calls through `io`.
19
+ - Update the single production call site in `agent-runner.ts` to pass real implementations.
20
+ - Eliminate all 4 `vi.mock()` calls in `session-config.test.ts`.
21
+ - Shift test assertions toward output-property verification.
22
+
23
+ ## Non-Goals
24
+
25
+ - SDK boundary injection into `agent-runner` (Step H, #133) — depends on this change but is deferred to its own issue.
26
+ - Consolidating shared test fixtures (#131) — independent refactor that can land before or after.
27
+ - Changing the behavior of `assembleSessionConfig` — this is a pure structural refactor.
28
+ - Injecting `getMemoryToolNames` / `getReadOnlyMemoryToolNames` — these are pure utility functions with no IO; they stay as direct imports.
29
+
30
+ ## Background
31
+
32
+ ### Current state
33
+
34
+ `session-config.ts` imports four functions used during assembly:
35
+
36
+ | Function | Module | IO? | Purpose in assembler |
37
+ | -------------------------- | ----------------- | ------------------------------ | --------------------------------------- |
38
+ | `preloadSkills` | `skill-loader.ts` | Yes (reads `.pi/skills` files) | Loads skill content into prompt extras |
39
+ | `buildMemoryBlock` | `memory.ts` | Yes (reads `MEMORY.md`) | Builds read-write memory prompt section |
40
+ | `buildReadOnlyMemoryBlock` | `memory.ts` | Yes (reads `MEMORY.md`) | Builds read-only memory prompt section |
41
+ | `buildAgentPrompt` | `prompts.ts` | No (pure) | Assembles final system prompt string |
42
+
43
+ The test file mocks all four via `vi.mock()` plus mocks `getMemoryToolNames` and `getReadOnlyMemoryToolNames` from `agent-types.ts` (pure functions that are mocked only for call-argument verification).
44
+
45
+ ### Established DI pattern
46
+
47
+ `AgentManager` already injects `AgentRunner` via its constructor options — the same tell-don't-ask pattern used here.
48
+ `assembleSessionConfig` already receives an `AgentConfigLookup` registry by parameter (migrated in #80/#108), demonstrating the incremental injection approach.
49
+
50
+ ### Architecture reference
51
+
52
+ Phase 8, Step G in `docs/architecture/architecture.md`.
53
+
54
+ ### Constraints from AGENTS.md
55
+
56
+ - Keep scope tight; prefer small, reversible changes.
57
+ - Prefer explicit configuration over hidden behavior.
58
+ - Business logic should be pure functions — keep IO at the edges.
59
+
60
+ ## Design Overview
61
+
62
+ ### `AssemblerIO` interface
63
+
64
+ Defined in `session-config.ts` alongside the existing assembler types:
65
+
66
+ ```typescript
67
+ export interface AssemblerIO {
68
+ preloadSkills: (skills: string[], cwd: string) => PreloadedSkill[];
69
+ buildMemoryBlock: (
70
+ name: string,
71
+ scope: MemoryScope,
72
+ cwd: string,
73
+ ) => string;
74
+ buildReadOnlyMemoryBlock: (
75
+ name: string,
76
+ scope: MemoryScope,
77
+ cwd: string,
78
+ ) => string;
79
+ buildAgentPrompt: (
80
+ config: AgentPromptConfig,
81
+ cwd: string,
82
+ env: EnvInfo,
83
+ parentPrompt?: string,
84
+ extras?: PromptExtras,
85
+ ) => string;
86
+ }
87
+ ```
88
+
89
+ The interface uses the same parameter types as the real functions.
90
+ The assembler calls `io.preloadSkills(...)` etc. instead of the direct imports.
91
+
92
+ ### Call site in `agent-runner.ts`
93
+
94
+ ```typescript
95
+ import { preloadSkills } from "./skill-loader.js";
96
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
97
+ import { buildAgentPrompt } from "./prompts.js";
98
+
99
+ const io: AssemblerIO = {
100
+ preloadSkills,
101
+ buildMemoryBlock,
102
+ buildReadOnlyMemoryBlock,
103
+ buildAgentPrompt,
104
+ };
105
+
106
+ const cfg = assembleSessionConfig(type, ctx, options, env, registry, io);
107
+ ```
108
+
109
+ The runner constructs the real IO object once and passes it through.
110
+ This keeps IO at the edge (runner) and makes the assembler a genuine pure function.
111
+
112
+ ### Test-side stubs
113
+
114
+ Tests create a plain object with `vi.fn()` stubs satisfying `AssemblerIO`:
115
+
116
+ ```typescript
117
+ const io: AssemblerIO = {
118
+ preloadSkills: vi.fn(() => []),
119
+ buildMemoryBlock: vi.fn(() => "memory block"),
120
+ buildReadOnlyMemoryBlock: vi.fn(() => "read-only memory block"),
121
+ buildAgentPrompt: vi.fn(() => "assembled system prompt"),
122
+ };
123
+ ```
124
+
125
+ This replaces all four `vi.mock()` calls and the hoisted mocks for those modules.
126
+
127
+ ### Pure utility functions stay as direct imports
128
+
129
+ `getMemoryToolNames` and `getReadOnlyMemoryToolNames` from `agent-types.ts` are pure functions (no IO, no filesystem access).
130
+ After the IO injection, the test's `vi.mock("../src/agent-types.js", ...)` can be removed and real implementations used.
131
+ Tests that previously controlled these mocks to verify call arguments will instead set up input tool names to produce the desired output from the real functions, then assert on the returned `SessionConfig.toolNames`.
132
+
133
+ ## Module-Level Changes
134
+
135
+ ### Modified files
136
+
137
+ 1. `src/session-config.ts`
138
+ - Add `AssemblerIO` interface export.
139
+ - Add `io: AssemblerIO` parameter to `assembleSessionConfig()` (after `registry`).
140
+ - Replace `preloadSkills(...)` with `io.preloadSkills(...)`.
141
+ - Replace `buildMemoryBlock(...)` with `io.buildMemoryBlock(...)`.
142
+ - Replace `buildReadOnlyMemoryBlock(...)` with `io.buildReadOnlyMemoryBlock(...)`.
143
+ - Replace `buildAgentPrompt(...)` with `io.buildAgentPrompt(...)`.
144
+ - Remove imports of `preloadSkills`, `buildMemoryBlock`, `buildReadOnlyMemoryBlock`, `buildAgentPrompt`.
145
+ - Keep imports of `getMemoryToolNames`, `getReadOnlyMemoryToolNames` (pure, no change).
146
+
147
+ 2. `src/agent-runner.ts`
148
+ - Add imports for `preloadSkills`, `buildMemoryBlock`, `buildReadOnlyMemoryBlock`, `buildAgentPrompt`.
149
+ - Import `AssemblerIO` type from `session-config.ts`.
150
+ - Construct `AssemblerIO` object from real implementations.
151
+ - Pass `io` to `assembleSessionConfig()`.
152
+
153
+ 3. `test/session-config.test.ts`
154
+ - Remove all 4 `vi.mock()` calls and the corresponding hoisted mocks.
155
+ - Create `io` stub object with `vi.fn()` implementations.
156
+ - Pass `io` to every `assembleSessionConfig()` call.
157
+ - Update memory-section tests to use real `getMemoryToolNames` / `getReadOnlyMemoryToolNames`.
158
+ - Migrate mock-call assertions to output-property assertions where the output already captures the information.
159
+
160
+ ## Test Impact Analysis
161
+
162
+ 1. The IO injection enables testing `assembleSessionConfig` without any module mocking.
163
+ Tests can choose to inject real implementations with controlled inputs (integration-style) or stubs (unit-style).
164
+ Previously this was impossible without `vi.mock()`.
165
+
166
+ 2. Several existing tests that only verified mock-call arguments become redundant once we verify the same information through output properties (e.g., "calls buildAgentPrompt with env, cwd, parentSystemPrompt, and extras" is redundant if we verify `result.systemPrompt` reflects those inputs).
167
+ These can be simplified or removed.
168
+
169
+ 3. Tests for model resolution, isolated mode, thinking level, and unknown-type fallback stay as-is — they already assert output properties and are unaffected by the IO injection.
170
+
171
+ ## TDD Order
172
+
173
+ 1. **Define `AssemblerIO` and inject into `assembleSessionConfig`.**
174
+ Add the `AssemblerIO` interface to `session-config.ts`.
175
+ Add `io: AssemblerIO` as a required parameter.
176
+ Replace the 4 direct function calls with `io.*` calls.
177
+ Remove the 4 function imports from `session-config.ts`.
178
+ Add the 4 imports to `agent-runner.ts` and construct the `io` object at the call site.
179
+ Run `pnpm run check` to verify types compile.
180
+ Commit: `feat: inject IO collaborators into assembleSessionConfig (#132)`
181
+
182
+ 2. **Migrate test file to use injected IO stubs.**
183
+ Create an `io` stub object with `vi.fn()` stubs matching the existing hoisted mocks' default return values.
184
+ Pass `io` to all `assembleSessionConfig()` calls.
185
+ Remove the 3 `vi.mock()` calls for `prompts.js`, `memory.js`, and `skill-loader.js`.
186
+ Remove the corresponding hoisted mock variables (`mockBuildAgentPrompt`, `mockBuildMemoryBlock`, `mockBuildReadOnlyMemoryBlock`, `mockPreloadSkills`).
187
+ Update `beforeEach` to reset the `io` stubs instead.
188
+ All existing tests pass with the same assertions (io stubs replace module mocks).
189
+ Commit: `test: replace vi.mock with injected IO stubs in session-config tests`
190
+
191
+ 3. **Drop the `agent-types.js` mock; use real pure functions.**
192
+ Remove the `vi.mock("../src/agent-types.js", ...)` call and the `importOriginal` pattern.
193
+ Remove hoisted `mockGetMemoryToolNames` and `mockGetReadOnlyMemoryToolNames`.
194
+ Update memory-section tests to set up `mockGetToolNamesForType` return values that produce the desired output from the real `getMemoryToolNames` / `getReadOnlyMemoryToolNames`.
195
+ Assertions shift from "mock was called with Set" to "result.toolNames contains expected names".
196
+ Commit: `test: use real getMemoryToolNames in session-config tests`
197
+
198
+ 4. **Shift remaining mock-call assertions to output-property checks.**
199
+ Replace `expect(io.buildAgentPrompt).toHaveBeenCalledWith(...)` with assertions on `result.systemPrompt` (requires io.buildAgentPrompt stub to echo identifying values).
200
+ Replace `expect(io.preloadSkills).toHaveBeenCalledWith(skillList, "/tmp")` with `result.extras.skillBlocks` checks (already partially present).
201
+ Remove test cases that are now fully redundant with output-based tests in the same describe block.
202
+ Clean up any unused imports and variables.
203
+ Commit: `test: verify output properties in session-config tests (#132)`
204
+
205
+ ## Risks and Mitigations
206
+
207
+ | Risk | Mitigation |
208
+ | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
209
+ | Adding a parameter to `assembleSessionConfig` breaks the `agent-runner.ts` call site | Only one production call site exists; updated in the same commit (step 1). `pnpm run check` verifies. |
210
+ | Removing `vi.mock()` causes tests to accidentally call real IO functions | The real functions are no longer imported by `session-config.ts` after step 1. The module simply doesn't reach them. Vitest will error if any unmocked import is called. |
211
+ | Using real `getMemoryToolNames` / `getReadOnlyMemoryToolNames` makes tests depend on their implementation | These are pure, stable utility functions (return tool names from a set). Their behavior is well-defined and unlikely to change. Using real implementations is more robust than mocking. |
212
+ | Step 2 touches 40+ call sites in the test file | All changes are mechanical (add `, io` argument). A find-and-replace handles it. Each call already passes `mockAgentLookup` as the last arg; the new arg follows the same pattern. |
213
+
214
+ ## Open Questions
215
+
216
+ - Should `AssemblerIO` be co-located in `session-config.ts` or extracted to a separate `session-config-types.ts`?
217
+ The interface is small (4 methods) and tightly coupled to the assembler.
218
+ Co-location in `session-config.ts` follows the existing pattern (`AssemblerContext`, `AssemblerOptions`, `SessionConfig` are all in the same file).
219
+ Extract only if it grows or gains consumers beyond `agent-runner.ts`.
@@ -0,0 +1,46 @@
1
+ ---
2
+ issue: 131
3
+ issue_title: Consolidate shared test fixtures
4
+ ---
5
+
6
+ # Retro: #131 — Consolidate shared test fixtures
7
+
8
+ ## Final Retrospective (2026-05-22T11:30:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the consolidation of six duplicated test factories into two shared helpers (`createMockSession` in `test/helpers/mock-session.ts`, `createToolDeps` in `test/helpers/make-deps.ts`).
13
+ All 715 tests pass, released as `pi-subagents-v6.9.4`.
14
+ The implementation was a pure test refactor with no production code changes.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The lift-and-shift approach worked cleanly: create factory → migrate one consumer at a time → verify green after each step.
21
+ Each migration commit was small and isolated, making failures easy to diagnose.
22
+ - Structural typing as a strategy proved out — `createToolDeps()` returns `AgentToolDeps` (the superset), and `spawnBackground(deps, ...)` and `runForeground(deps, ...)` accept their narrow `BackgroundDeps`/`ForegroundDeps` interfaces without any casting.
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ - `missing-context` — Plan used `registry.resolve("general-purpose", "/dir")` in the `make-deps.test.ts` test, but `AgentTypeRegistry` has no `resolve` method — the correct method is `resolveAgentConfig()`.
27
+ Impact: one test failure during step 5 red→green, fixed immediately with no rework.
28
+
29
+ - `missing-context` — Default values differed between the old narrow factories and the new shared factory: `"bg-1"` vs `"agent-1"` for spawn IDs (`background-spawner.test.ts`), `"Task done."` vs `"All done."` for result text (`foreground-runner.test.ts`).
30
+ Impact: two test failures in step 7, one in step 8, each requiring assertion updates before the migration step could pass.
31
+
32
+ - `missing-context` — `MockSession` interface used `ReturnType<typeof vi.fn>` which expands to `Mock<Procedure | Constructable>` in Vitest v4 — a union type TypeScript cannot call.
33
+ Impact: `pnpm run check` failed after all TDD steps were done, requiring a separate `style:` commit to switch to explicitly parameterized `Mock<() => void>` etc.
34
+
35
+ - `missing-context` — Removed the `AgentToolDeps` import from `agent-tool.test.ts` without checking that the `execute()` helper still referenced it.
36
+ Impact: caught in the same `pnpm run check` pass, fixed in the same `style:` commit.
37
+
38
+ #### What caused friction (user side)
39
+
40
+ - No user-side friction observed.
41
+ The plan was unambiguous, and the session ran autonomously through all 8 TDD steps plus post-checks without intervention.
42
+
43
+ ### Changes made
44
+
45
+ 1. `.pi/skills/testing/SKILL.md` — added TDD planning rule for diffing default values when consolidating duplicate test factories.
46
+ 2. `.pi/skills/testing/SKILL.md` — added Vitest mock pattern rule for typing mock fields with `Mock<specific-signature>` instead of `ReturnType<typeof vi.fn>`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.9.3",
3
+ "version": "6.10.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -15,9 +15,12 @@ import {
15
15
  import type { AgentConfigLookup } from "./agent-types.js";
16
16
  import { extractText } from "./context.js";
17
17
  import { detectEnv } from "./env.js";
18
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
18
19
  import type { ParentSnapshot } from "./parent-snapshot.js";
19
- import { assembleSessionConfig } from "./session-config.js";
20
+ import { buildAgentPrompt } from "./prompts.js";
21
+ import { type AssemblerIO, assembleSessionConfig } from "./session-config.js";
20
22
  import { deriveSubagentSessionDir } from "./session-dir.js";
23
+ import { preloadSkills } from "./skill-loader.js";
21
24
  import type { ShellExec, SubagentType, ThinkingLevel } from "./types.js";
22
25
 
23
26
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -178,6 +181,12 @@ export async function runAgent(
178
181
  const env = await detectEnv(options.exec, effectiveCwd);
179
182
 
180
183
  // Assemble session configuration (synchronous, no SDK objects).
184
+ const io: AssemblerIO = {
185
+ preloadSkills,
186
+ buildMemoryBlock,
187
+ buildReadOnlyMemoryBlock,
188
+ buildAgentPrompt,
189
+ };
181
190
  const cfg = assembleSessionConfig(
182
191
  type,
183
192
  {
@@ -194,6 +203,7 @@ export async function runAgent(
194
203
  },
195
204
  env,
196
205
  options.registry,
206
+ io,
197
207
  );
198
208
 
199
209
  const agentDir = getAgentDir();
@@ -16,13 +16,42 @@ import {
16
16
  getReadOnlyMemoryToolNames,
17
17
  } from "./agent-types.js";
18
18
  import type { EnvInfo } from "./env.js";
19
- import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
20
- import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
21
- import { preloadSkills } from "./skill-loader.js";
22
- import type { SubagentType, ThinkingLevel } from "./types.js";
19
+ import type { PromptExtras } from "./prompts.js";
20
+ import type { PreloadedSkill } from "./skill-loader.js";
21
+ import type {
22
+ AgentPromptConfig,
23
+ MemoryScope,
24
+ SubagentType,
25
+ ThinkingLevel,
26
+ } from "./types.js";
23
27
 
24
28
  // ── Public interfaces ────────────────────────────────────────────────────────
25
29
 
30
+ /**
31
+ * IO collaborators injected into `assembleSessionConfig`.
32
+ *
33
+ * Bundling the four IO-touching (or promptly testable) functions into a single
34
+ * interface keeps the assembler free of direct module imports and makes it
35
+ * trivially testable without `vi.mock()` — callers inject real implementations
36
+ * at the edge (`agent-runner.ts`) or stubs in tests.
37
+ */
38
+ export interface AssemblerIO {
39
+ preloadSkills: (skills: string[], cwd: string) => PreloadedSkill[];
40
+ buildMemoryBlock: (name: string, scope: MemoryScope, cwd: string) => string;
41
+ buildReadOnlyMemoryBlock: (
42
+ name: string,
43
+ scope: MemoryScope,
44
+ cwd: string,
45
+ ) => string;
46
+ buildAgentPrompt: (
47
+ config: AgentPromptConfig,
48
+ cwd: string,
49
+ env: EnvInfo,
50
+ parentPrompt?: string,
51
+ extras?: PromptExtras,
52
+ ) => string;
53
+ }
54
+
26
55
  /**
27
56
  * Narrow context the assembler reads from the parent session.
28
57
  * Tests construct plain objects satisfying this interface — no SDK mocking needed.
@@ -132,8 +161,8 @@ function resolveDefaultModel(
132
161
  /**
133
162
  * Assemble all configuration needed to create an agent session.
134
163
  *
135
- * Synchronous and side-effect-free (beyond calling `preloadSkills` which reads
136
- * the filesystem). The caller is responsible for resolving `EnvInfo` beforehand
164
+ * Synchronous and side-effect-free all IO is delegated through the `io`
165
+ * parameter. The caller is responsible for resolving `EnvInfo` beforehand
137
166
  * via `detectEnv()`.
138
167
  *
139
168
  * @param type The subagent type name (case-insensitive registry lookup).
@@ -141,6 +170,7 @@ function resolveDefaultModel(
141
170
  * @param options Per-call overrides (cwd, isolated, model, thinkingLevel).
142
171
  * @param env Pre-resolved environment info from `detectEnv()`.
143
172
  * @param registry Agent config lookup — provides resolveAgentConfig and getToolNamesForType.
173
+ * @param io IO collaborators (skill loader, memory builder, prompt builder).
144
174
  */
145
175
  export function assembleSessionConfig(
146
176
  type: SubagentType,
@@ -148,6 +178,7 @@ export function assembleSessionConfig(
148
178
  options: AssemblerOptions,
149
179
  env: EnvInfo,
150
180
  registry: AgentConfigLookup,
181
+ io: AssemblerIO,
151
182
  ): SessionConfig {
152
183
  const agentConfig = registry.resolveAgentConfig(type);
153
184
 
@@ -162,7 +193,7 @@ export function assembleSessionConfig(
162
193
 
163
194
  // Skill preloading: when skills is string[], preload their content into the prompt
164
195
  if (Array.isArray(skills)) {
165
- const loaded = preloadSkills(skills, effectiveCwd);
196
+ const loaded = io.preloadSkills(skills, effectiveCwd);
166
197
  if (loaded.length > 0) {
167
198
  extras.skillBlocks = loaded;
168
199
  }
@@ -185,7 +216,7 @@ export function assembleSessionConfig(
185
216
  if (hasWriteTools) {
186
217
  const extraNames = getMemoryToolNames(existingNames);
187
218
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
188
- extras.memoryBlock = buildMemoryBlock(
219
+ extras.memoryBlock = io.buildMemoryBlock(
189
220
  agentConfig.name,
190
221
  agentConfig.memory,
191
222
  effectiveCwd,
@@ -193,7 +224,7 @@ export function assembleSessionConfig(
193
224
  } else {
194
225
  const extraNames = getReadOnlyMemoryToolNames(existingNames);
195
226
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
196
- extras.memoryBlock = buildReadOnlyMemoryBlock(
227
+ extras.memoryBlock = io.buildReadOnlyMemoryBlock(
197
228
  agentConfig.name,
198
229
  agentConfig.memory,
199
230
  effectiveCwd,
@@ -202,7 +233,7 @@ export function assembleSessionConfig(
202
233
  }
203
234
 
204
235
  // Build system prompt from the resolved agent config
205
- const systemPrompt = buildAgentPrompt(
236
+ const systemPrompt = io.buildAgentPrompt(
206
237
  agentConfig,
207
238
  effectiveCwd,
208
239
  env,