@esso0428/pi-subagents 0.15.5 → 0.15.6

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -22
  2. package/README.md +5 -5
  3. package/dist/agent-history-list.d.ts +18 -0
  4. package/dist/agent-history-list.js +68 -0
  5. package/dist/agent-history.d.ts +1 -1
  6. package/dist/agent-history.js +31 -34
  7. package/dist/agent-manager.d.ts +22 -8
  8. package/dist/agent-manager.js +100 -93
  9. package/dist/agent-runner.js +67 -86
  10. package/dist/agent-types.js +27 -45
  11. package/dist/context.js +2 -6
  12. package/dist/cross-extension-rpc.js +5 -9
  13. package/dist/custom-agents.js +15 -18
  14. package/dist/default-agents.js +1 -4
  15. package/dist/enabled-models.js +11 -16
  16. package/dist/env.js +1 -4
  17. package/dist/group-join.js +1 -5
  18. package/dist/index.js +273 -286
  19. package/dist/invocation-config.js +2 -6
  20. package/dist/memory.js +24 -34
  21. package/dist/model-resolver.js +1 -4
  22. package/dist/nico-overrides.js +14 -20
  23. package/dist/output-file.js +16 -22
  24. package/dist/prompts.js +1 -4
  25. package/dist/schedule-store.js +16 -21
  26. package/dist/schedule.js +8 -12
  27. package/dist/settings.js +15 -23
  28. package/dist/skill-loader.js +20 -23
  29. package/dist/status-note.js +1 -4
  30. package/dist/types.d.ts +0 -1
  31. package/dist/types.js +1 -4
  32. package/dist/ui/agent-widget.d.ts +1 -5
  33. package/dist/ui/agent-widget.js +25 -49
  34. package/dist/ui/conversation-blocks.d.ts +18 -0
  35. package/dist/ui/conversation-blocks.js +214 -0
  36. package/dist/ui/conversation-nvim.d.ts +7 -0
  37. package/dist/ui/conversation-nvim.js +116 -0
  38. package/dist/ui/conversation-search.d.ts +38 -0
  39. package/dist/ui/conversation-search.js +123 -0
  40. package/dist/ui/conversation-viewer.d.ts +55 -19
  41. package/dist/ui/conversation-viewer.js +398 -215
  42. package/dist/ui/fleet-list.d.ts +23 -11
  43. package/dist/ui/fleet-list.js +85 -78
  44. package/dist/ui/schedule-menu.js +1 -4
  45. package/dist/ui/viewer-keys.js +7 -10
  46. package/dist/usage.js +4 -10
  47. package/dist/worktree.js +26 -31
  48. package/package.json +1 -1
  49. package/src/agent-history-list.ts +91 -0
  50. package/src/agent-history.ts +12 -17
  51. package/src/agent-manager.ts +108 -81
  52. package/src/agent-runner.ts +5 -13
  53. package/src/index.ts +84 -89
  54. package/src/output-file.ts +1 -1
  55. package/src/types.ts +0 -1
  56. package/src/ui/agent-widget.ts +1 -8
  57. package/src/ui/conversation-blocks.ts +270 -0
  58. package/src/ui/conversation-nvim.ts +127 -0
  59. package/src/ui/conversation-search.ts +161 -0
  60. package/src/ui/conversation-viewer.ts +376 -205
  61. package/src/ui/fleet-list.ts +73 -52
  62. package/test/agent-history-list.test.ts +121 -0
  63. package/test/agent-manager-history.test.ts +59 -58
  64. package/test/agent-menu-history-navigation.test.ts +86 -0
  65. package/test/ui/conversation-blocks.test.ts +72 -0
  66. package/test/ui/conversation-search.test.ts +55 -0
  67. package/test/ui/conversation-viewer.test.ts +132 -0
  68. package/test/ui/fleet-list.test.ts +131 -0
  69. package/dist/ui/markdown-result.d.ts +0 -3
  70. package/dist/ui/markdown-result.js +0 -53
  71. package/src/ui/markdown-result.ts +0 -56
  72. package/test/agent-history.test.ts +0 -63
  73. package/test/ui/markdown-result.test.ts +0 -45
@@ -4,7 +4,7 @@
4
4
  * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
5
5
  * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
6
6
  * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
