@gotgenes/pi-subagents 6.0.0 → 6.1.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.
@@ -0,0 +1,30 @@
1
+ ---
2
+ issue: 102
3
+ issue_title: "Consolidate test AgentRecord construction into a shared factory"
4
+ ---
5
+
6
+ # Retro: #102 — Consolidate test AgentRecord construction into a shared factory
7
+
8
+ ## Final Retrospective (2026-05-20T15:30:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned, implemented, and shipped a shared `createTestRecord()` factory in `test/helpers/make-record.ts`, migrating 7 test files from local factories and inline literals.
13
+ The session completed in three slash-command phases (plan → TDD → ship) with zero rework, zero test failures, and zero deviations from the plan.
14
+ Released as `pi-subagents-v6.0.1`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The Explore subagent surveyed all 8 test files' factory patterns in parallel, producing a structured comparison table that directly informed the plan's default-value decisions.
21
+ - Before migrating `service-adapter.test.ts`, reading the `toSubagentRecord` source confirmed that `!== undefined` guards make absent-property vs. property-set-to-undefined semantically equivalent — avoiding a subtle test failure.
22
+ - All 4 TDD steps passed on first run, confirming the plan's migration strategy (preserve old defaults via overrides) was sound.
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ No friction points — the mechanical nature of the refactoring and the well-specified plan eliminated ambiguity.
27
+
28
+ #### What caused friction (user side)
29
+
30
+ No friction points — the three-phase slash-command workflow required no manual intervention.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -9,10 +9,11 @@
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { AgentRecord } from "./agent-record.js";
12
13
  import type { AgentRunner, ToolActivity } from "./agent-runner.js";
13
14
  import { debugLog } from "./debug.js";
14
15
  import type { RunConfig } from "./runtime.js";
15
- import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
16
+ import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
16
17
  import { addUsage } from "./usage.js";
17
18
  import type { WorktreeManager } from "./worktree.js";
18
19
 
@@ -133,18 +134,15 @@ export class AgentManager {
133
134
  ): string {
134
135
  const id = randomUUID().slice(0, 17);
135
136
  const abortController = new AbortController();
136
- const record: AgentRecord = {
137
+ const record = new AgentRecord({
137
138
  id,
138
139
  type,
139
140
  description: options.description,
140
141
  status: options.isBackground ? "queued" : "running",
141
- toolUses: 0,
142
142
  startedAt: Date.now(),
143
143
  abortController,
144
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
145
- compactionCount: 0,
146
144
  invocation: options.invocation,
147
- };
145
+ });
148
146
  this.agents.set(id, record);
149
147
 
150
148
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
@@ -184,8 +182,7 @@ export class AgentManager {
184
182
  worktreeCwd = wt.path;
185
183
  }
186
184
 
187
- record.status = "running";
188
- record.startedAt = Date.now();
185
+ record.markRunning(Date.now());
189
186
  if (options.isBackground) this.runningBackground++;
190
187
  this.onStart?.(record);
191
188
 
@@ -244,27 +241,26 @@ export class AgentManager {
244
241
  },
245
242
  })
