@esso0428/pi-subagents 0.15.4 → 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.
- package/CHANGELOG.md +4 -17
- package/README.md +5 -5
- package/dist/agent-history-list.d.ts +18 -0
- package/dist/agent-history-list.js +68 -0
- package/dist/agent-history.d.ts +1 -1
- package/dist/agent-history.js +31 -34
- package/dist/agent-manager.d.ts +22 -8
- package/dist/agent-manager.js +100 -93
- package/dist/agent-runner.js +67 -86
- package/dist/agent-types.js +27 -45
- package/dist/context.js +2 -6
- package/dist/cross-extension-rpc.js +5 -9
- package/dist/custom-agents.js +15 -18
- package/dist/default-agents.js +1 -4
- package/dist/enabled-models.js +11 -16
- package/dist/env.js +1 -4
- package/dist/group-join.js +1 -5
- package/dist/index.js +273 -284
- package/dist/invocation-config.js +2 -6
- package/dist/memory.js +24 -34
- package/dist/model-resolver.js +1 -4
- package/dist/nico-overrides.js +14 -20
- package/dist/output-file.js +16 -22
- package/dist/prompts.js +1 -4
- package/dist/schedule-store.js +16 -21
- package/dist/schedule.js +8 -12
- package/dist/settings.js +15 -23
- package/dist/skill-loader.js +20 -23
- package/dist/status-note.js +1 -4
- package/dist/types.d.ts +0 -1
- package/dist/types.js +1 -4
- package/dist/ui/agent-widget.d.ts +1 -5
- package/dist/ui/agent-widget.js +25 -49
- package/dist/ui/conversation-blocks.d.ts +18 -0
- package/dist/ui/conversation-blocks.js +214 -0
- package/dist/ui/conversation-nvim.d.ts +7 -0
- package/dist/ui/conversation-nvim.js +116 -0
- package/dist/ui/conversation-search.d.ts +38 -0
- package/dist/ui/conversation-search.js +123 -0
- package/dist/ui/conversation-viewer.d.ts +55 -19
- package/dist/ui/conversation-viewer.js +398 -215
- package/dist/ui/fleet-list.d.ts +23 -11
- package/dist/ui/fleet-list.js +85 -78
- package/dist/ui/schedule-menu.js +1 -4
- package/dist/ui/viewer-keys.js +7 -10
- package/dist/usage.js +4 -10
- package/dist/worktree.js +26 -31
- package/package.json +1 -1
- package/src/agent-history-list.ts +91 -0
- package/src/agent-history.ts +12 -17
- package/src/agent-manager.ts +108 -81
- package/src/agent-runner.ts +5 -13
- package/src/index.ts +84 -85
- package/src/output-file.ts +1 -1
- package/src/types.ts +0 -1
- package/src/ui/agent-widget.ts +1 -8
- package/src/ui/conversation-blocks.ts +270 -0
- package/src/ui/conversation-nvim.ts +127 -0
- package/src/ui/conversation-search.ts +161 -0
- package/src/ui/conversation-viewer.ts +376 -205
- package/src/ui/fleet-list.ts +73 -52
- package/test/agent-history-list.test.ts +121 -0
- package/test/agent-manager-history.test.ts +59 -58
- package/test/agent-menu-history-navigation.test.ts +86 -0
- package/test/ui/conversation-blocks.test.ts +72 -0
- package/test/ui/conversation-search.test.ts +55 -0
- package/test/ui/conversation-viewer.test.ts +132 -0
- package/test/ui/fleet-list.test.ts +131 -0
- package/dist/ui/markdown-result.d.ts +0 -3
- package/dist/ui/markdown-result.js +0 -53
- package/src/ui/markdown-result.ts +0 -56
- package/test/agent-history.test.ts +0 -63
- package/test/ui/markdown-result.test.ts +0 -45
package/src/ui/fleet-list.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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,
|
|
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
|
-
/**
|
|
25
|
-
const
|
|
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
|
|
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
|
|
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
|
-
*
|
|
184
|
-
* started sooner sit at the top.
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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(
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
345
|
+
() => {
|
|
324
346
|
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
|
|
325
|
-
}
|
|
347
|
+
},
|
|
326
348
|
keybindings,
|
|
327
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
20
|
-
description: "
|
|
19
|
+
type: "Explore",
|
|
20
|
+
description: "Inspect the repository",
|
|
21
21
|
status: "completed",
|
|
22
|
-
result: "
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
manager
|
|
34
|
-
|
|
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("
|
|
38
|
-
manager =
|
|
39
|
-
|
|
48
|
+
it("ignores running, queued, and invalid records", () => {
|
|
49
|
+
const manager = makeManager();
|
|
40
50
|
manager.restoreCompleted([
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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()
|
|
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("
|
|
69
|
-
manager =
|
|
70
|
-
|
|
59
|
+
it("keeps the last valid duplicate input", () => {
|
|
60
|
+
const manager = makeManager();
|
|
71
61
|
manager.restoreCompleted([
|
|
72
|
-
|
|
73
|
-
|
|
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("
|
|
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
|
|
80
|
-
manager =
|
|
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([
|
|
83
|
-
|
|
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("
|
|
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
|
+
});
|