@gotgenes/pi-subagents 6.10.0 → 6.11.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,20 @@ 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.11.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.10.0...pi-subagents-v6.11.0) (2026-05-22)
9
+
10
+
11
+ ### Features
12
+
13
+ * inject SDK boundary into agent-runner via RunnerIO ([#133](https://github.com/gotgenes/pi-packages/issues/133)) ([a9f6a9e](https://github.com/gotgenes/pi-packages/commit/a9f6a9e8c71e307b71600409e865fb539312f539))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan SDK boundary injection into agent-runner ([#133](https://github.com/gotgenes/pi-packages/issues/133)) ([1706ebc](https://github.com/gotgenes/pi-packages/commit/1706ebcc1452c6798dafb733ec8c68e6ee9e8512))
19
+ * **retro:** add retro notes for issue [#132](https://github.com/gotgenes/pi-packages/issues/132) ([d0af140](https://github.com/gotgenes/pi-packages/commit/d0af1409ddc18099dfdda94ab37af2b99bc46c3c))
20
+ * update architecture doc for Step H completion ([#133](https://github.com/gotgenes/pi-packages/issues/133)) ([f6b1258](https://github.com/gotgenes/pi-packages/commit/f6b1258f50a038df18ca1f33e3681c7bc258f4fc))
21
+
8
22
  ## [6.10.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.9.4...pi-subagents-v6.10.0) (2026-05-22)
9
23
 
10
24
 
@@ -505,9 +505,8 @@ E2 (Type housekeeping) ── can start after A1, runs parallel to later steps
505
505
  Phase 7 eliminated all structural smells (mutable state, closure bags, callback threading, wide dependency bags).
506
506
  Phase 8 targets the next layer: testability friction, display module cohesion, and menu decomposition.
507
507
 
508
- The test suite (690 tests, 1.4:1 test-to-code ratio) is comprehensive but uneven in quality.
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.)
508
+ The test suite (714 tests) is comprehensive but uneven in quality.
509
+ Steps G and H have eliminated 11 of the original 12 `vi.mock()` calls in the runner tests, removing fragile call-sequence assertions in favour of injected stubs. (Step G resolved `session-config.test.ts`; Step H resolved both `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts`.)
511
510
 
512
511
  The display and menu improvements were identified during Phase 7 but deferred because they don't gate encapsulation work.
513
512
  They are included here because the display extraction unblocks menu decomposition.
@@ -516,8 +515,8 @@ They are included here because the display extraction unblocks menu decompositio
516
515
 
517
516
  | Symptom | Location | Root cause |
518
517
  | ----------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
519
- | 7 `vi.mock()` calls | `agent-runner.test.ts` | Runner imports prompts, memory, skills, env, session-dir directly |
520
- | 7 `vi.mock()` calls | `agent-runner.test.ts` | Runner imports prompts, memory, skills, env, session-dir directly |
518
+ | ~~7 `vi.mock()` calls~~ | ~~`agent-runner.test.ts`~~ | ~~Resolved by Step H (#133)~~ |
519
+ | ~~7 `vi.mock()` calls~~ | ~~`agent-runner-extension-tools.test.ts`~~ | ~~Resolved by Step H (#133)~~ |
521
520
  | 52 `as any` casts | Across test suite | SDK session/context interfaces too wide to construct in tests |
522
521
  | 3× duplicated `mockSession()` | agent-manager, record-observer, ui-observer tests | No shared test fixture |
523
522
  | 3× duplicated `makeDeps()` | agent-tool, background-spawner, foreground-runner tests | No shared tool-deps fixture |
@@ -538,30 +537,17 @@ Impact: reduces test boilerplate; single source of truth for mock shapes; change
538
537
  ### Step G: Inject IO collaborators into session-config (#132) ✓ done
539
538
 
540
539
  `assembleSessionConfig` now accepts `io: AssemblerIO` as a required parameter.
541
- `agent-runner.ts` constructs the real `AssemblerIO` from direct imports and passes it through.
540
+ `index.ts` constructs the real `AssemblerIO` from direct imports via the `RunnerIO.assemblerIO` field (wired in Step H).
542
541
  `session-config.test.ts` injects stubs — all 4 `vi.mock()` calls eliminated, assertions shifted to `SessionConfig` output properties.
543
542
 
544
- ### Step H: Inject SDK boundary into agent-runner (#133)
543
+ ### Step H: Inject SDK boundary into agent-runner (#133) ✓ done
545
544
 
546
- `agent-runner.ts` has 7 module mocks because it imports `createAgentSession`, `DefaultResourceLoader`, `SessionManager`, and `SettingsManager` from the Pi SDK, plus `detectEnv`, `deriveSubagentSessionDir`, and `assembleSessionConfig` from sibling modules.
545
+ `runAgent()` now accepts `io: RunnerIO` as a required parameter bundling all IO collaborators: `detectEnv`, `getAgentDir`, `createResourceLoader`, `deriveSessionDir`, `createSessionManager`, `createSettingsManager`, `createSession`, and `assemblerIO`.
547
546
 
548
- After Step G, `assembleSessionConfig` no longer needs mocking (its own IO is injected).
549
- The remaining SDK dependencies can be injected via a narrow `RunnerIO` interface:
547
+ `createAgentRunner(io: RunnerIO): AgentRunner` factory captures the boundary at construction time so `AgentManager` and the `AgentRunner` interface remain unchanged.
548
+ `index.ts` constructs the real `RunnerIO` from Pi SDK imports and sibling modules.
550
549
 
551
- ```typescript
552
- export interface RunnerIO {
553
- createSession: (opts: SessionOptions) => AgentSession;
554
- createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoader;
555
- createSessionManager: (cwd: string) => SessionManager;
556
- detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
557
- deriveSessionDir: (parentFile: string) => string;
558
- }
559
- ```
560
-
561
- The production call site in `agent-manager.ts` passes a `RunnerIO` built from the real SDK imports.
562
- Tests pass a stub `RunnerIO` without `vi.mock()`.
563
-
564
- Impact: eliminates 5–7 `vi.mock()` calls in `agent-runner.test.ts`; tests verify behavior (turn limits, tool filtering, response collection) through injected fakes; refactoring internal structure no longer breaks tests.
550
+ Impact: all 7 `vi.mock()` calls eliminated from both `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts`; tests verify behavior (turn limits, tool filtering, response collection) through injected stubs; SDK imports moved to the extension entry point.
565
551
 
566
552
  ### Step I: Reduce `as any` casts in tests (#134)
567
553
 
@@ -0,0 +1,373 @@
1
+ ---
2
+ issue: 133
3
+ issue_title: "Inject SDK boundary into `agent-runner`"
4
+ ---
5
+
6
+ # Inject SDK boundary into agent-runner
7
+
8
+ ## Problem Statement
9
+
10
+ `agent-runner.ts` directly imports five Pi SDK symbols (`createAgentSession`, `DefaultResourceLoader`, `getAgentDir`, `SessionManager`, `SettingsManager`) and two sibling modules (`detectEnv`, `deriveSubagentSessionDir`).
11
+ It also imports four functions (`preloadSkills`, `buildMemoryBlock`, `buildReadOnlyMemoryBlock`, `buildAgentPrompt`) solely to construct the `AssemblerIO` object introduced in #132.
12
+ This forces `agent-runner.test.ts` to use 7 `vi.mock()` calls, a `vi.hoisted()` block with 5+ mock factories, and a `beforeEach` that manually resets 6+ mocks.
13
+ Tests verify internal call patterns ("defaultResourceLoaderCtor was called with `noContextFiles: true`") rather than behavioral outcomes, making any internal restructuring break multiple tests without changing observable behavior.
14
+ The same 7-mock pattern is duplicated in `agent-runner-extension-tools.test.ts`.
15
+
16
+ ## Goals
17
+
18
+ - Define a `RunnerIO` interface bundling all SDK and IO collaborators used by `runAgent()`.
19
+ - Add `io: RunnerIO` as a parameter to `runAgent()`.
20
+ - Provide a `createAgentRunner(io: RunnerIO): AgentRunner` factory so the `AgentRunner` interface and `AgentManager` remain unchanged.
21
+ - Replace direct SDK and sibling-module imports in `runAgent()` with calls through `io`.
22
+ - Update the wiring in `index.ts` to construct a real `RunnerIO` and use `createAgentRunner()`.
23
+ - Eliminate all 7 `vi.mock()` calls in `agent-runner.test.ts`.
24
+ - Eliminate all 7 `vi.mock()` calls in `agent-runner-extension-tools.test.ts`.
25
+ - Shift test assertions toward behavioral outcomes (turn limits enforced, tool filtering correct, response text collected).
26
+
27
+ ## Non-Goals
28
+
29
+ - Changing `resumeAgent` — it receives an already-created `AgentSession` and has no SDK/IO deps to inject.
30
+ - Injecting `assembleSessionConfig` itself — the function is pure (after #132) and stays as a direct import; only its `AssemblerIO` collaborators move into `RunnerIO`.
31
+ - Injecting `getMemoryToolNames` / `getReadOnlyMemoryToolNames` — these are pure utility functions with no IO; they remain as direct imports in `session-config.ts`.
32
+ - Refactoring `filterActiveTools` or the turn-limit logic — out of scope.
33
+ - Consolidating shared test fixtures (#131) — independent work.
34
+
35
+ ## Background
36
+
37
+ ### Prerequisite
38
+
39
+ Issue #132 (inject IO into session-config) is closed.
40
+ `assembleSessionConfig` now receives an `AssemblerIO` parameter and no longer imports IO functions directly.
41
+ However, `agent-runner.ts` still imports those four functions to construct the `AssemblerIO` object, and the SDK factories remain as direct imports.
42
+
43
+ ### Current vi.mock inventory in agent-runner.test.ts
44
+
45
+ | # | Module | Symbols mocked | Why mocked |
46
+ | --- | --------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------- |
47
+ | 1 | `@earendil-works/pi-coding-agent` | `createAgentSession`, `DefaultResourceLoader`, `getAgentDir`, `SessionManager`, `SettingsManager` | SDK constructors and factories |
48
+ | 2 | `../src/agent-types.js` | `getMemoryToolNames`, `getReadOnlyMemoryToolNames` | Pure functions used by session-config |
49
+ | 3 | `../src/env.js` | `detectEnv` | Async IO (shell exec) |
50
+ | 4 | `../src/prompts.js` | `buildAgentPrompt` | Relayed to AssemblerIO |
51
+ | 5 | `../src/memory.js` | `buildMemoryBlock`, `buildReadOnlyMemoryBlock` | Relayed to AssemblerIO |
52
+ | 6 | `../src/skill-loader.js` | `preloadSkills` | Relayed to AssemblerIO |
53
+ | 7 | `../src/session-dir.js` | `deriveSubagentSessionDir` | Path derivation |
54
+
55
+ `agent-runner-extension-tools.test.ts` has an identical set.
56
+
57
+ ### Established DI patterns
58
+
59
+ - `AgentManager` already receives `AgentRunner` via constructor injection — the same boundary this issue pushes down one layer.
60
+ - `AssemblerIO` (#132) bundles four IO collaborators into a single injectable interface.
61
+ - `AgentManagerLike` in `service-adapter.ts` defines a narrow interface for the concrete `AgentManager` class, avoiding coupling to the concrete type.
62
+
63
+ ### Architecture reference
64
+
65
+ Phase 8, Step H in `docs/architecture/architecture.md`.
66
+
67
+ ### Constraints from AGENTS.md
68
+
69
+ - Keep scope tight; prefer small, reversible changes.
70
+ - Prefer explicit configuration over hidden behavior.
71
+ - Business logic should be pure functions — keep IO at the edges.
72
+ - Keep Pi SDK imports out of business-logic modules.
73
+
74
+ ## Design Overview
75
+
76
+ ### `RunnerIO` interface
77
+
78
+ Defined in `agent-runner.ts` alongside the existing runner types.
79
+ Bundles all IO dependencies that `runAgent()` uses:
80
+
81
+ ```typescript
82
+ /** Minimal resource-loader contract used by the runner. */
83
+ export interface ResourceLoaderLike {
84
+ reload(): Promise<void>;
85
+ }
86
+
87
+ /** Minimal session-manager contract used by the runner. */
88
+ export interface SessionManagerLike {
89
+ newSession(opts: { parentSession?: string }): void;
90
+ getSessionFile(): string | undefined;
91
+ }
92
+
93
+ /** Options passed to RunnerIO.createResourceLoader. */
94
+ export interface ResourceLoaderOptions {
95
+ cwd: string;
96
+ agentDir: string;
97
+ noExtensions?: boolean;
98
+ noSkills?: boolean;
99
+ noPromptTemplates?: boolean;
100
+ noThemes?: boolean;
101
+ noContextFiles?: boolean;
102
+ systemPromptOverride?: () => string;
103
+ appendSystemPromptOverride?: () => unknown[];
104
+ }
105
+
106
+ /** Options passed to RunnerIO.createSession. */
107
+ export interface CreateSessionOptions {
108
+ cwd: string;
109
+ agentDir: string;
110
+ sessionManager: SessionManagerLike;
111
+ settingsManager: unknown;
112
+ modelRegistry: unknown;
113
+ model?: unknown;
114
+ tools: string[];
115
+ resourceLoader: ResourceLoaderLike;
116
+ thinkingLevel?: ThinkingLevel;
117
+ }
118
+
119
+ /**
120
+ * IO boundary injected into runAgent().
121
+ *
122
+ * Decouples the runner from direct Pi SDK imports and sibling-module IO,
123
+ * making it testable via plain stub objects without vi.mock().
124
+ */
125
+ export interface RunnerIO {
126
+ detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
127
+ getAgentDir: () => string;
128
+ createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
129
+ deriveSessionDir: (
130
+ parentSessionFile: string | undefined,
131
+ effectiveCwd: string,
132
+ ) => string;
133
+ createSessionManager: (
134
+ cwd: string,
135
+ sessionDir: string,
136
+ ) => SessionManagerLike;
137
+ createSettingsManager: (cwd: string, agentDir: string) => unknown;
138
+ createSession: (
139
+ opts: CreateSessionOptions,
140
+ ) => Promise<{ session: AgentSession }>;
141
+ assemblerIO: AssemblerIO;
142
+ }
143
+ ```
144
+
145
+ The interface has 8 fields (7 functions + 1 nested `AssemblerIO`).
146
+ All 8 are consumed by `runAgent()` — no field is relayed without use.
147
+
148
+ ### `createAgentRunner` factory
149
+
150
+ ```typescript
151
+ export function createAgentRunner(io: RunnerIO): AgentRunner {
152
+ return {
153
+ run: (snapshot, type, prompt, options) =>
154
+ runAgent(snapshot, type, prompt, options, io),
155
+ resume: resumeAgent,
156
+ };
157
+ }
158
+ ```
159
+
160
+ This keeps the `AgentRunner` interface unchanged.
161
+ `AgentManager` continues to receive an `AgentRunner` — it never sees `RunnerIO`.
162
+
163
+ ### Call site in `index.ts`
164
+
165
+ ```typescript
166
+ import {
167
+ createAgentSession,
168
+ DefaultResourceLoader,
169
+ getAgentDir,
170
+ SessionManager,
171
+ SettingsManager,
172
+ } from "@earendil-works/pi-coding-agent";
173
+ import { detectEnv } from "./env.js";
174
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
175
+ import { buildAgentPrompt } from "./prompts.js";
176
+ import { deriveSubagentSessionDir } from "./session-dir.js";
177
+ import { preloadSkills } from "./skill-loader.js";
178
+
179
+ const runnerIO: RunnerIO = {
180
+ detectEnv,
181
+ getAgentDir,
182
+ createResourceLoader: (opts) => new DefaultResourceLoader(opts),
183
+ deriveSessionDir: deriveSubagentSessionDir,
184
+ createSessionManager: (cwd, dir) => SessionManager.create(cwd, dir),
185
+ createSettingsManager: (cwd, dir) => SettingsManager.create(cwd, dir),
186
+ createSession: createAgentSession,
187
+ assemblerIO: {
188
+ preloadSkills,
189
+ buildMemoryBlock,
190
+ buildReadOnlyMemoryBlock,
191
+ buildAgentPrompt,
192
+ },
193
+ };
194
+
195
+ const manager = new AgentManager({
196
+ runner: createAgentRunner(runnerIO),
197
+ // ... rest unchanged
198
+ });
199
+ ```
200
+
201
+ SDK and IO imports move from `agent-runner.ts` to `index.ts` — the extension entry point, which is the natural IO edge.
202
+
203
+ ### Test-side stubs
204
+
205
+ Tests create a plain `RunnerIO` object with `vi.fn()` stubs:
206
+
207
+ ```typescript
208
+ function createRunnerIO(): RunnerIO {
209
+ return {
210
+ detectEnv: vi.fn(async () => ({
211
+ isGitRepo: false,
212
+ branch: "",
213
+ platform: "linux",
214
+ })),
215
+ getAgentDir: vi.fn(() => "/mock/agent-dir"),
216
+ createResourceLoader: vi.fn(() => ({ reload: vi.fn() })),
217
+ deriveSessionDir: vi.fn(() => "/mock/session-dir/tasks"),
218
+ createSessionManager: vi.fn(() => ({
219
+ newSession: vi.fn(),
220
+ getSessionFile: vi.fn(() => "/sessions/child.jsonl"),
221
+ })),
222
+ createSettingsManager: vi.fn(() => ({ kind: "settings-manager" })),
223
+ createSession: vi.fn(),
224
+ assemblerIO: {
225
+ preloadSkills: vi.fn(() => []),
226
+ buildMemoryBlock: vi.fn(() => ""),
227
+ buildReadOnlyMemoryBlock: vi.fn(() => ""),
228
+ buildAgentPrompt: vi.fn(() => "system prompt"),
229
+ },
230
+ };
231
+ }
232
+ ```
233
+
234
+ This replaces all 7 `vi.mock()` calls, the `vi.hoisted()` block, and most of the `beforeEach` resets.
235
+ Each test calls `runAgent(snapshot, type, prompt, options, io)` directly with a stub `io`.
236
+
237
+ ### Interaction verification — consumer call site (Tell-Don't-Ask check)
238
+
239
+ ```typescript
240
+ // In index.ts — the consumer constructs RunnerIO and hands it off:
241
+ const runnerIO: RunnerIO = { detectEnv, getAgentDir, ... };
242
+ const manager = new AgentManager({
243
+ runner: createAgentRunner(runnerIO),
244
+ });
245
+ // AgentManager calls runner.run(...) — never reaches through to runnerIO.
246
+ // Tell-Don't-Ask: ✓ Manager tells runner to run; runner uses its own IO.
247
+ ```
248
+
249
+ ### Pure functions stay as direct imports
250
+
251
+ `assembleSessionConfig` (pure after #132), `filterActiveTools` (module-private), `normalizeMaxTurns` (pure exported), `collectResponseText`, `getLastAssistantText`, and `forwardAbortSignal` remain as direct code — they have no IO dependencies.
252
+
253
+ `getMemoryToolNames` / `getReadOnlyMemoryToolNames` in `session-config.ts` remain as direct imports (pure, no IO).
254
+ The `vi.mock("../src/agent-types.js", ...)` in both test files can be removed because the mock agent config has no `memory` field, so the memory branch in `assembleSessionConfig` is never entered and those functions are never called.
255
+
256
+ ## Module-Level Changes
257
+
258
+ ### Modified files
259
+
260
+ 1. `src/agent-runner.ts`
261
+ - Add `RunnerIO`, `ResourceLoaderLike`, `SessionManagerLike`, `ResourceLoaderOptions`, `CreateSessionOptions` interface exports.
262
+ - Add `createAgentRunner(io: RunnerIO): AgentRunner` factory export.
263
+ - Add `io: RunnerIO` parameter to `runAgent()`.
264
+ - Replace `detectEnv(...)` with `io.detectEnv(...)`.
265
+ - Replace `getAgentDir()` with `io.getAgentDir()`.
266
+ - Replace `new DefaultResourceLoader(...)` with `io.createResourceLoader(...)`.
267
+ - Replace `deriveSubagentSessionDir(...)` with `io.deriveSessionDir(...)`.
268
+ - Replace `SessionManager.create(...)` with `io.createSessionManager(...)`.
269
+ - Replace `SettingsManager.create(...)` with `io.createSettingsManager(...)`.
270
+ - Replace `createAgentSession(...)` with `io.createSession(...)`.
271
+ - Replace inline `AssemblerIO` construction with `io.assemblerIO`.
272
+ - Remove imports: `createAgentSession`, `DefaultResourceLoader`, `getAgentDir`, `SessionManager`, `SettingsManager` from `@earendil-works/pi-coding-agent`; `detectEnv` from `./env.js`; `deriveSubagentSessionDir` from `./session-dir.js`; `preloadSkills` from `./skill-loader.js`; `buildMemoryBlock`, `buildReadOnlyMemoryBlock` from `./memory.js`; `buildAgentPrompt` from `./prompts.js`.
273
+ - Keep imports: `type AgentSession`, `type AgentSessionEvent` from SDK (used in function signatures and event handling); `type AssemblerIO` from `./session-config.js`; `assembleSessionConfig` from `./session-config.js`; `extractText` from `./context.js`.
274
+
275
+ 2. `src/index.ts`
276
+ - Add imports: `detectEnv` from `./env.js`; `deriveSubagentSessionDir` from `./session-dir.js`; `preloadSkills` from `./skill-loader.js`; `buildMemoryBlock`, `buildReadOnlyMemoryBlock` from `./memory.js`; `buildAgentPrompt` from `./prompts.js`.
277
+ - Add import: `createAgentRunner`, `type RunnerIO` from `./agent-runner.js`.
278
+ - Remove import: `runAgent` from `./agent-runner.js` (replaced by factory).
279
+ - Construct `runnerIO` object from real implementations.
280
+ - Replace `runner: { run: runAgent, resume: resumeAgent }` with `runner: createAgentRunner(runnerIO)`.
281
+
282
+ 3. `test/agent-runner.test.ts`
283
+ - Remove all 7 `vi.mock()` calls and the `vi.hoisted()` block.
284
+ - Add `createRunnerIO()` factory function returning a stub `RunnerIO`.
285
+ - Pass `io` to all `runAgent()` calls.
286
+ - Simplify `beforeEach` to reset `io.createSession` (the only mock that needs per-test setup).
287
+ - Remove `mockAgentLookup.resolveAgentConfig` and `mockAgentLookup.getToolNamesForType` resets that are now unnecessary.
288
+ - Update assertions that verify SDK constructor arguments (e.g., `defaultResourceLoaderCtor` calls) to verify `io.createResourceLoader` calls instead.
289
+ - Remove the `agent-types.js` mock — pure functions run against controlled inputs.
290
+
291
+ 4. `test/agent-runner-extension-tools.test.ts`
292
+ - Same structural changes as `agent-runner.test.ts`: remove all 7 `vi.mock()` calls, inject `RunnerIO` stubs.
293
+ - Keep the `createSessionWithExtensionToolRegistration` helper — it creates mock sessions for testing post-bind tool filtering, which is behavioral.
294
+ - Update assertions to use `io.createResourceLoader` / `io.createSession` stubs.
295
+
296
+ ### Unchanged files
297
+
298
+ - `src/agent-manager.ts` — receives `AgentRunner` via injection; unaffected by `RunnerIO`.
299
+ - `test/agent-manager.test.ts` — already injects a mock `AgentRunner`; unaffected.
300
+ - `src/session-config.ts` — pure function, already receives `AssemblerIO`; unaffected.
301
+ - `test/session-config.test.ts` — tests the pure assembler directly; unaffected.
302
+ - `test/agent-runner-settings.test.ts` — tests `normalizeMaxTurns` (pure, no mocks); unaffected.
303
+ - `test/print-mode.test.ts` — mocks `runAgent` itself at the module level; unaffected (it tests `index.ts` notification wiring, not the runner internals).
304
+
305
+ ## Test Impact Analysis
306
+
307
+ 1. The `RunnerIO` injection enables testing `runAgent` without any module mocking.
308
+ Tests create plain stub objects satisfying `RunnerIO` — no `vi.mock()`, no `vi.hoisted()`, no module-level mock variable management.
309
+ This was previously impossible because `runAgent` hard-imported SDK constructors.
310
+
311
+ 2. Several existing tests that verify mock constructor arguments become redundant or shift to verifying `io.*` stub calls:
312
+ - "passes effective cwd and agentDir to the loader and settings manager" → verifies `io.createResourceLoader` and `io.createSettingsManager` were called with expected args (simpler, no `defaultResourceLoaderCtor` indirection).
313
+ - "suppresses AGENTS.md/CLAUDE.md/APPEND_SYSTEM.md for subagents" → verifies `io.createResourceLoader` was called with `noContextFiles: true` and an `appendSystemPromptOverride` that returns `[]`.
314
+
315
+ 3. Tests for turn-limit enforcement, abort forwarding, and response-text collection stay as-is — they already test behavioral outcomes through the mock session, not through SDK mock call patterns.
316
+
317
+ 4. The extension-tools tests (Patch 2) remain behavioral — they verify `setActiveToolsByName` calls before/after `bindExtensions`.
318
+ The only change is how the session is created (via `io.createSession` stub instead of a module mock).
319
+
320
+ 5. The `agent-types.js` mock can be removed from both test files because the mock agent configs have no `memory` field, so the code path through `getMemoryToolNames` / `getReadOnlyMemoryToolNames` is never reached.
321
+
322
+ ## TDD Order
323
+
324
+ 1. **Define `RunnerIO` and `createAgentRunner`; inject IO into `runAgent`.**
325
+ Add the `RunnerIO`, `ResourceLoaderLike`, `SessionManagerLike`, `ResourceLoaderOptions`, and `CreateSessionOptions` interfaces to `agent-runner.ts`.
326
+ Add `io: RunnerIO` parameter to `runAgent()`.
327
+ Add `createAgentRunner(io)` factory export.
328
+ Replace all direct SDK and IO imports with `io.*` calls inside `runAgent()`.
329
+ Remove the now-unused direct imports.
330
+ Update `index.ts` to construct `runnerIO` from real implementations and use `createAgentRunner(runnerIO)`.
331
+ Run `pnpm run check` to verify types compile.
332
+ Commit: `feat: inject SDK boundary into agent-runner via RunnerIO (#133)`
333
+
334
+ 2. **Migrate `agent-runner.test.ts` to use injected `RunnerIO` stubs.**
335
+ Add `createRunnerIO()` helper returning a fully-stubbed `RunnerIO`.
336
+ Pass `io` to all `runAgent()` calls.
337
+ Remove all 7 `vi.mock()` calls and the `vi.hoisted()` block.
338
+ Simplify `beforeEach` to reset only `io.createSession`.
339
+ Update assertions that referenced hoisted mocks (e.g., `defaultResourceLoaderCtor`, `sessionManagerCreate`, `settingsManagerCreate`, `getAgentDir`) to reference `io.*` stubs.
340
+ Remove the `mockAgentLookup` mock resets that are now unnecessary.
341
+ All existing tests pass with equivalent assertions.
342
+ Commit: `test: replace vi.mock with RunnerIO stubs in agent-runner tests (#133)`
343
+
344
+ 3. **Migrate `agent-runner-extension-tools.test.ts` to use injected `RunnerIO` stubs.**
345
+ Same structural changes as step 2: remove all 7 `vi.mock()` calls, inject `RunnerIO` stubs.
346
+ Keep `createSessionWithExtensionToolRegistration` helper (tests tool filtering behavior).
347
+ Simplify `beforeEach` and update stub references.
348
+ Commit: `test: replace vi.mock with RunnerIO stubs in extension-tools tests (#133)`
349
+
350
+ 4. **Shift constructor-argument assertions to behavioral checks.**
351
+ In `agent-runner.test.ts`, update tests that verify internal SDK call arguments:
352
+ - Replace `expect(defaultResourceLoaderCtor).toHaveBeenCalledWith(expect.objectContaining({...}))` with `expect(io.createResourceLoader).toHaveBeenCalledWith(expect.objectContaining({...}))`.
353
+ - Where the assertion only verified plumbing (e.g., "settings manager gets the right cwd"), simplify to a behavioral assertion or remove if covered by other tests.
354
+ - Keep assertions that verify meaningful configuration decisions (e.g., `noContextFiles: true`, `appendSystemPromptOverride` returns `[]`).
355
+ Run full test suite.
356
+ Commit: `test: shift agent-runner assertions toward behavioral checks (#133)`
357
+
358
+ ## Risks and Mitigations
359
+
360
+ | Risk | Mitigation |
361
+ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
362
+ | `RunnerIO` at 8 fields may seem wide | All 8 are consumed by the single consumer (`runAgent`). No field is relayed without use. The interface represents a genuine IO boundary — further narrowing would require splitting `runAgent` itself (out of scope). |
363
+ | Removing the `agent-types.js` mock could cause failures if a test unexpectedly enters the memory branch | The mock agent config has no `memory` field (`undefined`), so the memory branch is guarded by `if (agentConfig.memory)`. Verified by reading the test's `resolveAgentConfig` mock return value. |
364
+ | `index.ts` accumulates many new imports | The imports move from `agent-runner.ts` to `index.ts` — the extension entry point is the natural IO edge. The total import count across the two files is unchanged. |
365
+ | `createAgentRunner` factory adds indirection | The factory is a one-liner that captures `io` in a closure. The `AgentRunner` interface and `AgentManager` are completely unchanged. No new abstraction layer — just a construction-time binding. |
366
+ | Steps 2–3 touch many call sites in two test files (add `, io` argument) | All changes are mechanical. Each `runAgent(snapshot, type, prompt, {...})` becomes `runAgent(snapshot, type, prompt, {...}, io)`. A single find-and-replace handles it. |
367
+ | `print-mode.test.ts` mocks `runAgent` at the module level — does the new `io` parameter break it? | `print-mode.test.ts` mocks the entire `runAgent` export with `vi.mock("../src/agent-runner.js", ...)`. The mock replaces the function entirely, so the new parameter has no effect on that test. |
368
+
369
+ ## Open Questions
370
+
371
+ - Should `RunnerIO` live in `agent-runner.ts` or be extracted to a separate types file?
372
+ The interface is tightly coupled to `runAgent()` — co-location follows the `AssemblerIO` precedent in `session-config.ts`.
373
+ Extract only if a second consumer appears.
@@ -0,0 +1,33 @@
1
+ ---
2
+ issue: 132
3
+ issue_title: "Inject IO collaborators into `assembleSessionConfig`"
4
+ ---
5
+
6
+ # Retro: #132 — Inject IO collaborators into `assembleSessionConfig`
7
+
8
+ ## Final Retrospective (2026-05-22T12:25:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Defined an `AssemblerIO` interface bundling four IO/prompt collaborators, injected it into `assembleSessionConfig`, and updated `agent-runner.ts` to pass real implementations.
13
+ Eliminated all 4 `vi.mock()` calls in `session-config.test.ts`, flattened the `vi.hoisted()` block into plain `vi.fn()` declarations, and shifted assertions from mock-call verification to output-property checks.
14
+ Released as `pi-subagents-v6.10.0`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - Perl two-pass replacement (multi-line then single-line) handled 40+ `assembleSessionConfig` call-site updates in one command with zero manual errors.
21
+ - Flattening `vi.hoisted()` into regular `vi.fn()` declarations in step 3 was a clean simplification — hoisting was only needed when the mocks were referenced inside `vi.mock()` factories.
22
+ - Real `getMemoryToolNames` / `getReadOnlyMemoryToolNames` worked as drop-in replacements with no test rework needed — the pure functions' behavior matched what the mocks were configured to return for all existing test scenarios.
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ - `missing-context` — `mockBuildAgentPrompt` was declared as `vi.fn(() => "assembled system prompt")` which inferred `Mock<() => string>`.
27
+ When step 4 used `mockImplementationOnce` with a parameterized function, TypeScript rejected it.
28
+ The testing skill already documents `Mock<specific-signature>` for this exact case.
29
+ Impact: one type-check failure, fixed by adding `Mock<AssemblerIO["buildAgentPrompt"]>` annotation; added friction but no rework.
30
+
31
+ #### What caused friction (user side)
32
+
33
+ - Nothing notable — standard prompt-template workflow with no corrections needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.10.0",
3
+ "version": "6.11.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -6,21 +6,12 @@ import type { Model } from "@earendil-works/pi-ai";
6
6
  import {
7
7
  type AgentSession,
8
8
  type AgentSessionEvent,
9
- createAgentSession,
10
- DefaultResourceLoader,
11
- getAgentDir,
12
- SessionManager,
13
- SettingsManager,
14
9
  } from "@earendil-works/pi-coding-agent";
15
10
  import type { AgentConfigLookup } from "./agent-types.js";
16
11
  import { extractText } from "./context.js";
17
- import { detectEnv } from "./env.js";
18
- import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
12
+ import type { EnvInfo } from "./env.js";
19
13
  import type { ParentSnapshot } from "./parent-snapshot.js";
20
- import { buildAgentPrompt } from "./prompts.js";
21
14
  import { type AssemblerIO, assembleSessionConfig } from "./session-config.js";
22
- import { deriveSubagentSessionDir } from "./session-dir.js";
23
- import { preloadSkills } from "./skill-loader.js";
24
15
  import type { ShellExec, SubagentType, ThinkingLevel } from "./types.js";
25
16
 
26
17
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -68,6 +59,63 @@ export function normalizeMaxTurns(n: number | undefined): number | undefined {
68
59
  return Math.max(1, n);
69
60
  }
70
61
 
62
+ // ── IO boundary ───────────────────────────────────────────────────────────────
63
+
64
+ /** Minimal resource-loader contract used by the runner. */
65
+ export interface ResourceLoaderLike {
66
+ reload(): Promise<void>;
67
+ }
68
+
69
+ /** Minimal session-manager contract used by the runner. */
70
+ export interface SessionManagerLike {
71
+ newSession(opts: { parentSession?: string }): void;
72
+ getSessionFile(): string | undefined;
73
+ }
74
+
75
+ /** Options passed to RunnerIO.createResourceLoader. */
76
+ export interface ResourceLoaderOptions {
77
+ cwd: string;
78
+ agentDir: string;
79
+ noExtensions?: boolean;
80
+ noSkills?: boolean;
81
+ noPromptTemplates?: boolean;
82
+ noThemes?: boolean;
83
+ noContextFiles?: boolean;
84
+ systemPromptOverride?: () => string;
85
+ appendSystemPromptOverride?: () => unknown[];
86
+ }
87
+
88
+ /** Options passed to RunnerIO.createSession. */
89
+ export interface CreateSessionOptions {
90
+ cwd: string;
91
+ agentDir: string;
92
+ sessionManager: SessionManagerLike;
93
+ settingsManager: unknown;
94
+ modelRegistry: unknown;
95
+ model?: unknown;
96
+ tools: string[];
97
+ resourceLoader: ResourceLoaderLike;
98
+ thinkingLevel?: ThinkingLevel;
99
+ }
100
+
101
+ /**
102
+ * IO boundary injected into runAgent().
103
+ *
104
+ * Decouples the runner from direct Pi SDK imports and sibling-module IO,
105
+ * making it testable via plain stub objects without vi.mock().
106
+ */
107
+ export interface RunnerIO {
108
+ detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
109
+ getAgentDir: () => string;
110
+ createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
111
+ deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
112
+ createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
113
+ createSettingsManager: (cwd: string, agentDir: string) => unknown;
114
+ createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
115
+ assemblerIO: AssemblerIO;
116
+ }
117
+
118
+ // ── Public interfaces ─────────────────────────────────────────────────────────
71
119
 
72
120
  export interface RunOptions {
73
121
  /** Shell-exec callback for detectEnv — injected from pi.exec(). */
@@ -125,6 +173,20 @@ export interface AgentRunner {
125
173
  resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string>;
126
174
  }
127
175
 
176
+ /**
177
+ * Create an AgentRunner backed by the given IO boundary.
178
+ *
179
+ * Captures io at construction time so AgentManager remains IO-unaware.
180
+ */
181
+ export function createAgentRunner(io: RunnerIO): AgentRunner {
182
+ return {
183
+ run: (snapshot, type, prompt, options) => runAgent(snapshot, type, prompt, options, io),
184
+ resume: resumeAgent,
185
+ };
186
+ }
187
+
188
+ // ── Private helpers ───────────────────────────────────────────────────────────
189
+
128
190
  /**
129
191
  * Subscribe to a session and collect the last assistant message text.
130
192
  * Returns an object with a `getText()` getter and an `unsubscribe` function.
@@ -170,23 +232,20 @@ function forwardAbortSignal(
170
232
  return () => signal.removeEventListener("abort", onAbort);
171
233
  }
172
234
 
235
+ // ── Public functions ──────────────────────────────────────────────────────────
236
+
173
237
  export async function runAgent(
174
238
  snapshot: ParentSnapshot,
175
239
  type: SubagentType,
176
240
  prompt: string,
177
241
  options: RunOptions,
242
+ io: RunnerIO,
178
243
  ): Promise<RunResult> {
179
244
  // Resolve working directory upfront — needed for detectEnv before assembly.
180
245
  const effectiveCwd = options.cwd ?? snapshot.cwd;
181
- const env = await detectEnv(options.exec, effectiveCwd);
246
+ const env = await io.detectEnv(options.exec, effectiveCwd);
182
247
 
183
248
  // Assemble session configuration (synchronous, no SDK objects).
184
- const io: AssemblerIO = {
185
- preloadSkills,
186
- buildMemoryBlock,
187
- buildReadOnlyMemoryBlock,
188
- buildAgentPrompt,
189
- };
190
249
  const cfg = assembleSessionConfig(
191
250
  type,
192
251
  {
@@ -203,10 +262,10 @@ export async function runAgent(
203
262
  },
204
263
  env,
205
264
  options.registry,
206
- io,
265
+ io.assemblerIO,
207
266
  );
208
267
 
209
- const agentDir = getAgentDir();
268
+ const agentDir = io.getAgentDir();
210
269
 
211
270
  // Load extensions/skills: true or string[] → load; false → don't.
212
271
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
@@ -214,7 +273,7 @@ export async function runAgent(
214
273
  // would defeat prompt_mode: replace and isolated: true. Parent context, if
215
274
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
216
275
  // is embedded in systemPromptOverride) or inherit_context (conversation).
217
- const loader = new DefaultResourceLoader({
276
+ const loader = io.createResourceLoader({
218
277
  cwd: cfg.effectiveCwd,
219
278
  agentDir,
220
279
  noExtensions: cfg.extensions === false,
@@ -230,25 +289,21 @@ export async function runAgent(
230
289
  // Create a persisted SessionManager so transcripts are written in Pi's
231
290
  // official JSONL format. Falls back to a temp directory when the parent
232
291
  // session is not persisted (e.g. headless/API mode).
233
- const sessionDir = deriveSubagentSessionDir(options.parentSessionFile, cfg.effectiveCwd);
234
- const sessionManager = SessionManager.create(cfg.effectiveCwd, sessionDir);
292
+ const sessionDir = io.deriveSessionDir(options.parentSessionFile, cfg.effectiveCwd);
293
+ const sessionManager = io.createSessionManager(cfg.effectiveCwd, sessionDir);
235
294
  sessionManager.newSession({ parentSession: options.parentSessionId });
236
295
 
237
- const sessionOpts: Parameters<typeof createAgentSession>[0] = {
296
+ const { session } = await io.createSession({
238
297
  cwd: cfg.effectiveCwd,
239
298
  agentDir,
240
299
  sessionManager,
241
- settingsManager: SettingsManager.create(cfg.effectiveCwd, agentDir),
242
- modelRegistry: snapshot.modelRegistry as any,
243
- model: cfg.model as Model<any> | undefined,
300
+ settingsManager: io.createSettingsManager(cfg.effectiveCwd, agentDir),
301
+ modelRegistry: snapshot.modelRegistry,
302
+ model: cfg.model,
244
303
  tools: cfg.toolNames,
245
304
  resourceLoader: loader,
246
- };
247
- if (cfg.thinkingLevel) {
248
- sessionOpts.thinkingLevel = cfg.thinkingLevel;
249
- }
250
-
251
- const { session } = await createAgentSession(sessionOpts);
305
+ thinkingLevel: cfg.thinkingLevel,
306
+ });
252
307
 
253
308
  // Filter active tools: remove our own tools to prevent nesting,
254
309
  // apply extension allowlist if specified, and apply disallowedTools denylist.
package/src/index.ts CHANGED
@@ -11,19 +11,32 @@
11
11
  */
12
12
 
13
13
  import { join } from "node:path";
14
- import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ createAgentSession,
16
+ DefaultResourceLoader,
17
+ defineTool,
18
+ type ExtensionAPI,
19
+ getAgentDir,
20
+ SettingsManager as SdkSettingsManager,
21
+ SessionManager,
22
+ } from "@earendil-works/pi-coding-agent";
15
23
  import { AgentManager, type AgentManagerObserver } from "./agent-manager.js";
16
- import { getAgentConversation, resumeAgent, runAgent, steerAgent } from "./agent-runner.js";
24
+ import { createAgentRunner, getAgentConversation, type RunnerIO, steerAgent } from "./agent-runner.js";
17
25
  import { AgentTypeRegistry } from "./agent-types.js";
18
26
  import { loadCustomAgents } from "./custom-agents.js";
27
+ import { detectEnv } from "./env.js";
19
28
  import { SessionLifecycleHandler, ToolStartHandler } from "./handlers/index.js";
29
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
20
30
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
21
31
  import { buildEventData, type NotificationDetails, NotificationManager } from "./notification.js";
32
+ import { buildAgentPrompt } from "./prompts.js";
22
33
  import { createNotificationRenderer } from "./renderer.js";
23
34
  import { createSubagentRuntime } from "./runtime.js";
24
35
  import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
25
36
  import { createSubagentsService } from "./service-adapter.js";
37
+ import { deriveSubagentSessionDir } from "./session-dir.js";
26
38
  import { SettingsManager } from "./settings.js";
39
+ import { preloadSkills } from "./skill-loader.js";
27
40
  import { createAgentTool } from "./tools/agent-tool.js";
28
41
  import { createGetResultTool } from "./tools/get-result-tool.js";
29
42
  import { getModelLabelFromConfig } from "./tools/helpers.js";
@@ -120,8 +133,24 @@ export default function (pi: ExtensionAPI) {
120
133
  },
121
134
  };
122
135
 
136
+ const runnerIO: RunnerIO = {
137
+ detectEnv,
138
+ getAgentDir,
139
+ createResourceLoader: (opts) => new DefaultResourceLoader(opts as any),
140
+ deriveSessionDir: deriveSubagentSessionDir,
141
+ createSessionManager: (cwd, dir) => SessionManager.create(cwd, dir),
142
+ createSettingsManager: (cwd, dir) => SdkSettingsManager.create(cwd, dir),
143
+ createSession: (opts) => createAgentSession(opts as any),
144
+ assemblerIO: {
145
+ preloadSkills,
146
+ buildMemoryBlock,
147
+ buildReadOnlyMemoryBlock,
148
+ buildAgentPrompt,
149
+ },
150
+ };
151
+
123
152
  const manager = new AgentManager({
124
- runner: { run: runAgent, resume: resumeAgent },
153
+ runner: createAgentRunner(runnerIO),
125
154
  worktrees: new GitWorktreeManager(process.cwd()),
126
155
  exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
127
156
  registry,