7
- * A viewer stays open when its agent finishes; finished agents linger briefly in the list.
7
+ * A viewer stays open when its agent finishes so the final output remains readable.
8
8
  *
9
9
  * Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key
10
10
  * handling goes through `onTerminalInput` — which fires before the focused editor and
@@ -12,22 +12,22 @@
12
12
  */
13
13
 
14
14
  import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
16
  import type { AgentManager } from "../agent-manager.js";
16
- import { hasAgentHistory, readAgentHistory } from "../agent-history.js";
17
+ import { canOpenActiveAgent } from "../agent-history-list.js";
17
18
  import type { AgentRecord } from "../types.js";
18
19
  import { getLifetimeTotal } from "../usage.js";
19
20
  import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
20
- import { ConversationViewer, createStaticConversationSource, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
21
+ import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
21
22
 
22
23
  /** Widget key for the below-editor fleet list. */
23
24
  const FLEET_KEY = "fleet";
24
- /** Max agent rows shown at once; extras collapse into a "↓ N more" indicator. */
25
- const MAX_AGENT_ROWS = 5;
25
+ /** Maximum total rows contributed by FleetView, including hint and indicators. */
26
+ export const FLEET_MAX_RENDER_ROWS = 6;
27
+ const FLEET_BASE_ROWS = 2; // hint + main
28
+ const FLEET_BODY_ROWS = FLEET_MAX_RENDER_ROWS - FLEET_BASE_ROWS;
26
29
  /** Re-render cadence so elapsed/token stats tick while agents run. */
27
30
  const TICK_MS = 200;
28
- /** How long a finished agent lingers in the list before it drops out. */
29
- const FINISHED_LINGER_MS = 4000;
30
-
31
31
  /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
32
32
  export type FleetUICtx = {
33
33
  setWidget(
@@ -62,6 +62,32 @@ export function formatFleetTokens(count: number): string {
62
62
  return `↓ ${compact} tokens`;
63
63
  }
64
64
 
65
+ /**
66
+ * Calculate a bounded agent slice for the complete logical roster.
67
+ * `selectedRosterIndex` includes main at index 0; `end` is exclusive.
68
+ */
69
+ export function calculateFleetAgentWindow(
70
+ agentCount: number,
71
+ selectedRosterIndex: number,
72
+ ): { start: number; end: number; hiddenAbove: number; hiddenBelow: number } {
73
+ const count = Math.max(0, Math.floor(agentCount));
74
+ if (count === 0) return { start: 0, end: 0, hiddenAbove: 0, hiddenBelow: 0 };
75
+
76
+ const selectedAgent = Math.min(
77
+ count - 1,
78
+ Math.max(0, Math.floor(selectedRosterIndex) - 1),
79
+ );
80
+ const reserveAbove = selectedAgent > 0 ? 1 : 0;
81
+ const reserveBelow = selectedAgent < count - 1 ? 1 : 0;
82
+ const visibleCount = Math.max(1, FLEET_BODY_ROWS - reserveAbove - reserveBelow);
83
+ const start = Math.min(
84
+ Math.max(0, selectedAgent - visibleCount + 1),
85
+ Math.max(0, count - visibleCount),
86
+ );
87
+ const end = Math.min(count, start + visibleCount);
88
+ return { start, end, hiddenAbove: start, hiddenBelow: count - end };
89
+ }
90
+
65
91
  /**
66
92
  * Place `right` flush to `width`, truncating `left` first so the stats survive.
67
93
  * The final clamp guarantees the line never exceeds `width` (which would wrap and
@@ -80,6 +106,7 @@ export class FleetList {
80
106
  private tui: any | undefined;
81
107
  private inputUnsub: (() => void) | undefined;
82
108
  private widgetRegistered = false;
109
+ private lastRosterKey: string | undefined;
83
110
  private timer: ReturnType<typeof setInterval> | undefined;
84
111
 
85
112
  private enabled = true;
@@ -95,6 +122,8 @@ export class FleetList {
95
122
  private manager: AgentManager,
96
123
  private agentActivity: Map<string, AgentActivity>,
97
124
  private getCwd: () => string | undefined = () => undefined,
125
+ private pi?: ExtensionAPI,
126
+ private getCtx: () => ExtensionContext | undefined = () => undefined,
98
127
  ) {}
99
128
 
100
129
  // ---- Lifecycle ----
@@ -112,6 +141,7 @@ export class FleetList {
112
141
  this.inputUnsub?.();
113
142
  this.ui = ui;
114
143
  this.widgetRegistered = false;
144
+ this.lastRosterKey = undefined;
115
145
  this.tui = undefined;
116
146
  this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
117
147
  }
@@ -123,7 +153,7 @@ export class FleetList {
123
153
 
124
154
  /**
125
155
  * Called when an agent finishes. The viewer (if open on it) stays open so the
126
- * final output remains readable, and the row lingers in the list — just refresh.
156
+ * final output remains readable; refresh the active-only roster.
127
157
  */
128
158
  onAgentFinished(_id: string): void {
129
159
  this.update();
@@ -137,6 +167,7 @@ export class FleetList {
137
167
  this.viewingAgentId = undefined;
138
168
  if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
139
169
  this.widgetRegistered = false;
170
+ this.lastRosterKey = undefined;
140
171
  this.tui = undefined;
141
172
  this.active = false;
142
173
  // Null last so a `viewerClose()` microtask above can't re-register the widget.
@@ -146,7 +177,11 @@ export class FleetList {
146
177
  /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
147
178
  update(): void {
148
179
  if (!this.ui) return;
149
- const hasAgents = this.enabled && this.agentRecords().length > 0;
180
+ const records = this.agentRecords();
181
+ const hasAgents = this.enabled && records.length > 0;
182
+ const rosterKey = records.map(record => record.id).join("\0");
183
+ const rosterChanged = rosterKey !== this.lastRosterKey;
184
+ this.lastRosterKey = rosterKey;
150
185
 
151
186
  if (!hasAgents) {
152
187
  if (this.widgetRegistered) {
@@ -157,6 +192,7 @@ export class FleetList {
157
192
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
158
193
  this.active = false;
159
194
  this.selectedIndex = 0;
195
+ this.lastRosterKey = undefined;
160
196
  return;
161
197
  }
162
198
 
@@ -173,35 +209,21 @@ export class FleetList {
173
209
  }, { placement: "belowEditor" });
174
210
  this.widgetRegistered = true;
175
211
  } else {
176
- this.tui?.requestRender();
212
+ this.tui?.requestRender(rosterChanged);
177
213
  }
178
214
  }
179
215
 
180
216
  // ---- Roster ----
181
217
 
182
218
  /**
183
- * Agents shown in the list, ordered earliest-launched first so the ones you
184
- * started sooner sit at the top. Live rows are openable through their session;
185
- * terminal rows are included only when their durable transcript exists. This
186
- * prevents Enter from dead-ending on stale rehydrated metadata.
187
- * Included: running/queued, plus the agent currently being viewed, plus
188
- * recently-finished ones (they linger briefly before dropping out).
189
- * Pending agents with no session yet are hidden until they start.
190
- * (`listAgents()` is newest-first, so we re-sort.)
219
+ * Active agents shown in the list, ordered earliest-launched first so the
220
+ * ones you started sooner sit at the top. Pending agents with no session yet
221
+ * are hidden until they start. (`listAgents()` is newest-first, so we
222
+ * re-sort.)
191
223
  */
192
224
  private agentRecords(): AgentRecord[] {
193
- const now = Date.now();
194
225
  return this.manager.listAgents()
195
- .filter(a => {
196
- const live = a.session && (
197
- a.status === "running" || a.status === "queued"
198
- || a.id === this.viewingAgentId
199
- || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
200
- );
201
- const cwd = this.getCwd();
202
- const history = cwd !== undefined && hasAgentHistory(cwd, a.transcriptPath);
203
- return !!live || history;
204
- })
226
+ .filter(record => canOpenActiveAgent(record))
205
227
  .sort((a, b) => a.startedAt - b.startedAt);
206
228
  }
207
229
 
@@ -296,18 +318,18 @@ export class FleetList {
296
318
  }
297
319
  const record = entry.record;
298
320
  if (!this.ui) return;
299
- const session = record.session ?? (record.transcriptPath && this.getCwd()
300
- ? (() => {
301
- const messages = readAgentHistory(this.getCwd()!, record.transcriptPath!);
302
- return messages ? createStaticConversationSource(messages) : undefined;
303
- })()
304
- : undefined);
305
- if (!session) {
306
- this.ui.notify(`Agent is ${record.status} — no history available.`, "info");
321
+ if (!canOpenActiveAgent(record) || !record.session) {
322
+ this.ui.notify(`Agent is ${record.status} — no live session available.`, "info");
323
+ return;
324
+ }
325
+ const session = record.session;
326
+ const ctx = this.getCtx();
327
+ const pi = this.pi;
328
+ if (!ctx || !pi) {
329
+ this.ui.notify("Agent conversation is unavailable — no active session.", "info");
307
330
  return;
308
331
  }
309
332
  const activity = this.agentActivity.get(record.id);
310
- const isLive = record.session !== undefined;
311
333
  this.viewingAgentId = record.id;
312
334
 
313
335
  void this.ui.custom<undefined>(
@@ -320,11 +342,12 @@ export class FleetList {
320
342
  activity,
321
343
  theme,
322
344
  done,
323
- isLive ? () => {
345
+ () => {
324
346
  if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
325
- } : undefined,
347
+ },
326
348
  keybindings,
327
- isLive ? (message: string) => this.manager.steer(record.id, message) : undefined,
349
+ (message: string) => this.manager.steer(record.id, message),
350
+ { pi, ctx, readOnly: false },
328
351
  );
329
352
  },
330
353
  {
@@ -363,20 +386,18 @@ export class FleetList {
363
386
  : "esc to interrupt · ← for agents · ↓ to manage";
364
387
  const lines: string[] = [];
365
388
  lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
366
- lines.push("");
367
389
  lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
368
390
 
369
- // Window the agent rows so the selected one stays visible.
370
- const visible = Math.min(MAX_AGENT_ROWS, agents.length);
371
- const selAgent = Math.max(0, sel - 1);
372
- const start = selAgent < visible ? 0 : selAgent - visible + 1;
373
- const hiddenBelow = agents.length - (start + visible);
374
-
375
- if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
376
- for (let a = start; a < start + visible; a++) {
391
+ const window = calculateFleetAgentWindow(agents.length, sel);
392
+ if (window.hiddenAbove > 0) {
393
+ lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
394
+ }
395
+ for (let a = window.start; a < window.end; a++) {
377
396
  lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
378
397
  }
379
- if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
398
+ if (window.hiddenBelow > 0) {
399
+ lines.push(rightAlign("", theme.fg("dim", `↓ ${window.hiddenBelow} more`), width));
400
+ }
380
401
 
381
402
  return lines;
382
403
  }
@@ -0,0 +1,121 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ buildAgentStatusMenuEntries,
7
+ canOpenActiveAgent,
8
+ canOpenAgentHistory,
9
+ formatAgentHistoryOption,
10
+ isTerminalAgentStatus,
11
+ splitAgentRecords,
12
+ } from "../src/agent-history-list.js";
13
+
14
+ type TestRecord = {
15
+ status: string;
16
+ session?: object;
17
+ transcriptPath?: string;
18
+ type?: string;
19
+ description?: string;
20
+ startedAt?: number;
21
+ completedAt?: number;
22
+ };
23
+
24
+ const tempDirectories: string[] = [];
25
+
26
+ afterEach(() => {
27
+ for (const directory of tempDirectories.splice(0)) {
28
+ rmSync(directory, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ function record(overrides: TestRecord): any {
33
+ return {
34
+ id: "agent-1",
35
+ type: "Explore",
36
+ description: "Inspect the repository",
37
+ status: "completed",
38
+ toolUses: 0,
39
+ startedAt: 1_000,
40
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
41
+ compactionCount: 0,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ describe("agent history list helpers", () => {
47
+ it("recognizes exactly the five terminal statuses", () => {
48
+ for (const status of ["completed", "steered", "stopped", "aborted", "error"]) {
49
+ expect(isTerminalAgentStatus(status)).toBe(true);
50
+ }
51
+ for (const status of ["running", "queued", "unknown", ""]) {
52
+ expect(isTerminalAgentStatus(status)).toBe(false);
53
+ }
54
+ });
55
+
56
+ it("separates active and terminal records without mutating input", () => {
57
+ const done = record({ status: "completed", session: {} });
58
+ const running = record({ status: "running", session: {} });
59
+ const stopped = record({ status: "stopped", session: {} });
60
+ const queued = record({ status: "queued", session: {} });
61
+ const input = [done, running, stopped, queued];
62
+ const before = [...input];
63
+
64
+ const result = splitAgentRecords(input, undefined);
65
+
66
+ expect(result.active).toEqual([running, queued]);
67
+ expect(result.history).toEqual([done, stopped]);
68
+ expect(input).toEqual(before);
69
+ expect(input).toEqual([done, running, stopped, queued]);
70
+ });
71
+
72
+ it("requires a session or existing transcript for history", () => {
73
+ expect(canOpenAgentHistory(record({ session: undefined }), undefined)).toBe(false);
74
+ expect(canOpenAgentHistory(record({ session: {} }), undefined)).toBe(true);
75
+ expect(canOpenAgentHistory(record({ status: "running", session: {} }), undefined)).toBe(false);
76
+ expect(canOpenActiveAgent(record({ status: "running", session: {} }))).toBe(true);
77
+ expect(canOpenActiveAgent(record({ status: "queued", session: undefined }))).toBe(false);
78
+ expect(canOpenActiveAgent(record({ status: "completed", session: {} }))).toBe(false);
79
+ });
80
+
81
+ it("opens history only for an existing project-local transcript", () => {
82
+ const cwd = mkdtempSync(join(tmpdir(), "pi-subagents-history-list-"));
83
+ tempDirectories.push(cwd);
84
+ const transcriptPath = ".pi-subagents/agent-transcripts/agent.jsonl";
85
+ mkdirSync(join(cwd, ".pi-subagents", "agent-transcripts"), { recursive: true });
86
+ const historical = record({ session: undefined, transcriptPath });
87
+
88
+ expect(canOpenAgentHistory(historical, cwd)).toBe(false);
89
+ writeFileSync(join(cwd, transcriptPath), "{}\n");
90
+ expect(canOpenAgentHistory(historical, cwd)).toBe(true);
91
+ expect(canOpenAgentHistory(historical, undefined)).toBe(false);
92
+ });
93
+
94
+ it("builds separate menu entries with openable records only", () => {
95
+ const entries = buildAgentStatusMenuEntries([
96
+ record({ status: "running", session: {} }),
97
+ record({ status: "queued", session: undefined }),
98
+ record({ status: "completed", session: {} }),
99
+ record({ status: "error", session: {} }),
100
+ record({ status: "stopped", session: undefined }),
101
+ ], undefined);
102
+
103
+ expect(entries).toEqual(["Running agents (1)", "Agent history (2)"]);
104
+ expect(buildAgentStatusMenuEntries([record({ status: "queued", session: undefined })], undefined)).toEqual([]);
105
+ });
106
+
107
+ it("formats a bounded history option using the display name and duration", () => {
108
+ const option = formatAgentHistoryOption(record({
109
+ status: "completed",
110
+ type: "Explore",
111
+ description: "A very long description ".repeat(20),
112
+ startedAt: 1_000,
113
+ completedAt: 3_000,
114
+ }), 9_000);
115
+
116
+ expect(option).toContain("[completed]");
117
+ expect(option).toContain("Explore");
118
+ expect(option).toContain("2.0s");
119
+ expect(option.length).toBeLessThanOrEqual(140);
120
+ });
121
+ });
@@ -1,87 +1,88 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { AgentManager } from "../src/agent-manager.js";
3
3
 
4
- type PersistedRecord = {
5
- id: string;
6
- type: string;
7
- description: string;
8
- status: string;
9
- result?: unknown;
10
- error?: unknown;
11
- startedAt: number;
12
- completedAt: number;
13
- transcriptPath?: string;
14
- };
4
+ const managers: AgentManager[] = [];
15
5
 
16
- function persisted(overrides: Partial<PersistedRecord> = {}): PersistedRecord {
6
+ afterEach(() => {
7
+ for (const manager of managers.splice(0)) manager.dispose();
8
+ });
9
+
10
+ function makeManager(): AgentManager {
11
+ const manager = new AgentManager();
12
+ managers.push(manager);
13
+ return manager;
14
+ }
15
+
16
+ function record(overrides: Record<string, unknown> = {}) {
17
17
  return {
18
18
  id: "agent-1",
19
- type: "general-purpose",
20
- description: "history test",
19
+ type: "Explore",
20
+ description: "Inspect the repository",
21
21
  status: "completed",
22
- result: "restored result",
23
- startedAt: 100,
24
- completedAt: 200,
22
+ result: "done",
23
+ toolUses: 2,
24
+ startedAt: 1_000,
25
+ completedAt: 2_000,
25
26
  ...overrides,
26
27
  };
27
28
  }
28
29
 
29
30
  describe("AgentManager.restoreCompleted", () => {
30
- let manager: AgentManager | undefined;
31
+ it("restores all five terminal statuses without sessions", () => {
32
+ const manager = makeManager();
33
+ manager.restoreCompleted(["completed", "steered", "stopped", "aborted", "error"].map((status, i) =>
34
+ record({ id: `agent-${i}`, status }),
35
+ ));
31
36
 
32
- afterEach(() => {
33
- manager?.dispose();
34
- manager = undefined;
37
+ expect(manager.listAgents()).toHaveLength(5);
38
+ for (const restored of manager.listAgents()) {
39
+ expect(["completed", "steered", "stopped", "aborted", "error"]).toContain(restored.status);
40
+ expect(restored.session).toBeUndefined();
41
+ expect(restored.abortController).toBeUndefined();
42
+ expect(restored.promise).toBeUndefined();
43
+ expect(restored.lifetimeUsage).toEqual({ input: 0, output: 0, cacheWrite: 0 });
44
+ expect(restored.compactionCount).toBe(0);
45
+ }
35
46
  });
36
47
 
37
- it("restores terminal records without creating live runtime state", () => {
38
- manager = new AgentManager();
39
-
48
+ it("ignores running, queued, and invalid records", () => {
49
+ const manager = makeManager();
40
50
  manager.restoreCompleted([
41
- persisted(),
42
- persisted({ id: "agent-error", status: "error", error: "failed", transcriptPath: ".pi-subagents/agent-transcripts/agent-error.jsonl" }),
43
- persisted({ id: "agent-running", status: "running" }),
44
- persisted({ id: "agent-queued", status: "queued" }),
45
- persisted({ id: "agent-invalid", completedAt: Number.NaN }),
46
- persisted({ id: "agent-unsafe", transcriptPath: ".pi-subagents/../private.jsonl" }),
51
+ record({ id: "running", status: "running" }),
52
+ record({ id: "queued", status: "queued" }),
53
+ { id: "invalid" },
47
54
  ]);
48
55
 
49
- expect(manager.listAgents().map((record) => record.id).sort()).toEqual([
50
- "agent-1",
51
- "agent-error",
52
- ]);
53
- const restored = manager.getRecord("agent-1");
54
- expect(restored).toMatchObject({
55
- status: "completed",
56
- result: "restored result",
57
- toolUses: 0,
58
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
59
- compactionCount: 0,
60
- isBackground: true,
61
- transcriptPath: undefined,
62
- });
63
- expect(restored?.session).toBeUndefined();
64
- expect(restored?.promise).toBeUndefined();
65
- expect(restored?.abortController).toBeUndefined();
56
+ expect(manager.listAgents()).toEqual([]);
66
57
  });
67
58
 
68
- it("uses the newest branch entry when an id appears more than once", () => {
69
- manager = new AgentManager();
70
-
59
+ it("keeps the last valid duplicate input", () => {
60
+ const manager = makeManager();
71
61
  manager.restoreCompleted([
72
- persisted({ result: "old", completedAt: 200 }),
73
- persisted({ result: "new", completedAt: 300 }),
62
+ record({ result: "first", startedAt: 1_000, completedAt: 1_100 }),
63
+ record({ result: "last", startedAt: 2_000, completedAt: 2_100 }),
74
64
  ]);
75
65
 
76
- expect(manager.getRecord("agent-1")?.result).toBe("new");
66
+ expect(manager.getRecord("agent-1")?.result).toBe("last");
67
+ expect(manager.getRecord("agent-1")?.startedAt).toBe(2_000);
77
68
  });
78
69
 
79
- it("does not overwrite a record already held by the manager", () => {
80
- manager = new AgentManager();
70
+ it("does not replace a live record and rejects unsafe persisted data", () => {
71
+ const manager = makeManager();
72
+ // Exercise the public map through a restored terminal, then make it live
73
+ // without starting an LLM session.
74
+ manager.restoreCompleted([record({ id: "live", result: "original" })]);
75
+ const liveRecord = manager.getRecord("live")!;
76
+ liveRecord.status = "running";
81
77
 
82
- manager.restoreCompleted([persisted({ result: "first" })]);
83
- manager.restoreCompleted([persisted({ result: "second" })]);
78
+ manager.restoreCompleted([
79
+ record({ id: "live", result: "replacement" }),
80
+ record({ id: "bad-time", startedAt: Number.NaN }),
81
+ record({ id: "bad-path", transcriptPath: "../../outside.jsonl" }),
82
+ ]);
84
83
 
85
- expect(manager.getRecord("agent-1")?.result).toBe("first");
84
+ expect(manager.getRecord("live")?.result).toBe("original");
85
+ expect(manager.getRecord("bad-time")).toBeUndefined();
86
+ expect(manager.getRecord("bad-path")).toBeUndefined();
86
87
  });
87
88
  });
@@ -0,0 +1,86 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import extension from "../src/index.js";
6
+ import { agentHistoryLocator } from "../src/agent-history.js";
7
+
8
+ const tempDirectories: string[] = [];
9
+
10
+ afterEach(() => {
11
+ for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
12
+ });
13
+
14
+ describe("/agents history navigation", () => {
15
+ it("returns to the Agents menu after closing a history viewer", async () => {
16
+ const cwd = mkdtempSync(join(tmpdir(), "pi-subagents-menu-test-"));
17
+ tempDirectories.push(cwd);
18
+ const transcript = ".pi-subagents/agent-transcripts/agent-1.jsonl";
19
+ mkdirSync(join(cwd, ".pi-subagents/agent-transcripts"), { recursive: true });
20
+ writeFileSync(join(cwd, transcript), JSON.stringify({ message: { role: "user", content: "hello" } }) + "\n");
21
+
22
+ const eventHandlers = new Map<string, Set<(...args: any[]) => any>>();
23
+ const events = {
24
+ on: (name: string, handler: (...args: any[]) => any) => {
25
+ const handlersForEvent = eventHandlers.get(name) ?? new Set<(...args: any[]) => any>();
26
+ handlersForEvent.add(handler);
27
+ eventHandlers.set(name, handlersForEvent);
28
+ return () => handlersForEvent.delete(handler);
29
+ },
30
+ emit: (name: string, ...args: any[]) => {
31
+ for (const handler of eventHandlers.get(name) ?? []) handler(...args);
32
+ },
33
+ };
34
+ const handlers = new Map<string, (...args: any[]) => any>();
35
+ let command: { handler: (_args: unknown, ctx: any) => Promise<void> } | undefined;
36
+ const pi: any = {
37
+ events,
38
+ registerMessageRenderer: () => {},
39
+ registerTool: () => {},
40
+ registerCommand: (_name: string, definition: typeof command) => { command = definition; },
41
+ on: (name: string, handler: (...args: any[]) => any) => { handlers.set(name, handler); },
42
+ appendEntry: () => {},
43
+ sendMessage: () => {},
44
+ };
45
+ extension(pi);
46
+
47
+ const titles: string[] = [];
48
+ let historySelections = 0;
49
+ const ui = {
50
+ notify: () => {},
51
+ setWidget: () => {},
52
+ setStatus: () => {},
53
+ onTerminalInput: () => () => {},
54
+ select: async (title: string, options: string[]) => {
55
+ titles.push(title);
56
+ if (title === "Agents" && titles.filter(item => item === "Agents").length === 1) {
57
+ return options.find(option => option.startsWith("Agent history ("));
58
+ }
59
+ if (title === "Agent history" && historySelections++ === 0) return options[0];
60
+ return undefined;
61
+ },
62
+ custom: async () => undefined,
63
+ };
64
+ const ctx = {
65
+ cwd,
66
+ ui,
67
+ sessionManager: {
68
+ getSessionId: () => undefined,
69
+ getBranch: () => [{
70
+ type: "custom",
71
+ customType: "subagents:record",
72
+ data: {
73
+ id: "agent-1", type: "Explore", description: "Inspect", status: "completed",
74
+ startedAt: 1, completedAt: 2, transcriptPath: agentHistoryLocator(cwd, join(cwd, transcript)),
75
+ },
76
+ }],
77
+ },
78
+ };
79
+
80
+ await handlers.get("session_start")?.({}, ctx);
81
+ await command?.handler({}, ctx);
82
+ await handlers.get("session_shutdown")?.();
83
+
84
+ expect(titles).toEqual(["Agents", "Agent history", "Agents"]);
85
+ });
86
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatConversationMessages } from "../../src/ui/conversation-blocks.js";
3
+
4
+ describe("formatConversationMessages", () => {
5
+ it("keeps markdown source for user and assistant blocks", () => {
6
+ const blocks = formatConversationMessages([
7
+ { role: "user", content: "# Ask\n\nUse `x`." },
8
+ { role: "assistant", content: [
9
+ { type: "text", text: "## Answer\n\n```ts\nconst x: number = 1;\n```" },
10
+ ] },
11
+ ] as any);
12
+
13
+ expect(blocks.filter((b) => b.kind === "text").map((b) => b.markdown)).toEqual([
14
+ "# Ask\n\nUse `x`.",
15
+ "## Answer\n\n```ts\nconst x: number = 1;\n```",
16
+ ]);
17
+ });
18
+
19
+ it("separates assistant tool calls and bounds tool results", () => {
20
+ const blocks = formatConversationMessages([
21
+ { role: "assistant", content: [
22
+ { type: "toolCall", name: "read", arguments: { path: "src/index.ts" } },
23
+ ] },
24
+ { role: "toolResult", toolName: "read", isError: false, content: [
25
+ { type: "text", text: "a".repeat(100) },
26
+ ] },
27
+ ] as any, { maxToolResultChars: 40 });
28
+
29
+ expect(blocks.some((b) => b.toolLine?.includes("read"))).toBe(true);
30
+ const result = blocks.find((b) => b.role === "tool" && b.fullText.includes("read"));
31
+ expect(result?.fullText.length).toBeLessThanOrEqual(40);
32
+ });
33
+
34
+ it("normalizes CRLF and bounds text with a deterministic truncation marker", () => {
35
+ const blocks = formatConversationMessages([
36
+ { role: "user", content: "one\r\ntwo\rthree\nfour" },
37
+ ] as any, { maxEntryChars: 12 });
38
+
39
+ expect(blocks[0]?.markdown).toBe("\n… [truncate");
40
+ expect(blocks[0]?.id).toBe("message-0");
41
+ expect(blocks[0]?.fullText.length).toBeLessThanOrEqual(12);
42
+ });
43
+
44
+ it("formats bash, images, summaries, and deterministic tool ids", () => {
45
+ const blocks = formatConversationMessages([
46
+ { role: "assistant", content: [
47
+ { type: "text", text: "answer" },
48
+ { type: "toolCall", name: "read", arguments: { path: "a.ts" } },
49
+ { type: "toolCall", name: "write", arguments: { path: "b.ts" } },
50
+ ] },
51
+ { role: "bashExecution", command: "echo hi", output: "hi" },
52
+ { role: "custom", customType: "note", content: [{ type: "image" }] },
53
+ { role: "compactionSummary", summary: "Earlier context" },
54
+ ] as any);
55
+
56
+ expect(blocks.map((block) => block.id)).toEqual([
57
+ "message-0", "message-0-tool-0", "message-0-tool-1", "message-1", "message-2", "message-3",
58
+ ]);
59
+ expect(blocks[1]?.toolLine).toContain("· read → a.ts");
60
+ expect(blocks[3]?.fullText).toContain("$ echo hi\nhi");
61
+ expect(blocks[4]?.markdown).toBe("[image]");
62
+ expect(blocks[5]?.role).toBe("meta");
63
+ });
64
+
65
+ it("skips unsupported or empty messages without throwing", () => {
66
+ expect(formatConversationMessages([
67
+ { role: "unknown", content: { not: "text" } },
68
+ { role: "user", content: [{ type: "thinking", thinking: "hidden" }] },
69
+ { role: "custom", display: false, content: "hidden" },
70
+ ] as any)).toEqual([]);
71
+ });
72
+ });