@gotgenes/pi-subagents 7.2.8 → 7.3.1
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 +33 -0
- package/docs/architecture/architecture.md +4 -7
- package/docs/plans/0207-decompose-agent-widget-update.md +242 -0
- package/docs/plans/0208-extract-shared-test-fixtures.md +298 -0
- package/docs/retro/0207-decompose-agent-widget-update.md +92 -0
- package/docs/retro/0208-extract-shared-test-fixtures.md +45 -0
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +78 -38
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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
|
+
## [7.3.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.3.0...pi-subagents-v7.3.1) (2026-05-25)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* add parameter type annotations to shared test fixture factories ([2953adc](https://github.com/gotgenes/pi-packages/commit/2953adc5761dd4aa2d3c592ea5f8856161e01e3a))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* plan extract shared test fixtures ([#208](https://github.com/gotgenes/pi-packages/issues/208)) ([22fafed](https://github.com/gotgenes/pi-packages/commit/22fafed860134f00f1b5d6cb7c87fa42be7dcf09))
|
|
19
|
+
* **retro:** add planning stage notes for issue [#208](https://github.com/gotgenes/pi-packages/issues/208) ([aaa9d5d](https://github.com/gotgenes/pi-packages/commit/aaa9d5d0a4654b2ebb25a7ad4d11a3a54db7c206))
|
|
20
|
+
* **retro:** add retro notes for issue [#207](https://github.com/gotgenes/pi-packages/issues/207) ([3b97a5c](https://github.com/gotgenes/pi-packages/commit/3b97a5c5b3fbb98a78ba280fbdbbdf55f61d82fc))
|
|
21
|
+
* **retro:** add TDD stage notes for issue [#208](https://github.com/gotgenes/pi-packages/issues/208) ([65d0606](https://github.com/gotgenes/pi-packages/commit/65d0606167e531518bd8de4d33f91c6080e21723))
|
|
22
|
+
* update Phase 12 Step 4 to reference test/helpers/ ([8e9e406](https://github.com/gotgenes/pi-packages/commit/8e9e406bfe12487f67363b223d037664f842acce))
|
|
23
|
+
|
|
24
|
+
## [7.3.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.2.8...pi-subagents-v7.3.0) (2026-05-25)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* extract assembleWidgetState from agent-widget update ([c63fdac](https://github.com/gotgenes/pi-packages/commit/c63fdacdb184f989598ebafbf595cc9c01c9d3a0))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* plan decompose update in agent-widget.ts ([#207](https://github.com/gotgenes/pi-packages/issues/207)) ([5ae2e74](https://github.com/gotgenes/pi-packages/commit/5ae2e74385753cd8e68504f767539aff019e5d08))
|
|
35
|
+
* plan decompose update in agent-widget.ts ([#207](https://github.com/gotgenes/pi-packages/issues/207)) ([fb30d98](https://github.com/gotgenes/pi-packages/commit/fb30d98fc0fcc2c3cd6f016b78251cdb4caf6d96))
|
|
36
|
+
* **retro:** add planning stage notes for issue [#207](https://github.com/gotgenes/pi-packages/issues/207) ([1624d2d](https://github.com/gotgenes/pi-packages/commit/1624d2d4aef5fee77db319940de7e2a3e93a8a27))
|
|
37
|
+
* **retro:** add planning stage notes for issue [#207](https://github.com/gotgenes/pi-packages/issues/207) ([931ff0e](https://github.com/gotgenes/pi-packages/commit/931ff0ea3810d87a35f49b3b671d1ef4d88d55f7))
|
|
38
|
+
* **retro:** add TDD stage notes for issue [#207](https://github.com/gotgenes/pi-packages/issues/207) ([770940a](https://github.com/gotgenes/pi-packages/commit/770940a3ff5c6b7ddd84ac35a4352ffc6c7c189e))
|
|
39
|
+
* update complexity hotspots after widget decomposition ([5848a17](https://github.com/gotgenes/pi-packages/commit/5848a173158f13fa58ccd357edf9a310559f92e1))
|
|
40
|
+
|
|
8
41
|
## [7.2.8](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.2.7...pi-subagents-v7.2.8) (2026-05-25)
|
|
9
42
|
|
|
10
43
|
|
|
@@ -466,10 +466,7 @@ Bags with 10+ fields are the highest priority for decomposition.
|
|
|
466
466
|
|
|
467
467
|
Functions with cyclomatic complexity ≥ 21 (critical threshold):
|
|
468
468
|
|
|
469
|
-
|
|
470
|
-
| ------------------- | ---------- | --------- | --------------------------- | ------------------------------- |
|
|
471
|
-
| `renderWidgetLines` | 25 | 44 | `ui/widget-renderer.ts` | Renders widget status lines |
|
|
472
|
-
| `update` | 21 | 31 | `ui/agent-widget.ts` | Widget lifecycle + polling |
|
|
469
|
+
No functions remain above the critical threshold — all hotspots resolved in Phase 12.
|
|
473
470
|
|
|
474
471
|
### Churn hotspots
|
|
475
472
|
|
|
@@ -640,10 +637,10 @@ The 3 heaviest clone families:
|
|
|
640
637
|
- `agent-menu.test.ts` + `agent-creation-wizard.test.ts` + `agent-config-editor.test.ts` (54+51+24 lines)
|
|
641
638
|
- `agent-manager.test.ts` (18 internal clone groups, 210 duplicated lines)
|
|
642
639
|
|
|
643
|
-
Extract shared factories into `test/
|
|
640
|
+
Extract shared factories into `test/helpers/` modules.
|
|
644
641
|
|
|
645
|
-
- Target: new `test/
|
|
646
|
-
- Outcome: test duplication reduced by ~
|
|
642
|
+
- Target: new `test/helpers/runner-io.ts` and `test/helpers/ui-stubs.ts` modules
|
|
643
|
+
- Outcome: test duplication reduced by ~250 lines
|
|
647
644
|
|
|
648
645
|
## Refactoring history
|
|
649
646
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 207
|
|
3
|
+
issue_title: "Decompose update in agent-widget.ts (cognitive 31)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Decompose `update` in `agent-widget.ts`
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`update` in `ui/agent-widget.ts` has cognitive complexity 31 (CRITICAL per fallow health).
|
|
11
|
+
It mixes agent state categorization, widget teardown, status bar management, and widget registration dispatch in a single 70-line method.
|
|
12
|
+
Phase 12, Step 3 targets cognitive complexity < 10 per function.
|
|
13
|
+
|
|
14
|
+
## Goals
|
|
15
|
+
|
|
16
|
+
- Extract `assembleWidgetState` as an exported pure function (agent list → lightweight counts/flags) that is directly unit-testable.
|
|
17
|
+
- Extract `clearWidget` as a private method encapsulating the "nothing to show" teardown path.
|
|
18
|
+
- Extract `updateStatusBar` as a private method encapsulating status text computation and conditional update.
|
|
19
|
+
- Simplify `update` to a thin orchestrator: guard → assemble → if idle clear → else update status + register.
|
|
20
|
+
- Target: cognitive complexity < 10 for every function in the file.
|
|
21
|
+
|
|
22
|
+
## Non-Goals
|
|
23
|
+
|
|
24
|
+
- Decomposing `renderWidgetLines` (#205, done), `showAgentDetail` (#206, done), or shared test fixtures (#208) — sibling Phase 12 steps.
|
|
25
|
+
- Extracting a separate timer-manager class — the timer lifecycle (`ensureTimer` + `clearWidget`) is two lines of `setInterval`/`clearInterval`.
|
|
26
|
+
- Changing the widget's visual output, status bar format, registration timing, or timer interval.
|
|
27
|
+
- Narrowing the `AgentManager` dependency to an interface — tracked separately.
|
|
28
|
+
- Adding end-to-end tests for `AgentWidget` — the widget depends on the Pi TUI context.
|
|
29
|
+
- Changing `dispose` behavior — it remains a shutdown-only teardown path.
|
|
30
|
+
|
|
31
|
+
## Background
|
|
32
|
+
|
|
33
|
+
`agent-widget.ts` was substantially decomposed in #148 (Phase 9, Step P), which extracted pure rendering into `widget-renderer.ts`.
|
|
34
|
+
The widget shrank from 374 to ~198 lines.
|
|
35
|
+
`update` remained as a 70-line orchestrator with five interwoven concerns:
|
|
36
|
+
|
|
37
|
+
1. **Guard** — early return when `uiCtx` is not yet set.
|
|
38
|
+
2. **Agent state categorization** — counting running/queued/finished agents from `listAgents()`.
|
|
39
|
+
3. **Clear path** — unregistering widget, clearing status, stopping timer, cleaning stale `finishedTurnAge` entries.
|
|
40
|
+
4. **Status bar update** — computing status text from running/queued counts, conditionally calling `setStatus`.
|
|
41
|
+
5. **Widget lifecycle** — incrementing `widgetFrame`, registering the widget factory on first use, calling `requestRender()` on subsequent ticks.
|
|
42
|
+
|
|
43
|
+
Concerns 3–4 consume counts from concern 2 but each has a distinct responsibility.
|
|
44
|
+
|
|
45
|
+
`categorizeAgents` in `widget-renderer.ts` performs a similar filter but returns full `WidgetAgent[]` arrays for rendering.
|
|
46
|
+
`assembleWidgetState` returns lightweight counts for lifecycle decisions — different outputs for different consumers, not duplication.
|
|
47
|
+
|
|
48
|
+
No existing tests cover `AgentWidget` methods — `test/widget-renderer.test.ts` covers the rendering layer.
|
|
49
|
+
|
|
50
|
+
### Complexity sources in `update` (cognitive 31)
|
|
51
|
+
|
|
52
|
+
1. Agent counting loop with 3-branch if/else (`status` checks + `shouldShowFinished`) — contributes ~6.
|
|
53
|
+
2. Conditional widget clear path: nested checks for `widgetRegistered`, `lastStatusText`, `widgetInterval`, plus a `for`-loop with nested `if` for stale-entry cleanup — contributes ~12.
|
|
54
|
+
3. Status text computation with conditional `if (hasActive)` and nested `if`/push branches — contributes ~5.
|
|
55
|
+
4. Conditional status update (`newStatusText !== this.lastStatusText`) — contributes ~2.
|
|
56
|
+
5. Widget registration dispatch (`if (!widgetRegistered) ... else ...`) — contributes ~4.
|
|
57
|
+
|
|
58
|
+
### `dispose` is not a duplication target
|
|
59
|
+
|
|
60
|
+
`dispose` (10 lines, cognitive ~2) and `update`'s idle path share *some* statements — `clearInterval`, `setWidget(undefined)`, `setStatus(undefined)`, flag resets — but differ in two ways:
|
|
61
|
+
|
|
62
|
+
- **Guards:** `update`'s idle path guards each call (`if (widgetRegistered)`, `if (lastStatusText !== undefined)`) to avoid redundant SDK calls during repeated timer ticks.
|
|
63
|
+
`dispose` unconditionally calls `setWidget`/`setStatus` (when `uiCtx` exists) as a correctness guarantee during shutdown.
|
|
64
|
+
- **Stale-entry cleanup:** `update`'s idle path cleans `finishedTurnAge` entries for agents no longer in `listAgents()`.
|
|
65
|
+
`dispose` skips this — the Map is about to be garbage collected.
|
|
66
|
+
|
|
67
|
+
Per the code-design skill ("duplication is far cheaper than the wrong abstraction" — verify structural context before extracting), these are different lifecycle semantics.
|
|
68
|
+
`dispose` stays as-is; `clearWidget` is extracted only from `update`'s idle path.
|
|
69
|
+
|
|
70
|
+
## Design Overview
|
|
71
|
+
|
|
72
|
+
### `assembleWidgetState` (exported, pure)
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
/** Minimal agent shape needed for widget lifecycle decisions. */
|
|
76
|
+
interface AgentSummary {
|
|
77
|
+
readonly id: string;
|
|
78
|
+
readonly status: string;
|
|
79
|
+
readonly completedAt?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WidgetState {
|
|
83
|
+
readonly runningCount: number;
|
|
84
|
+
readonly queuedCount: number;
|
|
85
|
+
readonly hasFinished: boolean;
|
|
86
|
+
readonly hasActive: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function assembleWidgetState(
|
|
90
|
+
agents: readonly AgentSummary[],
|
|
91
|
+
shouldShowFinished: (agentId: string, status: string) => boolean,
|
|
92
|
+
): WidgetState
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The input uses a local `AgentSummary` interface (3 fields) rather than `WidgetAgent` (10+ fields).
|
|
96
|
+
This follows ISP — `assembleWidgetState` only reads `id`, `status`, and `completedAt`.
|
|
97
|
+
Tests can pass plain objects without constructing full agent fixtures.
|
|
98
|
+
`AgentRecord` satisfies `AgentSummary` structurally, so no adapter is needed at the call site.
|
|
99
|
+
|
|
100
|
+
`hasActive` is derived from the counts (`runningCount > 0 || queuedCount > 0`) but included for call-site readability.
|
|
101
|
+
Only `assembleWidgetState` constructs `WidgetState`, so consistency is guaranteed.
|
|
102
|
+
|
|
103
|
+
### `clearWidget` (private method)
|
|
104
|
+
|
|
105
|
+
Encapsulates `update`'s "nothing to show" teardown path:
|
|
106
|
+
|
|
107
|
+
- Unregister widget via `setWidget("agents", undefined)` if `widgetRegistered`.
|
|
108
|
+
- Clear status via `setStatus("subagents", undefined)` if `lastStatusText` is set.
|
|
109
|
+
- Stop timer via `clearInterval` if `widgetInterval` is running.
|
|
110
|
+
- Reset lifecycle flags (`widgetRegistered`, `tui`, `lastStatusText`, `widgetInterval`).
|
|
111
|
+
- Clean stale `finishedTurnAge` entries (agents no longer in `allAgents`).
|
|
112
|
+
|
|
113
|
+
Accepts `allAgents` as a parameter for the stale-entry cleanup.
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
private clearWidget(allAgents: readonly AgentSummary[]): void
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`dispose` does **not** delegate to `clearWidget` — see "dispose is not a duplication target" above.
|
|
120
|
+
|
|
121
|
+
### `updateStatusBar` (private method)
|
|
122
|
+
|
|
123
|
+
Encapsulates the status text concern:
|
|
124
|
+
|
|
125
|
+
- Compute status text from `runningCount` / `queuedCount` (undefined when `!hasActive`).
|
|
126
|
+
- Call `setStatus("subagents", text)` only when text differs from `lastStatusText`.
|
|
127
|
+
- Cache the new value in `lastStatusText`.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
private updateStatusBar(state: WidgetState): void
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### After refactoring
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
update() {
|
|
137
|
+
if (!this.uiCtx) return;
|
|
138
|
+
|
|
139
|
+
const allAgents = this.manager.listAgents();
|
|
140
|
+
const state = assembleWidgetState(allAgents, (id, status) => this.shouldShowFinished(id, status));
|
|
141
|
+
|
|
142
|
+
if (!state.hasActive && !state.hasFinished) {
|
|
143
|
+
this.clearWidget(allAgents);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.updateStatusBar(state);
|
|
148
|
+
this.widgetFrame++;
|
|
149
|
+
|
|
150
|
+
if (!this.widgetRegistered) {
|
|
151
|
+
this.uiCtx.setWidget("agents", (tui, theme) => {
|
|
152
|
+
this.tui = tui;
|
|
153
|
+
return {
|
|
154
|
+
render: () => this.renderWidget(tui, theme),
|
|
155
|
+
invalidate: () => {
|
|
156
|
+
this.widgetRegistered = false;
|
|
157
|
+
this.tui = undefined;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}, { placement: "aboveEditor" });
|
|
161
|
+
this.widgetRegistered = true;
|
|
162
|
+
} else {
|
|
163
|
+
this.tui?.requestRender();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Cognitive complexity: ~4 (one guard early return + one if/else branch + flat registration dispatch).
|
|
169
|
+
|
|
170
|
+
### Complexity budget
|
|
171
|
+
|
|
172
|
+
| Function | Estimated cognitive complexity |
|
|
173
|
+
| --------------------- | ----------------------------------------------- |
|
|
174
|
+
| `assembleWidgetState` | ~3 (flat loop with 3 branches) |
|
|
175
|
+
| `clearWidget` | ~6 (4 guards + loop with if) |
|
|
176
|
+
| `updateStatusBar` | ~4 (hasActive check + diff check) |
|
|
177
|
+
| `update` (after) | ~4 (guard + idle check + registration dispatch) |
|
|
178
|
+
| `dispose` (unchanged) | ~2 |
|
|
179
|
+
|
|
180
|
+
All under 10.
|
|
181
|
+
|
|
182
|
+
## Module-Level Changes
|
|
183
|
+
|
|
184
|
+
### Changed: `src/ui/agent-widget.ts`
|
|
185
|
+
|
|
186
|
+
- Add local `AgentSummary` interface (3 fields: `id`, `status`, `completedAt?`).
|
|
187
|
+
- Add exported `WidgetState` interface.
|
|
188
|
+
- Add exported `assembleWidgetState(agents, shouldShowFinished)` pure function.
|
|
189
|
+
- Add private `clearWidget(allAgents)` method — extracted from `update`'s idle path.
|
|
190
|
+
- Add private `updateStatusBar(state)` method — extracted from `update`'s status bar logic.
|
|
191
|
+
- Simplify `update` to orchestrate: guard → assemble → if idle clear → else update status + register.
|
|
192
|
+
- `dispose` is unchanged.
|
|
193
|
+
|
|
194
|
+
No exports are removed or renamed.
|
|
195
|
+
The public API (`AgentWidget` class with `setUICtx`, `onTurnStart`, `ensureTimer`, `markFinished`, `update`, `dispose`) is unchanged.
|
|
196
|
+
`UICtx` type stays exported.
|
|
197
|
+
|
|
198
|
+
### Unchanged: `test/widget-renderer.test.ts`
|
|
199
|
+
|
|
200
|
+
No changes — this file covers `widget-renderer.ts` functions, not `agent-widget.ts`.
|
|
201
|
+
|
|
202
|
+
### Changed: `docs/architecture/architecture.md`
|
|
203
|
+
|
|
204
|
+
- Update the complexity hotspots table: remove `update` row (no longer ≥ 21 cyclomatic).
|
|
205
|
+
- Also remove the `renderWidgetLines` row if #205 is already implemented (it is — closed).
|
|
206
|
+
|
|
207
|
+
## Test Impact Analysis
|
|
208
|
+
|
|
209
|
+
1. **New tests enabled:** Direct unit tests for `assembleWidgetState` — a pure function accepting plain objects.
|
|
210
|
+
Tests cover: empty list, running-only, queued-only, finished-only, mixed states, `shouldShowFinished` filtering, agents without `completedAt` excluded from finished.
|
|
211
|
+
The narrow `AgentSummary` input type means test fixtures are 3-field objects — no need for full agent records.
|
|
212
|
+
2. **No existing tests become redundant** — there are currently no tests for `AgentWidget`.
|
|
213
|
+
3. **No existing tests must change** — `test/widget-renderer.test.ts` (23 tests) exercises the renderer layer and is unaffected.
|
|
214
|
+
|
|
215
|
+
## TDD Order
|
|
216
|
+
|
|
217
|
+
1. **Red → Green:** Write unit tests for `assembleWidgetState` covering all agent status combinations.
|
|
218
|
+
Add the `AgentSummary` interface, `WidgetState` interface, and `assembleWidgetState` function as exported module-level items.
|
|
219
|
+
Implement to make tests pass.
|
|
220
|
+
Commit: `feat: extract assembleWidgetState from agent-widget update`
|
|
221
|
+
|
|
222
|
+
2. **Refactor:** Wire `update` to use `assembleWidgetState`.
|
|
223
|
+
Extract `clearWidget(allAgents)` method from the idle path.
|
|
224
|
+
Extract `updateStatusBar(state)` method from the status bar logic.
|
|
225
|
+
All existing tests pass (no behavior change, no export changes, `dispose` unchanged).
|
|
226
|
+
Commit: `refactor: decompose update into clearWidget and updateStatusBar`
|
|
227
|
+
|
|
228
|
+
3. **Docs:** Update the complexity hotspots table in `docs/architecture/architecture.md`.
|
|
229
|
+
Remove both `renderWidgetLines` (done in #205) and `update` rows.
|
|
230
|
+
Commit: `docs: update complexity hotspots after widget decomposition`
|
|
231
|
+
|
|
232
|
+
## Risks and Mitigations
|
|
233
|
+
|
|
234
|
+
| Risk | Mitigation |
|
|
235
|
+
| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
236
|
+
| `assembleWidgetState` has overlapping categorization with `categorizeAgents` in `widget-renderer.ts` | Different outputs for different consumers: counts+flags for lifecycle vs. full arrays for rendering. Not duplication. |
|
|
237
|
+
| No existing tests for `AgentWidget` — refactoring risks are higher for `clearWidget`/`updateStatusBar` | The pure function `assembleWidgetState` is tested directly. The method extractions are mechanical code moves with no semantic change — the type checker verifies structural integrity. |
|
|
238
|
+
| `clearWidget` guards redundant SDK calls (`if (widgetRegistered)`) — caller might expect unconditional teardown | Only `update`'s idle path calls `clearWidget`. `dispose` stays as-is with its own unconditional teardown semantics. |
|
|
239
|
+
|
|
240
|
+
## Open Questions
|
|
241
|
+
|
|
242
|
+
None — the decomposition is a mechanical extraction of existing code into named functions and methods, following the pattern established by Phase 12 Steps 1 and 2.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 208
|
|
3
|
+
issue_title: "Extract shared test fixtures to reduce test duplication"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract shared test fixtures
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
Test duplication across the pi-subagents package is 1,367 lines (7.2%) across 23 files (67 clone groups per fallow analysis).
|
|
11
|
+
The three heaviest clone families are:
|
|
12
|
+
|
|
13
|
+
1. Runner IO tests (`agent-runner.test.ts` + `agent-runner-extension-tools.test.ts` + `concrete-agent-runner.test.ts`): 60-line shared setup — `createRunnerIO()`, `AgentConfigLookup` stub, `ParentSnapshot` constant.
|
|
14
|
+
2. Menu/wizard UI tests (`agent-menu.test.ts` + `agent-creation-wizard.test.ts` + `agent-config-editor.test.ts`): 54+51+24 lines — `makeFileOps()`, `makeUI()`, `makeManager()`, `AgentConfig` defaults, `ParentSnapshot` constant.
|
|
15
|
+
3. `agent-manager.test.ts` internal: 18 clone groups, 210 duplicated lines — repetitive `manager.spawn(...)` call patterns.
|
|
16
|
+
|
|
17
|
+
Issue #131 (closed) already extracted `createMockSession`, `createToolDeps`, and `createTestRecord` into `test/helpers/`.
|
|
18
|
+
This issue targets the remaining duplication families.
|
|
19
|
+
|
|
20
|
+
## Goals
|
|
21
|
+
|
|
22
|
+
- Extract `createRunnerIO()` and `createAgentLookup()` into `test/helpers/runner-io.ts` — single source of truth for the `RunnerIO` and `AgentConfigLookup` stubs used by runner tests.
|
|
23
|
+
- Extract `makeFileOps()`, `makeMenuUI()`, `makeMenuManager()`, and `createTestAgentConfig()` into `test/helpers/ui-stubs.ts` — shared factories for the three UI test files.
|
|
24
|
+
- Consolidate `agent-manager.test.ts` internal duplication with local helper functions (`spawnBg`, `spawnFg`).
|
|
25
|
+
- Replace local `ParentSnapshot` definitions with the existing `STUB_SNAPSHOT` from `test/helpers/stub-ctx.ts` where possible.
|
|
26
|
+
- Remove stale `buildMemoryBlock` and `buildReadOnlyMemoryBlock` stubs from the `createRunnerIO` factory (these methods no longer exist on `AssemblerIO`).
|
|
27
|
+
- Target: ~250 lines of test duplication removed.
|
|
28
|
+
|
|
29
|
+
## Non-Goals
|
|
30
|
+
|
|
31
|
+
- Extracting session mock factories from the runner tests — each file's session factory serves a specialized purpose (`createSession(finalText)`, `createSessionWithExtensionToolRegistration(beforeBind, afterBind)`, `makeSession(text)`) and the variance is structural, not incidental.
|
|
32
|
+
- Extracting `makeSettings()` from `agent-menu.test.ts` — only used in one file.
|
|
33
|
+
- Extracting `makeHandler()` / `makeEditor()` / `makeDeps()` wrapper functions that compose collaborator stubs for specific handlers — too tightly coupled to each file's test structure.
|
|
34
|
+
- Extracting the mutable `agentConfigMock` pattern from `agent-runner-extension-tools.test.ts` — its per-test config mutation is inherently local.
|
|
35
|
+
- Decomposing UI complexity (Steps 1–3 of Phase 12, issues #205, #206, #207) — separate issues.
|
|
36
|
+
|
|
37
|
+
## Background
|
|
38
|
+
|
|
39
|
+
### Existing test helpers
|
|
40
|
+
|
|
41
|
+
`test/helpers/` already contains shared factories from issue #131:
|
|
42
|
+
|
|
43
|
+
| File | Exports | Used by |
|
|
44
|
+
| ----------------- | ----------------------------------------- | ------------------------------------------------------- |
|
|
45
|
+
| `mock-session.ts` | `createMockSession()`, `toAgentSession()` | agent-manager, record-observer, ui-observer tests |
|
|
46
|
+
| `make-deps.ts` | `createToolDeps()` | agent-tool, background-spawner, foreground-runner tests |
|
|
47
|
+
| `make-record.ts` | `createTestRecord()` | tool tests, UI tests |
|
|
48
|
+
| `stub-ctx.ts` | `STUB_CTX`, `STUB_SNAPSHOT` | tool tests (via `make-deps.ts`) |
|
|
49
|
+
|
|
50
|
+
The new factories follow the same pattern: shared files in `test/helpers/` with optional unit tests.
|
|
51
|
+
|
|
52
|
+
### Architecture doc reference
|
|
53
|
+
|
|
54
|
+
Phase 12, Step 4 in `docs/architecture/architecture.md` (lines 637–644) calls for `test/fixtures/` modules.
|
|
55
|
+
We use `test/helpers/` instead to follow the existing convention established in issue #131.
|
|
56
|
+
The architecture doc reference will be updated as part of this work.
|
|
57
|
+
|
|
58
|
+
### Interface shapes
|
|
59
|
+
|
|
60
|
+
`RunnerIO` = `EnvironmentIO & SessionFactoryIO` (in `src/lifecycle/agent-runner.ts`):
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
interface EnvironmentIO {
|
|
64
|
+
detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
|
|
65
|
+
getAgentDir: () => string;
|
|
66
|
+
deriveSessionDir: (parentSessionFile: string | undefined, cwd: string) => string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface SessionFactoryIO {
|
|
70
|
+
createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
|
|
71
|
+
createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
|
|
72
|
+
createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
|
|
73
|
+
createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
|
|
74
|
+
assemblerIO: AssemblerIO;
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`AssemblerIO` (in `src/session/session-config.ts`) has only `preloadSkills` and `buildAgentPrompt`.
|
|
79
|
+
The existing test factories in `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts` include stale `buildMemoryBlock` and `buildReadOnlyMemoryBlock` stubs that no longer match the interface — the shared factory will omit them.
|
|
80
|
+
|
|
81
|
+
### Duplication diff: default values across copies
|
|
82
|
+
|
|
83
|
+
Before extracting, the following differences across copies were identified:
|
|
84
|
+
|
|
85
|
+
**`createRunnerIO` / `makeIO`:**
|
|
86
|
+
|
|
87
|
+
| Field | agent-runner | extension-tools | concrete-agent-runner |
|
|
88
|
+
| -------------------------------------- | --------------- | --------------- | --------------------- |
|
|
89
|
+
| `assemblerIO.buildMemoryBlock` | present (stale) | present (stale) | absent |
|
|
90
|
+
| `assemblerIO.buildReadOnlyMemoryBlock` | present (stale) | absent | absent |
|
|
91
|
+
| Other fields | identical | identical | identical |
|
|
92
|
+
|
|
93
|
+
**`ParentSnapshot` stubs:**
|
|
94
|
+
|
|
95
|
+
| Field | agent-runner | extension-tools | concrete-agent-runner | agent-manager | agent-menu/wizard | STUB_SNAPSHOT |
|
|
96
|
+
| ---------------------------- | --------------- | --------------- | --------------------- | --------------- | ----------------- | ----------------- |
|
|
97
|
+
| `cwd` | "/tmp" | "/tmp" | "/workspace" | "/tmp" | "/test" | "/test" |
|
|
98
|
+
| `systemPrompt` | "parent prompt" | "parent prompt" | "" | "parent prompt" | "" | "test prompt" |
|
|
99
|
+
| `model` | undefined | undefined | {} | undefined | {} | undefined |
|
|
100
|
+
| `modelRegistry.find` | vi.fn() | vi.fn() | vi.fn() | vi.fn() | `() => undefined` | `() => undefined` |
|
|
101
|
+
| `modelRegistry.getAvailable` | vi.fn() | vi.fn() | — | — | — | — |
|
|
102
|
+
|
|
103
|
+
None of the consumer tests assert on `cwd`, `systemPrompt`, `model`, or `modelRegistry.find` return values — these fields are passed through to functions that are already mocked.
|
|
104
|
+
Using `STUB_SNAPSHOT` is safe for all consumers.
|
|
105
|
+
|
|
106
|
+
**`makeFileOps`:** character-for-character identical in all three UI test files.
|
|
107
|
+
|
|
108
|
+
**`makeUI`:** identical core in wizard and config-editor; agent-menu wraps it in an outer `{ ui, modelRegistry, parentSnapshot }` object.
|
|
109
|
+
|
|
110
|
+
**`makeManager`:** identical in agent-menu and wizard.
|
|
111
|
+
|
|
112
|
+
**`testDefaultAgentConfig`:** identical in agent-menu and config-editor (same 9 fields).
|
|
113
|
+
|
|
114
|
+
## Design Overview
|
|
115
|
+
|
|
116
|
+
### `test/helpers/runner-io.ts`
|
|
117
|
+
|
|
118
|
+
Two exports:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
/** Shared RunnerIO stub factory for agent-runner tests. */
|
|
122
|
+
function createRunnerIO(assemblerOverrides?: Partial<AssemblerIOStub>): RunnerIOStub;
|
|
123
|
+
|
|
124
|
+
/** Shared AgentConfigLookup stub. Returns a static Explore config by default. */
|
|
125
|
+
function createAgentLookup(config?: Partial<AgentConfig>): AgentLookupStub;
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`createRunnerIO` builds the full `RunnerIO` stub shape.
|
|
129
|
+
The `assemblerIO` sub-object defaults to stubs for `preloadSkills` and `buildAgentPrompt` only (matching the current `AssemblerIO` interface).
|
|
130
|
+
`assemblerOverrides` lets tests customize individual methods without rebuilding the entire factory.
|
|
131
|
+
|
|
132
|
+
`createAgentLookup` returns `{ resolveAgentConfig, getToolNamesForType }` wrapping a static config.
|
|
133
|
+
The default config is the Explore agent used in `agent-runner.test.ts` and `concrete-agent-runner.test.ts`.
|
|
134
|
+
Tests that need per-test config mutation (extension-tools) keep their local mutable wrapper but use the default config as a starting point.
|
|
135
|
+
|
|
136
|
+
Return types are deliberately unannotated (per testing skill) so `vi.fn()` stubs retain their `Mock<...>` methods.
|
|
137
|
+
|
|
138
|
+
### `test/helpers/ui-stubs.ts`
|
|
139
|
+
|
|
140
|
+
Four exports:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
/** FileOps stub — identical across all three UI test files. */
|
|
144
|
+
function makeFileOps(): FileOpsStub;
|
|
145
|
+
|
|
146
|
+
/** MenuUI stub with sequential select responses. */
|
|
147
|
+
function makeMenuUI(selectResults?: (string | undefined)[]): MenuUIStub;
|
|
148
|
+
|
|
149
|
+
/** Manager stub for UI tests (listAgents, getRecord, spawnAndWait). */
|
|
150
|
+
function makeMenuManager(): MenuManagerStub;
|
|
151
|
+
|
|
152
|
+
/** AgentConfig factory with sensible defaults and override support. */
|
|
153
|
+
function createTestAgentConfig(overrides?: Partial<AgentConfig>): AgentConfig;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`makeMenuUI` returns the flat UI shape (select, input, confirm, editor, notify, custom).
|
|
157
|
+
`agent-menu.test.ts` wraps this locally into its `{ ui, modelRegistry, parentSnapshot }` structure — the wrapping stays in the test file because it's specific to `AgentsMenuHandler`'s interface.
|
|
158
|
+
|
|
159
|
+
### `agent-manager.test.ts` local helpers
|
|
160
|
+
|
|
161
|
+
Two local helpers reduce the 42 repetitive `manager.spawn(...)` calls:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
function spawnBg(mgr: AgentManager, prompt = "test", desc = prompt) {
|
|
165
|
+
return mgr.spawn(STUB_SNAPSHOT, "general-purpose", prompt, {
|
|
166
|
+
description: desc,
|
|
167
|
+
isBackground: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function spawnFg(mgr: AgentManager, prompt = "test", desc = prompt) {
|
|
172
|
+
return mgr.spawnAndWait(STUB_SNAPSHOT, "general-purpose", prompt, {
|
|
173
|
+
description: desc,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
These stay local because they are only needed in this file.
|
|
179
|
+
They also replace the local `mockSnapshot` with the imported `STUB_SNAPSHOT`.
|
|
180
|
+
|
|
181
|
+
## Module-Level Changes
|
|
182
|
+
|
|
183
|
+
### New files
|
|
184
|
+
|
|
185
|
+
1. `test/helpers/runner-io.ts` — exports `createRunnerIO()`, `createAgentLookup()`.
|
|
186
|
+
2. `test/helpers/runner-io.test.ts` — unit tests: verifies stub shape satisfies `RunnerIO`, override merging, default config shape.
|
|
187
|
+
3. `test/helpers/ui-stubs.ts` — exports `makeFileOps()`, `makeMenuUI()`, `makeMenuManager()`, `createTestAgentConfig()`.
|
|
188
|
+
4. `test/helpers/ui-stubs.test.ts` — unit tests: verifies stub shapes, sequential select behavior, config override merging.
|
|
189
|
+
|
|
190
|
+
### Modified files
|
|
191
|
+
|
|
192
|
+
1. `test/lifecycle/agent-runner.test.ts` — remove local `createRunnerIO()`, `mockAgentLookup`, `snapshot`, `exec`; import from helpers.
|
|
193
|
+
2. `test/lifecycle/agent-runner-extension-tools.test.ts` — remove local `createRunnerIO()`, `snapshot`; import from helpers; keep local mutable `agentConfigMock` and `mockAgentLookup` (they use the mutable wrapper pattern).
|
|
194
|
+
3. `test/lifecycle/concrete-agent-runner.test.ts` — remove local `makeIO()`, `registry`, `snapshot`; import from helpers.
|
|
195
|
+
4. `test/lifecycle/agent-manager.test.ts` — remove local `mockSnapshot`; import `STUB_SNAPSHOT`; add local `spawnBg()` and `spawnFg()` helpers; update all `manager.spawn(mockSnapshot, ...)` calls.
|
|
196
|
+
5. `test/ui/agent-menu.test.ts` — remove local `makeFileOps()`, `makeManager()`, `testDefaultAgentConfig`, `stubParentSnapshot`; import from helpers; keep local `makeSettings()` and `makeHandler()`.
|
|
197
|
+
6. `test/ui/agent-creation-wizard.test.ts` — remove local `makeFileOps()`, `makeManager()`, `stubParentSnapshot`; import from helpers; keep local `makeDeps()`.
|
|
198
|
+
7. `test/ui/agent-config-editor.test.ts` — remove local `makeFileOps()`, `testDefaultConfig`, `testCustomConfig`; import from helpers; keep local `makeEditor()`.
|
|
199
|
+
|
|
200
|
+
### Doc updates
|
|
201
|
+
|
|
202
|
+
1. `docs/architecture/architecture.md` — update Phase 12 Step 4 reference from `test/fixtures/` to `test/helpers/` (lines 640, 642).
|
|
203
|
+
|
|
204
|
+
## Test Impact Analysis
|
|
205
|
+
|
|
206
|
+
1. The new factory unit tests (`runner-io.test.ts`, `ui-stubs.test.ts`) verify shared fixture behavior that was previously only implicitly tested through consumer test files.
|
|
207
|
+
This enables targeted debugging when a mock shape drifts from the production interface.
|
|
208
|
+
2. No existing tests become redundant — consumer tests exercise distinct production behavior that the factory tests do not cover.
|
|
209
|
+
3. All existing tests stay as-is in terms of assertions.
|
|
210
|
+
Only the setup code (local factory → shared import) changes.
|
|
211
|
+
4. The removal of stale `buildMemoryBlock` and `buildReadOnlyMemoryBlock` stubs from `createRunnerIO` is safe because `AssemblerIO` no longer declares these methods — structural typing means they were always no-ops.
|
|
212
|
+
|
|
213
|
+
## TDD Order
|
|
214
|
+
|
|
215
|
+
1. **Red → Green: `createRunnerIO` and `createAgentLookup` factories.**
|
|
216
|
+
Write `test/helpers/runner-io.test.ts` — verify `createRunnerIO()` returns a shape satisfying `RunnerIO` (`EnvironmentIO & SessionFactoryIO`), verify `assemblerIO` override merging, verify `createAgentLookup()` returns the default Explore config and accepts overrides.
|
|
217
|
+
Implement `test/helpers/runner-io.ts`.
|
|
218
|
+
Run: `pnpm vitest run test/helpers/runner-io.test.ts`.
|
|
219
|
+
Commit: `test: add createRunnerIO and createAgentLookup shared test fixtures`
|
|
220
|
+
|
|
221
|
+
2. **Green: migrate `agent-runner.test.ts` to shared runner-io factories.**
|
|
222
|
+
Import `createRunnerIO`, `createAgentLookup` from helpers.
|
|
223
|
+
Import `STUB_SNAPSHOT` from `stub-ctx.ts`.
|
|
224
|
+
Remove local `createRunnerIO()`, `mockAgentLookup`, `snapshot`.
|
|
225
|
+
Run: `pnpm vitest run test/lifecycle/agent-runner.test.ts`.
|
|
226
|
+
Commit: `test: use shared runner-io fixtures in agent-runner tests`
|
|
227
|
+
|
|
228
|
+
3. **Green: migrate `agent-runner-extension-tools.test.ts` to shared `createRunnerIO`.**
|
|
229
|
+
Import `createRunnerIO` from helpers.
|
|
230
|
+
Import `STUB_SNAPSHOT` from `stub-ctx.ts`.
|
|
231
|
+
Remove local `createRunnerIO()` and `snapshot`.
|
|
232
|
+
Keep local `agentConfigMock` and `mockAgentLookup` (mutable wrapper pattern).
|
|
233
|
+
Run: `pnpm vitest run test/lifecycle/agent-runner-extension-tools.test.ts`.
|
|
234
|
+
Commit: `test: use shared createRunnerIO in extension-tools tests`
|
|
235
|
+
|
|
236
|
+
4. **Green: migrate `concrete-agent-runner.test.ts` to shared factories.**
|
|
237
|
+
Import `createRunnerIO`, `createAgentLookup` from helpers.
|
|
238
|
+
Import `STUB_SNAPSHOT` from `stub-ctx.ts`.
|
|
239
|
+
Remove local `makeIO()`, `registry`, `snapshot`.
|
|
240
|
+
Run: `pnpm vitest run test/lifecycle/concrete-agent-runner.test.ts`.
|
|
241
|
+
Commit: `test: use shared runner-io fixtures in concrete-agent-runner tests`
|
|
242
|
+
|
|
243
|
+
5. **Red → Green: UI stub factories.**
|
|
244
|
+
Write `test/helpers/ui-stubs.test.ts` — verify `makeFileOps()` shape, `makeMenuUI()` sequential select behavior, `makeMenuManager()` shape, `createTestAgentConfig()` default and override merging.
|
|
245
|
+
Implement `test/helpers/ui-stubs.ts`.
|
|
246
|
+
Run: `pnpm vitest run test/helpers/ui-stubs.test.ts`.
|
|
247
|
+
Commit: `test: add shared UI stub factories`
|
|
248
|
+
|
|
249
|
+
6. **Green: migrate `agent-config-editor.test.ts` to shared UI stubs.**
|
|
250
|
+
Import `makeFileOps`, `makeMenuUI`, `createTestAgentConfig` from helpers.
|
|
251
|
+
Remove local `makeFileOps()`, `makeUI()`, `testDefaultConfig`; derive `testCustomConfig` from `createTestAgentConfig`.
|
|
252
|
+
Run: `pnpm vitest run test/ui/agent-config-editor.test.ts`.
|
|
253
|
+
Commit: `test: use shared UI stubs in agent-config-editor tests`
|
|
254
|
+
|
|
255
|
+
7. **Green: migrate `agent-creation-wizard.test.ts` to shared UI stubs.**
|
|
256
|
+
Import `makeFileOps`, `makeMenuUI`, `makeMenuManager` from helpers.
|
|
257
|
+
Import `STUB_SNAPSHOT` from `stub-ctx.ts`.
|
|
258
|
+
Remove local `makeFileOps()`, `makeUI()`, `makeManager()`, `stubParentSnapshot`.
|
|
259
|
+
Run: `pnpm vitest run test/ui/agent-creation-wizard.test.ts`.
|
|
260
|
+
Commit: `test: use shared UI stubs in agent-creation-wizard tests`
|
|
261
|
+
|
|
262
|
+
8. **Green: migrate `agent-menu.test.ts` to shared UI stubs.**
|
|
263
|
+
Import `makeFileOps`, `makeMenuUI`, `makeMenuManager`, `createTestAgentConfig` from helpers.
|
|
264
|
+
Import `STUB_SNAPSHOT` from `stub-ctx.ts`.
|
|
265
|
+
Remove local `makeFileOps()`, `makeManager()`, `testDefaultAgentConfig`, `stubParentSnapshot`.
|
|
266
|
+
Adapt `makeHandler()` to wrap `makeMenuUI()` into its `{ ui, modelRegistry, parentSnapshot }` shape.
|
|
267
|
+
Keep local `makeSettings()`.
|
|
268
|
+
Run: `pnpm vitest run test/ui/agent-menu.test.ts`.
|
|
269
|
+
Commit: `test: use shared UI stubs in agent-menu tests`
|
|
270
|
+
|
|
271
|
+
9. **Green: consolidate `agent-manager.test.ts` internal duplication.**
|
|
272
|
+
Import `STUB_SNAPSHOT` from `stub-ctx.ts`.
|
|
273
|
+
Remove local `mockSnapshot`.
|
|
274
|
+
Add local `spawnBg()` and `spawnFg()` helpers.
|
|
275
|
+
Update all ~42 `manager.spawn(mockSnapshot, ...)` calls to use `spawnBg()`.
|
|
276
|
+
Update `spawnAndWait` calls to use `spawnFg()`.
|
|
277
|
+
Run: `pnpm vitest run test/lifecycle/agent-manager.test.ts`.
|
|
278
|
+
Commit: `test: consolidate agent-manager spawn patterns with local helpers`
|
|
279
|
+
|
|
280
|
+
10. **Docs: update architecture doc reference.**
|
|
281
|
+
Update `docs/architecture/architecture.md` Phase 12 Step 4 to reference `test/helpers/` instead of `test/fixtures/`.
|
|
282
|
+
Commit: `docs: update Phase 12 Step 4 to reference test/helpers/`
|
|
283
|
+
|
|
284
|
+
## Risks and Mitigations
|
|
285
|
+
|
|
286
|
+
| Risk | Mitigation |
|
|
287
|
+
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
288
|
+
| `STUB_SNAPSHOT` shape differs from local snapshots (`cwd`, `model`, `systemPrompt` values) | Verified: no consumer tests assert on these field values. The snapshot is passed through to fully-mocked functions. Run full test suite after each migration step. |
|
|
289
|
+
| Removing stale `buildMemoryBlock`/`buildReadOnlyMemoryBlock` stubs breaks a test that somehow depends on them | These methods don't exist on `AssemblerIO` — any code accessing them would be a TypeScript error in production. Vitest's esbuild won't catch this, so run `pnpm run check` after step 2. |
|
|
290
|
+
| Wider mock shape in `createRunnerIO` causes false-positive tests (runner tests pass when they should fail) | The production `RunnerIO` interface is already narrow; extra mock methods are harmless. Existing assertions on specific mock calls catch regressions. |
|
|
291
|
+
| `makeMenuUI` sequential-select pattern breaks when agent-menu wraps it differently | Agent-menu's `makeHandler()` composes the wrapping locally. The shared factory returns only the flat UI shape, avoiding coupling to any specific consumer's wrapping structure. |
|
|
292
|
+
| `agent-manager.test.ts` `spawnBg()` helper hides important spawn options from test readers | Helper uses default values matching the most common pattern. Tests that need non-default options (e.g., `description: "first"`) pass explicit arguments, preserving readability. |
|
|
293
|
+
|
|
294
|
+
## Open Questions
|
|
295
|
+
|
|
296
|
+
- Should `STUB_SNAPSHOT` be updated to use `vi.fn()` for `modelRegistry.find` instead of `() => undefined`?
|
|
297
|
+
Currently it uses plain functions, but some runner tests use `vi.fn()`.
|
|
298
|
+
Decide during implementation — if no test asserts on `find` call counts, plain functions are fine.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 207
|
|
3
|
+
issue_title: "Decompose update in agent-widget.ts (cognitive 31)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #207 — Decompose `update` in `agent-widget.ts`
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-25T04:12:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned the decomposition of `update` (cognitive complexity 31) into an exported pure `assembleWidgetState` function, a `clearWidget` method, and an `updateStatusBar` method.
|
|
13
|
+
The plan follows the Phase 12 pattern established by Steps 1 and 2 (#205, #206) — extract pure functions where possible, otherwise extract methods, and simplify the original function to a thin orchestrator.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The sibling plans (#205, #206) provided a clear template for this plan — structure, section ordering, and test impact analysis all followed the established pattern.
|
|
18
|
+
- There are **no existing tests** for `AgentWidget` — the only testable concern is the newly extracted `assembleWidgetState` pure function.
|
|
19
|
+
The rest of the refactoring is a mechanical extraction verified by the type checker.
|
|
20
|
+
- `categorizeAgents` in `widget-renderer.ts` does a similar filter but returns full arrays (for rendering), while `assembleWidgetState` returns lightweight counts (for lifecycle decisions).
|
|
21
|
+
Different outputs for different consumers — no duplication concern.
|
|
22
|
+
- No `ask_user` was needed — the issue's "Proposed change" section was unambiguous and the design pattern was well-established by the two preceding Phase 12 steps.
|
|
23
|
+
|
|
24
|
+
## Stage: Planning — revision (2026-05-25T16:00:00Z)
|
|
25
|
+
|
|
26
|
+
### Session summary
|
|
27
|
+
|
|
28
|
+
Reviewed and revised the prior plan after a thorough code audit of `agent-widget.ts`, `widget-renderer.ts`, `agent-record.ts`, and `runtime.ts`.
|
|
29
|
+
Three design changes were made to the original plan.
|
|
30
|
+
|
|
31
|
+
### Observations
|
|
32
|
+
|
|
33
|
+
- **Narrowed the input type:** Changed `assembleWidgetState` from accepting `WidgetAgent[]` (10+ fields) to a local `AgentSummary` interface (3 fields: `id`, `status`, `completedAt?`).
|
|
34
|
+
The original plan violated ISP — the function only reads 3 fields, so requiring full `WidgetAgent` fixtures in tests would be needless friction.
|
|
35
|
+
`AgentRecord` satisfies `AgentSummary` structurally, so no adapter is needed at the call site.
|
|
36
|
+
- **Kept `dispose` independent:** The original plan made `dispose` delegate to `clearWidget`, but `dispose` and `update`'s idle path have different lifecycle semantics — `dispose` uses unconditional teardown (correctness guarantee), while `update`'s idle path uses guarded calls (avoiding redundant SDK calls during repeated ticks).
|
|
37
|
+
`dispose` also skips stale-entry cleanup (the Map is about to be GC'd).
|
|
38
|
+
Per the code-design skill's Sandi Metz principle, this is structural duplication that should not be extracted.
|
|
39
|
+
- **Added complexity budget table:** Explicitly estimated cognitive complexity for each extracted function to verify the < 10 target is achievable across the board.
|
|
40
|
+
|
|
41
|
+
## Stage: Implementation — TDD (2026-05-25T13:10:00Z)
|
|
42
|
+
|
|
43
|
+
### Session summary
|
|
44
|
+
|
|
45
|
+
Completed all three TDD steps from the plan.
|
|
46
|
+
`assembleWidgetState` was extracted and tested with 16 unit tests covering all status combinations; `clearWidget` and `updateStatusBar` were extracted as private methods simplifying `update` to a thin orchestrator.
|
|
47
|
+
Test count went from 868 to 884 (+16 tests across 55 files, up from 54).
|
|
48
|
+
|
|
49
|
+
### Observations
|
|
50
|
+
|
|
51
|
+
- No deviations from the plan.
|
|
52
|
+
The non-null assertion (`this.uiCtx!`) in `clearWidget` and `updateStatusBar` is safe because both methods are only called from `update` after the `if (!this.uiCtx) return` guard.
|
|
53
|
+
- The `AgentSummary` interface and narrow test fixtures worked exactly as planned — test objects are plain 3-field literals, no `createTestRecord` needed.
|
|
54
|
+
- The complexity hotspots table in `architecture.md` now has no rows (both `renderWidgetLines` from #205 and `update` from this issue are resolved).
|
|
55
|
+
The section note was updated to reflect that Phase 12 cleared all critical hotspots.
|
|
56
|
+
- `pnpm fallow dead-code` (from repo root) passed with no issues.
|
|
57
|
+
|
|
58
|
+
## Stage: Final Retrospective (2026-05-25T13:15:00Z)
|
|
59
|
+
|
|
60
|
+
### Session summary
|
|
61
|
+
|
|
62
|
+
Issue #207 shipped as `pi-subagents-v7.3.0` with zero deviations from the revised plan.
|
|
63
|
+
The session covered plan revision, TDD implementation (16 new tests, 868 → 884), shipping, and CI verification.
|
|
64
|
+
|
|
65
|
+
### Observations
|
|
66
|
+
|
|
67
|
+
#### What went well
|
|
68
|
+
|
|
69
|
+
- The plan revision caught three real design issues before implementation started: ISP violation in the function parameter type, incorrect `dispose` → `clearWidget` delegation, and missing complexity budget.
|
|
70
|
+
Fixing these upfront meant the TDD execution had zero deviations and zero rework.
|
|
71
|
+
- The narrow `AgentSummary` type (3 fields) made test fixtures trivial plain objects — no `createTestRecord` or factory infrastructure needed.
|
|
72
|
+
This validated the ISP improvement concretely.
|
|
73
|
+
- The `dispose` independence decision (Sandi Metz principle applied to lifecycle semantics) kept `dispose` at its current 10-line simplicity while `clearWidget` got its own guarded teardown logic.
|
|
74
|
+
|
|
75
|
+
#### What caused friction (agent side)
|
|
76
|
+
|
|
77
|
+
- `missing-context` — The original planning session (prior to this one) used `WidgetAgent[]` as the input type without checking which fields `assembleWidgetState` actually reads.
|
|
78
|
+
The `code-design` skill already says "do not pass a shared dependency bag to functions that only use a subset of it" but the principle wasn't applied to the proposed function signature.
|
|
79
|
+
Impact: required a full plan revision session; no rework in implementation because it was caught before TDD started.
|
|
80
|
+
- `wrong-abstraction` — The original plan proposed `dispose` → `clearWidget` delegation as "eliminating duplication" without evaluating whether the two methods have the same lifecycle semantics.
|
|
81
|
+
`dispose` uses unconditional teardown (shutdown correctness); `clearWidget` uses guarded calls (avoiding redundant SDK calls during repeated timer ticks).
|
|
82
|
+
Impact: same as above — caught in revision, no implementation rework.
|
|
83
|
+
|
|
84
|
+
#### What caused friction (user side)
|
|
85
|
+
|
|
86
|
+
- The user's "I don't yet trust the plan" intervention was the key moment that improved the design.
|
|
87
|
+
Without it, the plan would have been implemented with the wider type and the `dispose` delegation.
|
|
88
|
+
This was effective judgment — the user identified that a mechanical plan for a mechanical refactoring still warranted critical design review.
|
|
89
|
+
|
|
90
|
+
### Changes made
|
|
91
|
+
|
|
92
|
+
1. Added two sentences to `.pi/prompts/plan-issue.md` Design Overview section: ISP check for new function parameter types, and structural-duplication check when consolidating methods into a shared helper.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 208
|
|
3
|
+
issue_title: "Extract shared test fixtures to reduce test duplication"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #208 — Extract shared test fixtures to reduce test duplication
|
|
7
|
+
|
|
8
|
+
## Stage: Implementation — TDD (2026-05-25T21:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Completed all 10 TDD steps plus a type-fix commit.
|
|
13
|
+
Created 4 new files (`runner-io.ts`, `runner-io.test.ts`, `ui-stubs.ts`, `ui-stubs.test.ts`) and migrated 7 existing test files.
|
|
14
|
+
Test count grew from 884 → 913 (+29 tests in new helper unit tests).
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- Vitest v4 changed `vi.fn()` without implementation annotation to type `Mock<Procedure | Constructable>`, which is NOT assignable to specific function signatures in production interfaces.
|
|
19
|
+
The fix was to add typed implementation annotations (`vi.fn((_path: string): boolean => false)`) to all vi.fn() stubs in the shared factories.
|
|
20
|
+
This was a new friction point not anticipated in the plan.
|
|
21
|
+
- The plan's `assemblerOverrides` parameter in `createRunnerIO()` was removed because the `??` union typing caused `Mock<Procedure | Constructable> | Mock<specific-fn>` which TypeScript couldn't resolve as assignable to `RunnerIO`.
|
|
22
|
+
No consumer test actually used the override parameter, so removing it simplified both the implementation and the type story.
|
|
23
|
+
- The `findAgentFile` signature in `AgentFileOps` is `(name: string, dirs: string[])` — the second parameter is `string[]`, not a second string as initially assumed from test patterns.
|
|
24
|
+
This was caught by `pnpm run check`.
|
|
25
|
+
- The `agent-config-editor.test.ts` migration removed the `import type { AgentConfig }` import that was still needed by `buildEjectContent` tests further down the file.
|
|
26
|
+
Also caught by `pnpm run check`.
|
|
27
|
+
- `STUB_SNAPSHOT` replacement was safe: no consumer test asserts on snapshot field values.
|
|
28
|
+
The `mockSnapshot` in `agent-manager.test.ts` had `systemPrompt: "parent prompt"` vs `STUB_SNAPSHOT`'s `"test prompt"` but this caused no test failures.
|
|
29
|
+
- Architecture doc was updated to reference `test/helpers/` (correcting `test/fixtures/` from the original entry).
|
|
30
|
+
|
|
31
|
+
## Stage: Planning (2026-05-25T20:00:00Z)
|
|
32
|
+
|
|
33
|
+
### Session summary
|
|
34
|
+
|
|
35
|
+
Analyzed the three heaviest test clone families identified by fallow and designed a 10-step TDD plan to extract shared factories into `test/helpers/`.
|
|
36
|
+
Decided to follow the existing `test/helpers/` convention rather than the `test/fixtures/` directory mentioned in the issue and architecture doc.
|
|
37
|
+
|
|
38
|
+
### Observations
|
|
39
|
+
|
|
40
|
+
- Issue #131 (closed) already extracted `createMockSession`, `createToolDeps`, and `createTestRecord` — this issue targets the remaining duplication.
|
|
41
|
+
- The `createRunnerIO` factory in `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts` includes stale `buildMemoryBlock` and `buildReadOnlyMemoryBlock` stubs that no longer match the `AssemblerIO` interface — the shared factory will clean these up as a side benefit.
|
|
42
|
+
- Session mock factories in the runner tests are structurally specialized (each serves a different test purpose) and were explicitly scoped as non-goals — extracting them would create a confusing multi-mode factory.
|
|
43
|
+
- The `agent-runner-extension-tools.test.ts` uses a mutable `agentConfigMock.current` pattern that doesn't fit into a shared static factory — only `createRunnerIO` is shared from that file.
|
|
44
|
+
- `STUB_SNAPSHOT` from `stub-ctx.ts` can replace all 5 local `ParentSnapshot` definitions — verified no test asserts on the specific field values.
|
|
45
|
+
- The `agent-manager.test.ts` internal duplication (~42 repetitive spawn calls) is best handled with local `spawnBg()`/`spawnFg()` helpers rather than cross-file extraction.
|
package/package.json
CHANGED
package/src/ui/agent-widget.ts
CHANGED
|
@@ -14,6 +14,42 @@ import { renderWidgetLines } from "#src/ui/widget-renderer";
|
|
|
14
14
|
|
|
15
15
|
// ---- Types ----
|
|
16
16
|
|
|
17
|
+
/** Minimal agent shape needed for widget lifecycle decisions. */
|
|
18
|
+
interface AgentSummary {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly status: string;
|
|
21
|
+
readonly completedAt?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Lightweight state snapshot used by AgentWidget.update() to decide what to show. */
|
|
25
|
+
export interface WidgetState {
|
|
26
|
+
readonly runningCount: number;
|
|
27
|
+
readonly queuedCount: number;
|
|
28
|
+
readonly hasFinished: boolean;
|
|
29
|
+
/** True when runningCount > 0 || queuedCount > 0. Included for call-site readability. */
|
|
30
|
+
readonly hasActive: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Count agents by status and return a lightweight state snapshot.
|
|
35
|
+
* Pure function — no IO, no side effects. Exported for direct unit testing.
|
|
36
|
+
*/
|
|
37
|
+
export function assembleWidgetState(
|
|
38
|
+
agents: readonly AgentSummary[],
|
|
39
|
+
shouldShowFinished: (agentId: string, status: string) => boolean,
|
|
40
|
+
): WidgetState {
|
|
41
|
+
let runningCount = 0;
|
|
42
|
+
let queuedCount = 0;
|
|
43
|
+
let hasFinished = false;
|
|
44
|
+
for (const a of agents) {
|
|
45
|
+
if (a.status === "running") { runningCount++; }
|
|
46
|
+
else if (a.status === "queued") { queuedCount++; }
|
|
47
|
+
else if (a.completedAt && shouldShowFinished(a.id, a.status)) { hasFinished = true; }
|
|
48
|
+
}
|
|
49
|
+
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
50
|
+
return { runningCount, queuedCount, hasFinished, hasActive };
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
export type UICtx = {
|
|
18
54
|
setStatus(key: string, text: string | undefined): void;
|
|
19
55
|
setWidget(
|
|
@@ -108,55 +144,59 @@ export class AgentWidget {
|
|
|
108
144
|
});
|
|
109
145
|
}
|
|
110
146
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (a.status === "running") { runningCount++; }
|
|
122
|
-
else if (a.status === "queued") { queuedCount++; }
|
|
123
|
-
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
|
|
147
|
+
/**
|
|
148
|
+
* Unregister the widget, clear the status bar, stop the interval timer, and
|
|
149
|
+
* purge stale `finishedTurnAge` entries for agents no longer in `allAgents`.
|
|
150
|
+
* Called only from `update`'s idle path — not from `dispose`.
|
|
151
|
+
*/
|
|
152
|
+
private clearWidget(allAgents: readonly AgentSummary[]): void {
|
|
153
|
+
if (this.widgetRegistered) {
|
|
154
|
+
this.uiCtx!.setWidget("agents", undefined);
|
|
155
|
+
this.widgetRegistered = false;
|
|
156
|
+
this.tui = undefined;
|
|
124
157
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (!hasActive && !hasFinished) {
|
|
129
|
-
if (this.widgetRegistered) {
|
|
130
|
-
this.uiCtx.setWidget("agents", undefined);
|
|
131
|
-
this.widgetRegistered = false;
|
|
132
|
-
this.tui = undefined;
|
|
133
|
-
}
|
|
134
|
-
if (this.lastStatusText !== undefined) {
|
|
135
|
-
this.uiCtx.setStatus("subagents", undefined);
|
|
136
|
-
this.lastStatusText = undefined;
|
|
137
|
-
}
|
|
138
|
-
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
139
|
-
// Clean up stale entries
|
|
140
|
-
for (const [id] of this.finishedTurnAge) {
|
|
141
|
-
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
142
|
-
}
|
|
143
|
-
return;
|
|
158
|
+
if (this.lastStatusText !== undefined) {
|
|
159
|
+
this.uiCtx!.setStatus("subagents", undefined);
|
|
160
|
+
this.lastStatusText = undefined;
|
|
144
161
|
}
|
|
162
|
+
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
163
|
+
for (const [id] of this.finishedTurnAge) {
|
|
164
|
+
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
145
167
|
|
|
146
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Compute the status bar text from the current widget state and call
|
|
170
|
+
* `setStatus` only when it differs from the last cached value.
|
|
171
|
+
*/
|
|
172
|
+
private updateStatusBar(state: WidgetState): void {
|
|
147
173
|
let newStatusText: string | undefined;
|
|
148
|
-
if (hasActive) {
|
|
174
|
+
if (state.hasActive) {
|
|
149
175
|
const statusParts: string[] = [];
|
|
150
|
-
if (runningCount > 0) statusParts.push(`${runningCount} running`);
|
|
151
|
-
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
|
|
152
|
-
const total = runningCount + queuedCount;
|
|
176
|
+
if (state.runningCount > 0) statusParts.push(`${state.runningCount} running`);
|
|
177
|
+
if (state.queuedCount > 0) statusParts.push(`${state.queuedCount} queued`);
|
|
178
|
+
const total = state.runningCount + state.queuedCount;
|
|
153
179
|
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
|
|
154
180
|
}
|
|
155
181
|
if (newStatusText !== this.lastStatusText) {
|
|
156
|
-
this.uiCtx
|
|
182
|
+
this.uiCtx!.setStatus("subagents", newStatusText);
|
|
157
183
|
this.lastStatusText = newStatusText;
|
|
158
184
|
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Force an immediate widget update. */
|
|
188
|
+
update() {
|
|
189
|
+
if (!this.uiCtx) return;
|
|
190
|
+
|
|
191
|
+
const allAgents = this.manager.listAgents();
|
|
192
|
+
const state = assembleWidgetState(allAgents, (id, status) => this.shouldShowFinished(id, status));
|
|
193
|
+
|
|
194
|
+
if (!state.hasActive && !state.hasFinished) {
|
|
195
|
+
this.clearWidget(allAgents);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
159
198
|
|
|
199
|
+
this.updateStatusBar(state);
|
|
160
200
|
this.widgetFrame++;
|
|
161
201
|
|
|
162
202
|
// Register widget callback once; subsequent updates use requestRender()
|