246
243
  .then(({ responseText, session, aborted, steered, sessionFile }) => {
247
- // Don't overwrite status if externally stopped via abort()
248
- if (record.status !== "stopped") {
249
- record.status = aborted ? "aborted" : steered ? "steered" : "completed";
250
- }
251
- record.result = responseText;
252
- record.session = session;
253
- record.completedAt ??= Date.now();
254
- if (sessionFile) record.outputFile = sessionFile;
255
-
256
244
  detach();
257
245
 
258
- // Clean up worktree if used
246
+ // Clean up worktree before transition so the final result includes branch text
247
+ let finalResult = responseText;
259
248
  if (record.worktree) {
260
249
  const wtResult = this.worktrees.cleanup(record.worktree, options.description);
261
250
  record.worktreeResult = wtResult;
262
251
  if (wtResult.hasChanges && wtResult.branch) {
263
- record.result = (record.result ?? "") +
264
- `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
252
+ finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
265
253
  }
266
254
  }
267
255
 
256
+ // Transition — guards against overwriting externally-stopped status
257
+ if (aborted) record.markAborted(finalResult);
258
+ else if (steered) record.markSteered(finalResult);
259
+ else record.markCompleted(finalResult);
260
+
261
+ record.session = session;
262
+ if (sessionFile) record.outputFile = sessionFile;
263
+
268
264
  if (options.isBackground) {
269
265
  this.runningBackground--;
270
266
  try { this.onComplete?.(record); } catch (err) { debugLog("onComplete callback", err); }
@@ -273,17 +269,10 @@ export class AgentManager {
273
269
  return responseText;
274
270
  })
275
271
  .catch((err) => {
276
- // Don't overwrite status if externally stopped via abort()
277
- if (record.status !== "stopped") {
278
- record.status = "error";
279
- }
280
- record.error = err instanceof Error ? err.message : String(err);
281
- record.completedAt ??= Date.now();
272
+ record.markError(err);
282
273
 
283
274
  detach();
284
275
 
285
-
286
-
287
276
  // Best-effort worktree cleanup on error
288
277
  if (record.worktree) {
289
278
  try {
@@ -314,9 +303,7 @@ export class AgentManager {
314
303
  } catch (err) {
315
304
  // Late failure (e.g. strict worktree-isolation) — surface on the record
316
305
  // so the user/agent can see it via /agents, then keep draining.
317
- record.status = "error";
318
- record.error = err instanceof Error ? err.message : String(err);
319
- record.completedAt = Date.now();
306
+ record.markError(err);
320
307
  this.onComplete?.(record);
321
308
  }
322
309
  }
@@ -350,11 +337,7 @@ export class AgentManager {
350
337
  const record = this.agents.get(id);
351
338
  if (!record?.session) return undefined;
352
339
 
353
- record.status = "running";
354
- record.startedAt = Date.now();
355
- record.completedAt = undefined;
356
- record.result = undefined;
357
- record.error = undefined;
340
+ record.resetForResume(Date.now());
358
341
 
359
342
  try {
360
343
  const responseText = await this.runner.resume(record.session, prompt, {
@@ -370,13 +353,9 @@ export class AgentManager {
370
353
  },
371
354
  signal,
372
355
  });
373
- record.status = "completed";
374
- record.result = responseText;
375
- record.completedAt = Date.now();
356
+ record.markCompleted(responseText);
376
357
  } catch (err) {
377
- record.status = "error";
378
- record.error = err instanceof Error ? err.message : String(err);
379
- record.completedAt = Date.now();
358
+ record.markError(err);
380
359
  }
381
360
 
382
361
  return record;
@@ -399,15 +378,13 @@ export class AgentManager {
399
378
  // Remove from queue if queued
400
379
  if (record.status === "queued") {
401
380
  this.queue = this.queue.filter(q => q.id !== id);
402
- record.status = "stopped";
403
- record.completedAt = Date.now();
381
+ record.markStopped();
404
382
  return true;
405
383
  }
406
384
 
407
385
  if (record.status !== "running") return false;
408
386
  record.abortController?.abort();
409
- record.status = "stopped";
410
- record.completedAt = Date.now();
387
+ record.markStopped();
411
388
  return true;
412
389
  }
413
390
 
@@ -452,8 +429,7 @@ export class AgentManager {
452
429
  for (const queued of this.queue) {
453
430
  const record = this.agents.get(queued.id);
454
431
  if (record) {
455
- record.status = "stopped";
456
- record.completedAt = Date.now();
432
+ record.markStopped();
457
433
  count++;
458
434
  }
459
435
  }
@@ -462,8 +438,7 @@ export class AgentManager {
462
438
  for (const record of this.agents.values()) {
463
439
  if (record.status === "running") {
464
440
  record.abortController?.abort();
465
- record.status = "stopped";
466
- record.completedAt = Date.now();
441
+ record.markStopped();
467
442
  count++;
468
443
  }
469
444
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * agent-record.ts — AgentRecord class with encapsulated status-transition logic.
3
+ *
4
+ * Status transitions (status, result, error, startedAt, completedAt) are owned
5
+ * by the class and exposed via transition methods. External code reads these
6
+ * fields through public properties but cannot write them directly.
7
+ *
8
+ * Non-transition state (session, toolUses, lifetimeUsage, etc.) remains public.
9
+ */
10
+
11
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
+ import type { AgentInvocation, SubagentType } from "./types.js";
13
+ import type { LifetimeUsage } from "./usage.js";
14
+
15
+ export type AgentRecordStatus =
16
+ | "queued"
17
+ | "running"
18
+ | "completed"
19
+ | "steered"
20
+ | "aborted"
21
+ | "stopped"
22
+ | "error";
23
+
24
+ export interface AgentRecordInit {
25
+ id: string;
26
+ type: SubagentType;
27
+ description: string;
28
+ status?: AgentRecordStatus;
29
+ startedAt?: number;
30
+ completedAt?: number;
31
+ result?: string;
32
+ error?: string;
33
+ toolUses?: number;
34
+ lifetimeUsage?: LifetimeUsage;
35
+ compactionCount?: number;
36
+ abortController?: AbortController;
37
+ invocation?: AgentInvocation;
38
+ session?: AgentSession;
39
+ promise?: Promise<string>;
40
+ resultConsumed?: boolean;
41
+ pendingSteers?: string[];
42
+ worktree?: { path: string; branch: string };
43
+ worktreeResult?: { hasChanges: boolean; branch?: string };
44
+ toolCallId?: string;
45
+ outputFile?: string;
46
+ }
47
+
48
+ export class AgentRecord {
49
+ // Identity — set once at construction
50
+ readonly id: string;
51
+ readonly type: SubagentType;
52
+ readonly description: string;
53
+ readonly invocation?: AgentInvocation;
54
+
55
+ // Transition state — encapsulated behind getters, mutated only via transition methods
56
+ private _status: AgentRecordStatus;
57
+ get status(): AgentRecordStatus { return this._status; }
58
+
59
+ private _result?: string;
60
+ get result(): string | undefined { return this._result; }
61
+
62
+ private _error?: string;
63
+ get error(): string | undefined { return this._error; }
64
+
65
+ private _startedAt: number;
66
+ get startedAt(): number { return this._startedAt; }
67
+
68
+ private _completedAt?: number;
69
+ get completedAt(): number | undefined { return this._completedAt; }
70
+
71
+ // Non-transition mutable state
72
+ toolUses: number;
73
+ lifetimeUsage: LifetimeUsage;
74
+ compactionCount: number;
75
+ session?: AgentSession;
76
+ abortController?: AbortController;
77
+ promise?: Promise<string>;
78
+ resultConsumed?: boolean;
79
+ pendingSteers?: string[];
80
+ worktree?: { path: string; branch: string };
81
+ worktreeResult?: { hasChanges: boolean; branch?: string };
82
+ toolCallId?: string;
83
+ outputFile?: string;
84
+
85
+ constructor(init: AgentRecordInit) {
86
+ this.id = init.id;
87
+ this.type = init.type;
88
+ this.description = init.description;
89
+ this.invocation = init.invocation;
90
+
91
+ this._status = init.status ?? "queued";
92
+ this._result = init.result;
93
+ this._error = init.error;
94
+ this._startedAt = init.startedAt ?? Date.now();
95
+ this._completedAt = init.completedAt;
96
+
97
+ this.toolUses = init.toolUses ?? 0;
98
+ this.lifetimeUsage = init.lifetimeUsage ?? { input: 0, output: 0, cacheWrite: 0 };
99
+ this.compactionCount = init.compactionCount ?? 0;
100
+ this.abortController = init.abortController;
101
+ this.session = init.session;
102
+ this.promise = init.promise;
103
+ this.resultConsumed = init.resultConsumed;
104
+ this.pendingSteers = init.pendingSteers;
105
+ this.worktree = init.worktree;
106
+ this.worktreeResult = init.worktreeResult;
107
+ this.toolCallId = init.toolCallId;
108
+ this.outputFile = init.outputFile;
109
+ }
110
+
111
+ /** Transition to running state. Sets status and startedAt. */
112
+ markRunning(startedAt: number): void {
113
+ this._status = "running";
114
+ this._startedAt = startedAt;
115
+ }
116
+
117
+ /**
118
+ * Transition to completed state.
119
+ * Always sets result and completedAt (??=). Only changes status if not stopped.
120
+ */
121
+ markCompleted(result: string, completedAt?: number): void {
122
+ this._result = result;
123
+ this._completedAt ??= completedAt ?? Date.now();
124
+ if (this._status !== "stopped") {
125
+ this._status = "completed";
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Transition to aborted state.
131
+ * Always sets result and completedAt (??=). Only changes status if not stopped.
132
+ */
133
+ markAborted(result: string, completedAt?: number): void {
134
+ this._result = result;
135
+ this._completedAt ??= completedAt ?? Date.now();
136
+ if (this._status !== "stopped") {
137
+ this._status = "aborted";
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Transition to steered state.
143
+ * Always sets result and completedAt (??=). Only changes status if not stopped.
144
+ */
145
+ markSteered(result: string, completedAt?: number): void {
146
+ this._result = result;
147
+ this._completedAt ??= completedAt ?? Date.now();
148
+ if (this._status !== "stopped") {
149
+ this._status = "steered";
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Transition to error state.
155
+ * Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
156
+ */
157
+ markError(error: unknown, completedAt?: number): void {
158
+ this._error = error instanceof Error ? error.message : String(error);
159
+ this._completedAt ??= completedAt ?? Date.now();
160
+ if (this._status !== "stopped") {
161
+ this._status = "error";
162
+ }
163
+ }
164
+
165
+ /** Transition to stopped state. Always valid — no guard. */
166
+ markStopped(completedAt?: number): void {
167
+ this._status = "stopped";
168
+ this._completedAt = completedAt ?? Date.now();
169
+ }
170
+
171
+ /** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
172
+ resetForResume(startedAt: number): void {
173
+ this._status = "running";
174
+ this._startedAt = startedAt;
175
+ this._completedAt = undefined;
176
+ this._result = undefined;
177
+ this._error = undefined;
178
+ }
179
+ }
package/src/types.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
- import type { LifetimeUsage } from "./usage.js";
8
6
 
7
+ export type { AgentRecordInit, AgentRecordStatus } from "./agent-record.js";
8
+
9
+ export { AgentRecord } from "./agent-record.js";
9
10
  export type { ThinkingLevel };
10
11
 
11
12
  /** Agent type: any string name (built-in defaults or user-defined). */
@@ -55,43 +56,6 @@ export interface AgentConfig {
55
56
  source?: "default" | "project" | "global";
56
57
  }
57
58
 
58
- export interface AgentRecord {
59
- id: string;
60
- type: SubagentType;
61
- description: string;
62
- status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
63
- result?: string;
64
- error?: string;
65
- toolUses: number;
66
- startedAt: number;
67
- completedAt?: number;
68
- session?: AgentSession;
69
- abortController?: AbortController;
70
- promise?: Promise<string>;
71
- /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
72
- resultConsumed?: boolean;
73
- /** Steering messages queued before the session was ready. */
74
- pendingSteers?: string[];
75
- /** Worktree info if the agent is running in an isolated worktree. */
76
- worktree?: { path: string; branch: string };
77
- /** Worktree cleanup result after agent completion. */
78
- worktreeResult?: { hasChanges: boolean; branch?: string };
79
- /** The tool_use_id from the original Agent tool call. */
80
- toolCallId?: string;
81
- /** Path to the persisted session transcript file. */
82
- outputFile?: string;
83
- /**
84
- * Lifetime usage breakdown, accumulated via `message_end` events. Survives
85
- * compaction. Total = input + output + cacheWrite (cacheRead deliberately
86
- * excluded — see issue #38). Initialized to zeros at spawn.
87
- */
88
- lifetimeUsage: LifetimeUsage;
89
- /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
90
- compactionCount: number;
91
- /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
92
- invocation?: AgentInvocation;
93
- }
94
-
95
59
  export interface AgentInvocation {
96
60
  /** Short display name, e.g. "haiku" — only set when different from parent. */
97
61
  modelName?: string;