pi-extended-teams 2.1.16
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/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,1659 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Key } from "@mariozechner/pi-tui";
|
|
6
|
+
import { LEGACY_FAVORITE_MODEL_SLOT_ALIASES } from "../src/utils/settings";
|
|
7
|
+
|
|
8
|
+
type RegisteredTool = {
|
|
9
|
+
name: string;
|
|
10
|
+
execute: (toolCallId: string, params: any, signal: AbortSignal, onUpdate: any, ctx: any) => Promise<any>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const testRoot = path.join(os.tmpdir(), "pi-extended-teams-extension-" + Date.now());
|
|
14
|
+
|
|
15
|
+
function makeCtx(cwd: string, sessionId = "test-session") {
|
|
16
|
+
return {
|
|
17
|
+
cwd,
|
|
18
|
+
mode: "tui",
|
|
19
|
+
sessionManager: { getSessionId: vi.fn(() => sessionId) },
|
|
20
|
+
model: { provider: "provider", id: "model" },
|
|
21
|
+
modelRegistry: {
|
|
22
|
+
getAvailable: vi.fn(async () => [{ provider: "provider", id: "model" }]),
|
|
23
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
24
|
+
},
|
|
25
|
+
ui: {
|
|
26
|
+
notify: vi.fn(),
|
|
27
|
+
setStatus: vi.fn(),
|
|
28
|
+
setWidget: vi.fn(),
|
|
29
|
+
custom: vi.fn(async (..._args: any[]): Promise<any> => undefined),
|
|
30
|
+
getToolsExpanded: vi.fn(() => false),
|
|
31
|
+
setEditorComponent: vi.fn(),
|
|
32
|
+
getEditorComponent: vi.fn((..._args: any[]): any => undefined),
|
|
33
|
+
},
|
|
34
|
+
isIdle: vi.fn(() => true),
|
|
35
|
+
isProjectTrusted: vi.fn(() => true),
|
|
36
|
+
shutdown: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function setupExtension(
|
|
41
|
+
env: Record<string, string | undefined> = {},
|
|
42
|
+
options: { withSendMessage?: boolean } = {}
|
|
43
|
+
) {
|
|
44
|
+
vi.resetModules();
|
|
45
|
+
const originalEnv = { ...process.env };
|
|
46
|
+
delete process.env.PI_TEAM_NAME;
|
|
47
|
+
delete process.env.PI_AGENT_NAME;
|
|
48
|
+
process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT = "0";
|
|
49
|
+
for (const [key, value] of Object.entries(env)) {
|
|
50
|
+
if (value === undefined) delete process.env[key];
|
|
51
|
+
else process.env[key] = value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const terminal = {
|
|
55
|
+
spawn: vi.fn(() => `%${terminal.spawn.mock.calls.length}`),
|
|
56
|
+
kill: vi.fn(),
|
|
57
|
+
isAlive: vi.fn(() => true),
|
|
58
|
+
setTitle: vi.fn(),
|
|
59
|
+
focusPane: vi.fn(() => true),
|
|
60
|
+
getCurrentPaneId: vi.fn(() => process.env.TMUX_PANE || "%lead"),
|
|
61
|
+
getWindowIdForPane: vi.fn((paneId: string) => paneId ? `@${paneId.replace("%", "")}` : null),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
vi.doMock("../src/adapters/terminal-registry", () => ({ getTerminalAdapter: () => terminal }));
|
|
65
|
+
|
|
66
|
+
const readAgentMock = {
|
|
67
|
+
runReadAgentInProcess: vi.fn(),
|
|
68
|
+
sendMessageToRunningReadAgent: vi.fn(async () => false),
|
|
69
|
+
closeReadAgentMessageDelivery: vi.fn((state: any) => {
|
|
70
|
+
state.acceptingMessages = false;
|
|
71
|
+
state.messageDeliveryClosed = true;
|
|
72
|
+
return state.messageDeliveryTail ?? Promise.resolve();
|
|
73
|
+
}),
|
|
74
|
+
shutdownReadAgentSession: vi.fn(async () => {}),
|
|
75
|
+
};
|
|
76
|
+
vi.doMock("./agents/read-agent.js", () => readAgentMock);
|
|
77
|
+
|
|
78
|
+
const sleepAssertionReleases: Array<ReturnType<typeof vi.fn>> = [];
|
|
79
|
+
const sleepController = {
|
|
80
|
+
retain: vi.fn(() => {
|
|
81
|
+
const release = vi.fn();
|
|
82
|
+
sleepAssertionReleases.push(release);
|
|
83
|
+
return release;
|
|
84
|
+
}),
|
|
85
|
+
dispose: vi.fn(),
|
|
86
|
+
};
|
|
87
|
+
vi.doMock("./runtime/active-agent-sleep.js", () => ({
|
|
88
|
+
createActiveAgentSleepController: vi.fn(() => sleepController),
|
|
89
|
+
runWithActiveAgentSleepAssertion: (controller: typeof sleepController, launch: () => unknown) => {
|
|
90
|
+
const release = controller.retain();
|
|
91
|
+
try {
|
|
92
|
+
return Promise.resolve(launch()).finally(release);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
release();
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
const extensionModule = await import("./index.js") as any;
|
|
101
|
+
const extension = extensionModule.default;
|
|
102
|
+
const paths = await import("../src/utils/paths.js");
|
|
103
|
+
const teams = await import("../src/utils/teams.js");
|
|
104
|
+
|
|
105
|
+
fs.mkdirSync(testRoot, { recursive: true });
|
|
106
|
+
const root = fs.mkdtempSync(path.join(testRoot, "case-"));
|
|
107
|
+
const teamsRoot = path.join(root, "teams");
|
|
108
|
+
const tasksRoot = path.join(root, "tasks");
|
|
109
|
+
fs.mkdirSync(teamsRoot, { recursive: true });
|
|
110
|
+
fs.mkdirSync(tasksRoot, { recursive: true });
|
|
111
|
+
vi.spyOn(os, "homedir").mockReturnValue(root);
|
|
112
|
+
|
|
113
|
+
vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName))));
|
|
114
|
+
vi.spyOn(paths, "taskDir").mockImplementation((teamName: unknown) => path.join(tasksRoot, paths.sanitizeName(String(teamName))));
|
|
115
|
+
vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "config.json"));
|
|
116
|
+
vi.spyOn(paths, "inboxPath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`));
|
|
117
|
+
vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`));
|
|
118
|
+
vi.spyOn(paths, "claimsPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "claims.json"));
|
|
119
|
+
vi.spyOn(paths, "writeQueuePath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "write-queue.json"));
|
|
120
|
+
vi.spyOn(paths, "readHelperQueuePath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "read-helper-queue.json"));
|
|
121
|
+
vi.spyOn(paths, "leadSessionPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lead-session.json"));
|
|
122
|
+
vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`));
|
|
123
|
+
|
|
124
|
+
const tools = new Map<string, RegisteredTool>();
|
|
125
|
+
const commands = new Map<string, any>();
|
|
126
|
+
const eventHandlers = new Map<string, Function[]>();
|
|
127
|
+
const extensionEventHandlers = new Map<string, Function[]>();
|
|
128
|
+
const pi: any = {
|
|
129
|
+
registerTool: vi.fn((tool: RegisteredTool) => tools.set(tool.name, tool)),
|
|
130
|
+
registerCommand: vi.fn((name: string, command: any) => commands.set(name, command)),
|
|
131
|
+
registerShortcut: vi.fn(),
|
|
132
|
+
getCommands: vi.fn(() => Array.from(commands.keys()).map((name) => ({
|
|
133
|
+
name,
|
|
134
|
+
source: "extension",
|
|
135
|
+
sourceInfo: {
|
|
136
|
+
path: path.join(process.cwd(), "extensions", "index.ts"),
|
|
137
|
+
source: "local",
|
|
138
|
+
scope: "user",
|
|
139
|
+
origin: "top-level",
|
|
140
|
+
},
|
|
141
|
+
}))),
|
|
142
|
+
getAllTools: vi.fn(() => Array.from(tools.keys()).map((name) => ({
|
|
143
|
+
name,
|
|
144
|
+
sourceInfo: {
|
|
145
|
+
path: path.join(process.cwd(), "extensions", "index.ts"),
|
|
146
|
+
source: "local",
|
|
147
|
+
scope: "user",
|
|
148
|
+
origin: "top-level",
|
|
149
|
+
},
|
|
150
|
+
}))),
|
|
151
|
+
on: vi.fn((name: string, handler: Function) => {
|
|
152
|
+
eventHandlers.set(name, [...(eventHandlers.get(name) || []), handler]);
|
|
153
|
+
}),
|
|
154
|
+
events: {
|
|
155
|
+
on: vi.fn((name: string, handler: Function) => {
|
|
156
|
+
const target = name.startsWith("pi-") ? extensionEventHandlers : eventHandlers;
|
|
157
|
+
target.set(name, [...(target.get(name) || []), handler]);
|
|
158
|
+
}),
|
|
159
|
+
emit: vi.fn(),
|
|
160
|
+
},
|
|
161
|
+
sendUserMessage: vi.fn(),
|
|
162
|
+
};
|
|
163
|
+
if (options.withSendMessage) pi.sendMessage = vi.fn();
|
|
164
|
+
|
|
165
|
+
extension(pi as any);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
root,
|
|
169
|
+
tools,
|
|
170
|
+
commands,
|
|
171
|
+
eventHandlers,
|
|
172
|
+
extensionEventHandlers,
|
|
173
|
+
pi,
|
|
174
|
+
terminal,
|
|
175
|
+
readAgentMock,
|
|
176
|
+
sleepController,
|
|
177
|
+
sleepAssertionReleases,
|
|
178
|
+
teams,
|
|
179
|
+
restoreEnv() {
|
|
180
|
+
process.env = originalEnv;
|
|
181
|
+
vi.restoreAllMocks();
|
|
182
|
+
if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeFavoriteLevels(root: string) {
|
|
188
|
+
const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
|
|
189
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
190
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
191
|
+
favoriteModels: {
|
|
192
|
+
"read-collect": { model: "provider/model", thinking: "low" },
|
|
193
|
+
"read-review": { model: "provider/model", thinking: "high" },
|
|
194
|
+
"read-critical": { model: "provider/model", thinking: "xhigh" },
|
|
195
|
+
"write-patch": { model: "provider/model", thinking: "high" },
|
|
196
|
+
"write-critical": { model: "provider/model", thinking: "xhigh" },
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe("extension integration", () => {
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
vi.useFakeTimers();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
vi.useRealTimers();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("registers the small public tool surface without legacy management commands", async () => {
|
|
211
|
+
const setup = await setupExtension();
|
|
212
|
+
try {
|
|
213
|
+
expect(Array.from(setup.tools.keys()).sort()).toEqual([
|
|
214
|
+
"check_teammate",
|
|
215
|
+
"claim_file",
|
|
216
|
+
"list_file_claims",
|
|
217
|
+
"read_inbox",
|
|
218
|
+
"release_file",
|
|
219
|
+
"report_and_exit",
|
|
220
|
+
"send_message",
|
|
221
|
+
"spawn_agent",
|
|
222
|
+
"spawn_swarm_agents",
|
|
223
|
+
"stop_teammate",
|
|
224
|
+
]);
|
|
225
|
+
expect(setup.commands.has("agents")).toBe(false);
|
|
226
|
+
expect(setup.commands.has("team")).toBe(false);
|
|
227
|
+
expect(setup.commands.has("agents-favorite-models")).toBe(true);
|
|
228
|
+
expect(setup.commands.has("agents-extensions")).toBe(true);
|
|
229
|
+
expect(setup.pi.registerShortcut).toHaveBeenCalledWith(Key.alt("tab"), expect.objectContaining({ handler: expect.any(Function) }));
|
|
230
|
+
expect(setup.tools.has("team_create")).toBe(false);
|
|
231
|
+
expect(setup.tools.has("ensure_team")).toBe(false);
|
|
232
|
+
expect(setup.tools.has("spawn_teammate")).toBe(false);
|
|
233
|
+
expect(setup.tools.has("request_read_helper")).toBe(false);
|
|
234
|
+
expect(setup.tools.has("list_available_models")).toBe(false);
|
|
235
|
+
} finally {
|
|
236
|
+
setup.restoreEnv();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("does not expose report_progress in the lead session", async () => {
|
|
241
|
+
const setup = await setupExtension();
|
|
242
|
+
try {
|
|
243
|
+
expect(setup.tools.has("report_progress")).toBe(false);
|
|
244
|
+
} finally {
|
|
245
|
+
setup.restoreEnv();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("report_progress updates runtime-backed agents without inbox or lead-turn side effects", async () => {
|
|
250
|
+
const setup = await setupExtension({ PI_TEAM_NAME: "team", PI_AGENT_NAME: "writer", PI_LIFECYCLE_RUN_ID: "writer-run" });
|
|
251
|
+
try {
|
|
252
|
+
const paths = await import("../src/utils/paths.js");
|
|
253
|
+
const config = setup.teams.createTeam("team", "writer-session", "lead", "", "provider/model");
|
|
254
|
+
config.members.push({
|
|
255
|
+
agentId: "writer@team",
|
|
256
|
+
name: "writer",
|
|
257
|
+
agentType: "teammate",
|
|
258
|
+
role: "write",
|
|
259
|
+
lifecycleRunId: "writer-run",
|
|
260
|
+
joinedAt: Date.now(),
|
|
261
|
+
tmuxPaneId: "%writer",
|
|
262
|
+
cwd: setup.root,
|
|
263
|
+
subscriptions: [],
|
|
264
|
+
});
|
|
265
|
+
fs.writeFileSync(paths.configPath("team"), JSON.stringify(config, null, 2));
|
|
266
|
+
expect(setup.tools.has("spawn_agent")).toBe(false);
|
|
267
|
+
expect(setup.tools.has("spawn_swarm_agents")).toBe(false);
|
|
268
|
+
expect(setup.tools.has("send_message")).toBe(true);
|
|
269
|
+
const runtime = await import("../src/utils/runtime.js");
|
|
270
|
+
const messaging = await import("../src/utils/messaging.js");
|
|
271
|
+
const ctx = makeCtx(setup.root, "writer-session");
|
|
272
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
273
|
+
|
|
274
|
+
const result = await setup.tools.get("report_progress")!.execute("progress", {
|
|
275
|
+
status: " Running\n focused tests ",
|
|
276
|
+
}, new AbortController().signal, undefined, ctx);
|
|
277
|
+
|
|
278
|
+
expect(result.details).toMatchObject({ session: "team", status: "Running focused tests" });
|
|
279
|
+
expect(await runtime.readRuntimeStatus("team", "writer")).toMatchObject({
|
|
280
|
+
currentAction: "starting",
|
|
281
|
+
latestProgress: "Running focused tests",
|
|
282
|
+
progressUpdatedAt: expect.any(Number),
|
|
283
|
+
});
|
|
284
|
+
expect(await messaging.readInbox("team", "team-lead", false, false)).toEqual([]);
|
|
285
|
+
expect(setup.pi.sendUserMessage).not.toHaveBeenCalled();
|
|
286
|
+
} finally {
|
|
287
|
+
setup.restoreEnv();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("warns lead sessions at boot when no favorite levels are configured", async () => {
|
|
292
|
+
const setup = await setupExtension();
|
|
293
|
+
try {
|
|
294
|
+
const ctx = makeCtx(setup.root, "boot-session");
|
|
295
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
296
|
+
|
|
297
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
298
|
+
"No agent intent tiers are configured. Define them with /agents-favorite-models before spawning agents. See README.md for intent-tier examples.",
|
|
299
|
+
"warning"
|
|
300
|
+
);
|
|
301
|
+
} finally {
|
|
302
|
+
setup.restoreEnv();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("spawn_agent creates the implicit current-session group and runs edit agents in-process by level", async () => {
|
|
307
|
+
const setup = await setupExtension();
|
|
308
|
+
try {
|
|
309
|
+
writeFavoriteLevels(setup.root);
|
|
310
|
+
const ctx = makeCtx(setup.root, "edit-session");
|
|
311
|
+
const abort = new AbortController().signal;
|
|
312
|
+
|
|
313
|
+
const result = await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
314
|
+
name: "editor",
|
|
315
|
+
prompt: "Edit the file",
|
|
316
|
+
cwd: setup.root,
|
|
317
|
+
model_slot: "write-critical",
|
|
318
|
+
}, abort, undefined, ctx);
|
|
319
|
+
|
|
320
|
+
expect(result.details).toMatchObject({ name: "editor", role: "write", mode: "in-process", terminalId: null, session: "session-edit-session", modelSlot: "write-critical" });
|
|
321
|
+
expect(setup.terminal.spawn).not.toHaveBeenCalled();
|
|
322
|
+
expect(setup.readAgentMock.runReadAgentInProcess).toHaveBeenCalledWith(
|
|
323
|
+
"session-edit-session",
|
|
324
|
+
expect.objectContaining({ name: "editor", role: "write", model: "provider/model", thinking: "xhigh", modelSlot: "write-critical" }),
|
|
325
|
+
"Edit the file",
|
|
326
|
+
ctx,
|
|
327
|
+
expect.any(Object),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const config = await setup.teams.readConfig("session-edit-session");
|
|
331
|
+
expect(config.members.map((member: any) => member.name)).toEqual(["team-lead", "editor"]);
|
|
332
|
+
} finally {
|
|
333
|
+
setup.restoreEnv();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("delivers completed read reports to lead context without rendering their bodies", async () => {
|
|
338
|
+
const setup = await setupExtension({}, { withSendMessage: true });
|
|
339
|
+
try {
|
|
340
|
+
writeFavoriteLevels(setup.root);
|
|
341
|
+
const ctx = makeCtx(setup.root, "hidden-report-session");
|
|
342
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
343
|
+
name: "reader",
|
|
344
|
+
prompt: "Inspect the project",
|
|
345
|
+
cwd: setup.root,
|
|
346
|
+
model_slot: "read-review",
|
|
347
|
+
}, new AbortController().signal, undefined, ctx);
|
|
348
|
+
|
|
349
|
+
const spawnOptions = setup.readAgentMock.runReadAgentInProcess.mock.calls[0]![4];
|
|
350
|
+
const startedAt = Date.now() - 1_000;
|
|
351
|
+
spawnOptions.emitAgentReport(
|
|
352
|
+
"session-hidden-report-session",
|
|
353
|
+
"reader",
|
|
354
|
+
startedAt,
|
|
355
|
+
42,
|
|
356
|
+
"Full report body for the lead model.",
|
|
357
|
+
true,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
expect(setup.pi.events.emit).toHaveBeenCalledWith(
|
|
361
|
+
"pi-extended-teams:agent-report",
|
|
362
|
+
expect.objectContaining({
|
|
363
|
+
teamName: "session-hidden-report-session",
|
|
364
|
+
name: "reader",
|
|
365
|
+
report: "Full report body for the lead model.",
|
|
366
|
+
ok: true,
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
expect(setup.pi.sendMessage).toHaveBeenCalledOnce();
|
|
370
|
+
expect(setup.pi.sendMessage).toHaveBeenCalledWith(
|
|
371
|
+
{
|
|
372
|
+
customType: "pi-extended-teams-report",
|
|
373
|
+
content: "Full report body for the lead model.",
|
|
374
|
+
display: false,
|
|
375
|
+
details: { name: "reader", elapsedMs: 1_000, tokens: 42, ok: true },
|
|
376
|
+
},
|
|
377
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
378
|
+
);
|
|
379
|
+
expect(setup.pi.sendUserMessage).not.toHaveBeenCalled();
|
|
380
|
+
} finally {
|
|
381
|
+
setup.restoreEnv();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("holds one shared sleep assertion across concurrent in-process agent runs", async () => {
|
|
386
|
+
const setup = await setupExtension();
|
|
387
|
+
try {
|
|
388
|
+
writeFavoriteLevels(setup.root);
|
|
389
|
+
let resolveFirst!: () => void;
|
|
390
|
+
let resolveSecond!: () => void;
|
|
391
|
+
const firstRun = new Promise<void>((resolve) => { resolveFirst = resolve; });
|
|
392
|
+
const secondRun = new Promise<void>((resolve) => { resolveSecond = resolve; });
|
|
393
|
+
setup.readAgentMock.runReadAgentInProcess
|
|
394
|
+
.mockReturnValueOnce(firstRun)
|
|
395
|
+
.mockReturnValueOnce(secondRun);
|
|
396
|
+
const ctx = makeCtx(setup.root, "sleep-assertion-session");
|
|
397
|
+
const signal = new AbortController().signal;
|
|
398
|
+
|
|
399
|
+
await setup.tools.get("spawn_agent")!.execute("spawn-first", {
|
|
400
|
+
name: "first",
|
|
401
|
+
prompt: "Inspect first",
|
|
402
|
+
cwd: setup.root,
|
|
403
|
+
model_slot: "read-review",
|
|
404
|
+
}, signal, undefined, ctx);
|
|
405
|
+
await setup.tools.get("spawn_agent")!.execute("spawn-second", {
|
|
406
|
+
name: "second",
|
|
407
|
+
prompt: "Inspect second",
|
|
408
|
+
cwd: setup.root,
|
|
409
|
+
model_slot: "read-review",
|
|
410
|
+
}, signal, undefined, ctx);
|
|
411
|
+
|
|
412
|
+
expect(setup.sleepController.retain).toHaveBeenCalledTimes(2);
|
|
413
|
+
expect(setup.sleepAssertionReleases).toHaveLength(2);
|
|
414
|
+
|
|
415
|
+
resolveFirst();
|
|
416
|
+
await firstRun;
|
|
417
|
+
await Promise.resolve();
|
|
418
|
+
expect(setup.sleepAssertionReleases[0]).toHaveBeenCalledOnce();
|
|
419
|
+
expect(setup.sleepAssertionReleases[1]).not.toHaveBeenCalled();
|
|
420
|
+
|
|
421
|
+
resolveSecond();
|
|
422
|
+
await secondRun;
|
|
423
|
+
await Promise.resolve();
|
|
424
|
+
expect(setup.sleepAssertionReleases[1]).toHaveBeenCalledOnce();
|
|
425
|
+
} finally {
|
|
426
|
+
setup.restoreEnv();
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("disposes the shared sleep assertion during extension shutdown", async () => {
|
|
431
|
+
const setup = await setupExtension();
|
|
432
|
+
try {
|
|
433
|
+
const ctx = makeCtx(setup.root, "sleep-assertion-shutdown-session");
|
|
434
|
+
|
|
435
|
+
for (const handler of setup.eventHandlers.get("session_shutdown") ?? []) {
|
|
436
|
+
await handler({ reason: "quit" }, ctx);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
expect(setup.sleepController.dispose).toHaveBeenCalledOnce();
|
|
440
|
+
} finally {
|
|
441
|
+
setup.restoreEnv();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("rejects direct delivery when the active same-name agent has a different lifecycle run", async () => {
|
|
446
|
+
const setup = await setupExtension();
|
|
447
|
+
try {
|
|
448
|
+
writeFavoriteLevels(setup.root);
|
|
449
|
+
let capturedOptions: any;
|
|
450
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
451
|
+
capturedOptions = options;
|
|
452
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), {
|
|
453
|
+
runId: "writer-run-B",
|
|
454
|
+
name: member.name,
|
|
455
|
+
teamName,
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
const ctx = makeCtx(setup.root, "replacement-run-delivery-session");
|
|
459
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
460
|
+
name: "writer",
|
|
461
|
+
prompt: "Run the replacement assignment",
|
|
462
|
+
cwd: setup.root,
|
|
463
|
+
model_slot: "write-critical",
|
|
464
|
+
}, new AbortController().signal, undefined, ctx);
|
|
465
|
+
|
|
466
|
+
const delivered = await capturedOptions.deliverMessageToActiveAgent(
|
|
467
|
+
"session-replacement-run-delivery-session",
|
|
468
|
+
"writer",
|
|
469
|
+
"stale child report",
|
|
470
|
+
"writer-run-A"
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
expect(delivered).toBe(false);
|
|
474
|
+
expect(setup.readAgentMock.sendMessageToRunningReadAgent).not.toHaveBeenCalled();
|
|
475
|
+
} finally {
|
|
476
|
+
setup.restoreEnv();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("captures public lead extension sourceInfo when the child resource plan is requested", async () => {
|
|
481
|
+
const setup = await setupExtension();
|
|
482
|
+
try {
|
|
483
|
+
writeFavoriteLevels(setup.root);
|
|
484
|
+
const selfPath = path.join(process.cwd(), "extensions", "index.ts");
|
|
485
|
+
const externalPath = path.join(setup.root, "extensions", "external.ts");
|
|
486
|
+
setup.pi.getCommands.mockReturnValue([
|
|
487
|
+
{
|
|
488
|
+
name: "agents-extensions",
|
|
489
|
+
source: "extension",
|
|
490
|
+
sourceInfo: { path: selfPath, source: "local", scope: "user", origin: "top-level" },
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: "external-command",
|
|
494
|
+
source: "extension",
|
|
495
|
+
sourceInfo: { path: externalPath, source: "local", scope: "user", origin: "top-level" },
|
|
496
|
+
},
|
|
497
|
+
]);
|
|
498
|
+
setup.pi.getAllTools.mockReturnValue([]);
|
|
499
|
+
const ctx = makeCtx(setup.root, "extension-snapshot-session");
|
|
500
|
+
|
|
501
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
502
|
+
name: "reader",
|
|
503
|
+
prompt: "Inspect the project",
|
|
504
|
+
cwd: setup.root,
|
|
505
|
+
model_slot: "read-review",
|
|
506
|
+
}, new AbortController().signal, undefined, ctx);
|
|
507
|
+
|
|
508
|
+
const spawnOptions = setup.readAgentMock.runReadAgentInProcess.mock.calls[0]![4];
|
|
509
|
+
const plan = spawnOptions.createResourcePlan({ cwd: setup.root, projectTrusted: true });
|
|
510
|
+
expect(plan.selfExtensionPath).toBe(selfPath);
|
|
511
|
+
expect(plan.extensionPaths).toEqual([externalPath]);
|
|
512
|
+
expect(plan.extensions.map((extension: any) => extension.name)).toEqual(["pi-extended-teams", "external"]);
|
|
513
|
+
} finally {
|
|
514
|
+
setup.restoreEnv();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("emits only the minimal correlated nested-agent progress event", async () => {
|
|
519
|
+
const setup = await setupExtension();
|
|
520
|
+
try {
|
|
521
|
+
writeFavoriteLevels(setup.root);
|
|
522
|
+
const ctx = makeCtx(setup.root, "progress-event-session");
|
|
523
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
524
|
+
name: "planner-private",
|
|
525
|
+
prompt: "Inspect the repository",
|
|
526
|
+
cwd: setup.root,
|
|
527
|
+
model_slot: "read-critical",
|
|
528
|
+
}, new AbortController().signal, undefined, ctx);
|
|
529
|
+
|
|
530
|
+
const options = setup.readAgentMock.runReadAgentInProcess.mock.calls[0]![4];
|
|
531
|
+
options.emitAgentProgress("session-progress-event-session", "planner-private", "Reviewing focused tests", 1_720_000_000_000);
|
|
532
|
+
|
|
533
|
+
expect(setup.pi.events.emit).toHaveBeenCalledWith("pi-extended-teams:agent-progress", {
|
|
534
|
+
teamName: "session-progress-event-session",
|
|
535
|
+
name: "planner-private",
|
|
536
|
+
status: "Reviewing focused tests",
|
|
537
|
+
updatedAt: 1_720_000_000_000,
|
|
538
|
+
});
|
|
539
|
+
const payload = vi.mocked(setup.pi.events.emit).mock.calls.at(-1)?.[1];
|
|
540
|
+
for (const denied of ["model", "prompt", "cwd", "path", "tool", "result", "assistant", "tokens", "nonce", "skills", "thinking", "report"]) {
|
|
541
|
+
expect(Object.keys(payload as object).join(" ").toLowerCase()).not.toContain(denied);
|
|
542
|
+
}
|
|
543
|
+
} finally {
|
|
544
|
+
setup.restoreEnv();
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("injects every new agent inbox message as a lead follow-up even while busy", async () => {
|
|
549
|
+
const setup = await setupExtension({}, { withSendMessage: true });
|
|
550
|
+
try {
|
|
551
|
+
writeFavoriteLevels(setup.root);
|
|
552
|
+
const ctx = makeCtx(setup.root);
|
|
553
|
+
ctx.isIdle.mockReturnValue(false);
|
|
554
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
555
|
+
|
|
556
|
+
const abort = new AbortController().signal;
|
|
557
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
558
|
+
name: "reader",
|
|
559
|
+
prompt: "Inspect this",
|
|
560
|
+
cwd: setup.root,
|
|
561
|
+
model_slot: "read-review",
|
|
562
|
+
}, abort, undefined, ctx);
|
|
563
|
+
|
|
564
|
+
const messaging = await import("../src/utils/messaging.js");
|
|
565
|
+
const targetTeamName = "session-test-session";
|
|
566
|
+
await messaging.sendPlainMessage(
|
|
567
|
+
targetTeamName,
|
|
568
|
+
"reader",
|
|
569
|
+
"team-lead",
|
|
570
|
+
"first report",
|
|
571
|
+
"First report",
|
|
572
|
+
undefined,
|
|
573
|
+
{ metadata: { finalReport: true } }
|
|
574
|
+
);
|
|
575
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
576
|
+
|
|
577
|
+
expect(setup.pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
578
|
+
expect(setup.pi.sendMessage).toHaveBeenCalledWith(
|
|
579
|
+
expect.objectContaining({ customType: "pi-extended-teams-wake", content: expect.stringContaining("A teammate finished and sent its final report"), display: false }),
|
|
580
|
+
{ triggerTurn: true, deliverAs: "followUp" }
|
|
581
|
+
);
|
|
582
|
+
const firstWake = setup.pi.sendMessage.mock.calls[0]?.[0]?.content;
|
|
583
|
+
expect(firstWake).toContain("Call read_inbox once");
|
|
584
|
+
expect(firstWake).toContain("continue the active task");
|
|
585
|
+
expect(firstWake).toContain("reporting agent is self-exiting");
|
|
586
|
+
expect(firstWake).toContain("do not call stop_teammate");
|
|
587
|
+
expect(firstWake).not.toContain("shut down finished teammates");
|
|
588
|
+
expect(setup.pi.sendUserMessage).not.toHaveBeenCalled();
|
|
589
|
+
|
|
590
|
+
await messaging.readInbox(targetTeamName, "team-lead", true, true);
|
|
591
|
+
await messaging.sendPlainMessage(targetTeamName, "reader", "team-lead", "second report", "Second report");
|
|
592
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
593
|
+
|
|
594
|
+
expect(setup.pi.sendMessage).toHaveBeenCalledTimes(2);
|
|
595
|
+
expect(setup.pi.sendMessage).toHaveBeenLastCalledWith(
|
|
596
|
+
expect.objectContaining({ customType: "pi-extended-teams-wake", content: expect.stringContaining("Check your agent messages now"), display: false }),
|
|
597
|
+
{ triggerTurn: true, deliverAs: "followUp" }
|
|
598
|
+
);
|
|
599
|
+
} finally {
|
|
600
|
+
setup.restoreEnv();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("delivers queued helper startup failures directly and retains them when the requester closes", async () => {
|
|
605
|
+
const setup = await setupExtension({}, { withSendMessage: true });
|
|
606
|
+
try {
|
|
607
|
+
writeFavoriteLevels(setup.root);
|
|
608
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
609
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), {
|
|
610
|
+
runId: member.lifecycleRunId,
|
|
611
|
+
name: member.name,
|
|
612
|
+
teamName,
|
|
613
|
+
startedAt: Date.now(),
|
|
614
|
+
tokensUsed: 0,
|
|
615
|
+
status: "working",
|
|
616
|
+
recentEvents: [],
|
|
617
|
+
lastActivityAt: Date.now(),
|
|
618
|
+
acceptingMessages: true,
|
|
619
|
+
messageDeliveryClosed: false,
|
|
620
|
+
session: { isStreaming: true },
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
setup.readAgentMock.sendMessageToRunningReadAgent.mockResolvedValue(true);
|
|
624
|
+
const ctx = makeCtx(setup.root, "queued-helper-session");
|
|
625
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
626
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
627
|
+
name: "writer",
|
|
628
|
+
prompt: "Own the parent task",
|
|
629
|
+
cwd: setup.root,
|
|
630
|
+
model_slot: "write-critical",
|
|
631
|
+
}, new AbortController().signal, undefined, ctx);
|
|
632
|
+
|
|
633
|
+
const queue = await import("../src/utils/read-helper-queue.js");
|
|
634
|
+
const messaging = await import("../src/utils/messaging.js");
|
|
635
|
+
const targetTeamName = "session-queued-helper-session";
|
|
636
|
+
await queue.enqueueReadHelperRequest(targetTeamName, {
|
|
637
|
+
requester: "writer",
|
|
638
|
+
name: "helper-start-failure",
|
|
639
|
+
prompt: "Inspect one source",
|
|
640
|
+
cwd: setup.root,
|
|
641
|
+
modelSlot: "missing-reading-slot" as any,
|
|
642
|
+
});
|
|
643
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
644
|
+
|
|
645
|
+
expect(setup.readAgentMock.sendMessageToRunningReadAgent).toHaveBeenCalledWith(
|
|
646
|
+
expect.objectContaining({ name: "writer" }),
|
|
647
|
+
expect.stringContaining("could not start for writer")
|
|
648
|
+
);
|
|
649
|
+
expect(await messaging.readInbox(targetTeamName, "writer", false, false)).toEqual([]);
|
|
650
|
+
let leadInbox = await messaging.readInbox(targetTeamName, "team-lead", false, false);
|
|
651
|
+
expect(leadInbox).toHaveLength(1);
|
|
652
|
+
expect(leadInbox[0]).toMatchObject({
|
|
653
|
+
metadata: { finalReport: true, helperCompletion: true, outcome: "failed", requestedBy: "writer" },
|
|
654
|
+
});
|
|
655
|
+
expect(leadInbox[0].text).toContain("Failure sent to writer");
|
|
656
|
+
expect(setup.pi.sendMessage).toHaveBeenCalledWith(
|
|
657
|
+
expect.objectContaining({ content: expect.stringContaining("A teammate finished and sent its final report") }),
|
|
658
|
+
{ triggerTurn: true, deliverAs: "followUp" }
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
await messaging.readInbox(targetTeamName, "team-lead", true, true);
|
|
662
|
+
await setup.teams.updateMember(targetTeamName, "writer", { isActive: false });
|
|
663
|
+
setup.readAgentMock.sendMessageToRunningReadAgent.mockRejectedValueOnce(new Error("agent is finishing"));
|
|
664
|
+
await queue.enqueueReadHelperRequest(targetTeamName, {
|
|
665
|
+
requester: "writer",
|
|
666
|
+
name: "helper-after-stop",
|
|
667
|
+
prompt: "Inspect another source",
|
|
668
|
+
cwd: setup.root,
|
|
669
|
+
modelSlot: "missing-reading-slot" as any,
|
|
670
|
+
});
|
|
671
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
672
|
+
|
|
673
|
+
expect(await messaging.readInbox(targetTeamName, "writer", false, false)).toEqual([]);
|
|
674
|
+
leadInbox = await messaging.readInbox(targetTeamName, "team-lead", false, false);
|
|
675
|
+
expect(leadInbox).toHaveLength(2);
|
|
676
|
+
expect(leadInbox[1].text).toContain("writer is no longer running; the failure is retained here");
|
|
677
|
+
} finally {
|
|
678
|
+
setup.restoreEnv();
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("observes queued-helper launch rejection without unhandled rejection or observer cleanup", async () => {
|
|
683
|
+
const setup = await setupExtension();
|
|
684
|
+
const unhandled = vi.fn();
|
|
685
|
+
process.on("unhandledRejection", unhandled);
|
|
686
|
+
try {
|
|
687
|
+
const ctx = makeCtx(setup.root, "helper-observer-session");
|
|
688
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
689
|
+
writeFavoriteLevels(setup.root);
|
|
690
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
691
|
+
name: "writer",
|
|
692
|
+
prompt: "own the task",
|
|
693
|
+
cwd: setup.root,
|
|
694
|
+
model_slot: "write-critical",
|
|
695
|
+
}, new AbortController().signal, undefined, ctx);
|
|
696
|
+
const helperDispose = vi.fn();
|
|
697
|
+
let persistedHelperState: any;
|
|
698
|
+
let helperRunningMap: Map<string, any>;
|
|
699
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementationOnce((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
700
|
+
helperRunningMap = options.runningReadAgents;
|
|
701
|
+
persistedHelperState = {
|
|
702
|
+
runId: member.lifecycleRunId,
|
|
703
|
+
name: member.name,
|
|
704
|
+
teamName,
|
|
705
|
+
startedAt: Date.now(),
|
|
706
|
+
tokensUsed: 0,
|
|
707
|
+
status: "finishing",
|
|
708
|
+
recentEvents: [],
|
|
709
|
+
lastActivityAt: Date.now(),
|
|
710
|
+
teardownState: "persistence_failed",
|
|
711
|
+
session: { dispose: helperDispose, getSessionStats: () => ({ tokens: { total: 0 } }) },
|
|
712
|
+
};
|
|
713
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), persistedHelperState);
|
|
714
|
+
return Promise.reject(new Error("terminal helper rejection"));
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const queue = await import("../src/utils/read-helper-queue.js");
|
|
718
|
+
await queue.enqueueReadHelperRequest("session-helper-observer-session", {
|
|
719
|
+
requester: "writer",
|
|
720
|
+
name: "helper",
|
|
721
|
+
prompt: "inspect one source",
|
|
722
|
+
cwd: setup.root,
|
|
723
|
+
modelSlot: "reading-fast",
|
|
724
|
+
});
|
|
725
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
726
|
+
await Promise.resolve();
|
|
727
|
+
|
|
728
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
729
|
+
expect(setup.readAgentMock.runReadAgentInProcess).toHaveBeenCalledTimes(2);
|
|
730
|
+
const config = await setup.teams.readConfig("session-helper-observer-session");
|
|
731
|
+
expect(config.members.map((member: any) => member.name)).toEqual(["team-lead", "writer", "helper"]);
|
|
732
|
+
expect(persistedHelperState.teardownState).toBe("persistence_failed");
|
|
733
|
+
expect(helperRunningMap!.get("session-helper-observer-session:helper")).toBe(persistedHelperState);
|
|
734
|
+
expect(helperDispose).not.toHaveBeenCalled();
|
|
735
|
+
expect(setup.terminal.kill).not.toHaveBeenCalled();
|
|
736
|
+
const messaging = await import("../src/utils/messaging.js");
|
|
737
|
+
expect(await messaging.readInbox("session-helper-observer-session", "team-lead", false, false)).toEqual([]);
|
|
738
|
+
} finally {
|
|
739
|
+
process.off("unhandledRejection", unhandled);
|
|
740
|
+
setup.restoreEnv();
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("observes prompt-build launch rejection without unhandled rejection or observer cleanup", async () => {
|
|
745
|
+
const setup = await setupExtension();
|
|
746
|
+
const unhandled = vi.fn();
|
|
747
|
+
process.on("unhandledRejection", unhandled);
|
|
748
|
+
try {
|
|
749
|
+
const ctx = makeCtx(setup.root, "prompt-observer-session");
|
|
750
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
751
|
+
writeFavoriteLevels(setup.root);
|
|
752
|
+
const promptDispose = vi.fn();
|
|
753
|
+
let persistedPromptState: any;
|
|
754
|
+
let promptRunningMap: Map<string, any>;
|
|
755
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementationOnce((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
756
|
+
promptRunningMap = options.runningReadAgents;
|
|
757
|
+
persistedPromptState = {
|
|
758
|
+
runId: member.lifecycleRunId,
|
|
759
|
+
name: member.name,
|
|
760
|
+
teamName,
|
|
761
|
+
startedAt: Date.now(),
|
|
762
|
+
tokensUsed: 0,
|
|
763
|
+
status: "finishing",
|
|
764
|
+
recentEvents: [],
|
|
765
|
+
lastActivityAt: Date.now(),
|
|
766
|
+
teardownState: "persistence_failed",
|
|
767
|
+
session: { dispose: promptDispose, getSessionStats: () => ({ tokens: { total: 0 } }) },
|
|
768
|
+
};
|
|
769
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), persistedPromptState);
|
|
770
|
+
return Promise.reject(new Error("terminal prompt rejection"));
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const handler = setup.extensionEventHandlers.get("pi-prompt:prompt-build:start")?.[0];
|
|
774
|
+
expect(handler).toBeTypeOf("function");
|
|
775
|
+
await handler!({
|
|
776
|
+
teamName: "prompt-observer",
|
|
777
|
+
prompts: ["build one branch"],
|
|
778
|
+
cwd: setup.root,
|
|
779
|
+
model_slot: "read-review",
|
|
780
|
+
});
|
|
781
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
782
|
+
await Promise.resolve();
|
|
783
|
+
|
|
784
|
+
expect(unhandled).not.toHaveBeenCalled();
|
|
785
|
+
expect(setup.readAgentMock.runReadAgentInProcess).toHaveBeenCalledOnce();
|
|
786
|
+
const config = await setup.teams.readConfig("prompt-observer");
|
|
787
|
+
expect(config.members.map((member: any) => member.name)).toEqual(["team-lead", "prompt-branch-1"]);
|
|
788
|
+
expect(persistedPromptState.teardownState).toBe("persistence_failed");
|
|
789
|
+
expect(promptRunningMap!.get("prompt-observer:prompt-branch-1")).toBe(persistedPromptState);
|
|
790
|
+
expect(promptDispose).not.toHaveBeenCalled();
|
|
791
|
+
expect(setup.terminal.kill).not.toHaveBeenCalled();
|
|
792
|
+
expect(setup.pi.events.emit.mock.calls.some(([name]: [string]) => name === "pi-prompt:prompt-build:error")).toBe(false);
|
|
793
|
+
} finally {
|
|
794
|
+
process.off("unhandledRejection", unhandled);
|
|
795
|
+
setup.restoreEnv();
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("renders zero before provider context usage and updates without remounting", async () => {
|
|
800
|
+
const setup = await setupExtension();
|
|
801
|
+
try {
|
|
802
|
+
let billedTokenCount = 0;
|
|
803
|
+
let contextTokenCount = 597;
|
|
804
|
+
let contextPercent = 0.3;
|
|
805
|
+
let runningState: any;
|
|
806
|
+
const abortAgent = vi.fn(async () => {});
|
|
807
|
+
const disposeAgent = vi.fn();
|
|
808
|
+
const heartbeatWork = vi.fn();
|
|
809
|
+
(setup.readAgentMock.shutdownReadAgentSession as any).mockImplementation(async (session: any) => { await session?.abort?.(); });
|
|
810
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
811
|
+
runningState = {
|
|
812
|
+
runId: member.lifecycleRunId,
|
|
813
|
+
name: member.name,
|
|
814
|
+
teamName,
|
|
815
|
+
startedAt: Date.now() - 1000,
|
|
816
|
+
tokensUsed: 12,
|
|
817
|
+
status: "thinking",
|
|
818
|
+
recentEvents: ["assistant: secret agent thought"],
|
|
819
|
+
lastActivityAt: Date.now(),
|
|
820
|
+
role: member.role,
|
|
821
|
+
model: member.model,
|
|
822
|
+
thinking: member.thinking,
|
|
823
|
+
modelSlot: member.modelSlot,
|
|
824
|
+
latestAssistantSnippet: "secret agent thought",
|
|
825
|
+
latestProgress: "Reviewing focused test coverage",
|
|
826
|
+
progressUpdatedAt: Date.now(),
|
|
827
|
+
heartbeatTimer: setInterval(heartbeatWork, 5_000),
|
|
828
|
+
session: {
|
|
829
|
+
abort: abortAgent,
|
|
830
|
+
dispose: disposeAgent,
|
|
831
|
+
getSessionStats: () => ({
|
|
832
|
+
tokens: { total: billedTokenCount },
|
|
833
|
+
contextUsage: { tokens: contextTokenCount, contextWindow: 200_000, percent: contextPercent },
|
|
834
|
+
}),
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), runningState);
|
|
838
|
+
});
|
|
839
|
+
const ctx = makeCtx(setup.root, "status-session");
|
|
840
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
841
|
+
const abort = new AbortController().signal;
|
|
842
|
+
|
|
843
|
+
writeFavoriteLevels(setup.root);
|
|
844
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
845
|
+
name: "reader",
|
|
846
|
+
prompt: "Think about this",
|
|
847
|
+
cwd: setup.root,
|
|
848
|
+
model_slot: "reading-default",
|
|
849
|
+
}, abort, undefined, ctx);
|
|
850
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
851
|
+
|
|
852
|
+
const widgetCall = [...ctx.ui.setWidget.mock.calls]
|
|
853
|
+
.reverse()
|
|
854
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function");
|
|
855
|
+
expect(widgetCall?.[2]).toEqual({ placement: "belowEditor" });
|
|
856
|
+
const requestRender = vi.fn();
|
|
857
|
+
const widget = widgetCall;
|
|
858
|
+
const initialRendered = widget.render(160).join("\n");
|
|
859
|
+
expect(initialRendered).toContain("agent activity");
|
|
860
|
+
expect(initialRendered).toMatch(/\(reader\) model\/high · read-review · 1s · 0 tok \(0%\) · Reviewing focused test coverage\.{1,3}/);
|
|
861
|
+
expect(initialRendered).not.toContain("597 tok");
|
|
862
|
+
expect(initialRendered).not.toContain("secret agent thought");
|
|
863
|
+
expect(initialRendered).not.toContain("reader read thinking");
|
|
864
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith("01-pi-extended-teams-read", undefined);
|
|
865
|
+
|
|
866
|
+
const firstWidgetCallIndex = ctx.ui.setWidget.mock.calls.indexOf(widgetCall!);
|
|
867
|
+
billedTokenCount = 2_300_000;
|
|
868
|
+
contextTokenCount = 80_000;
|
|
869
|
+
contextPercent = 40;
|
|
870
|
+
runningState.latestProgress = "Writing the final report";
|
|
871
|
+
runningState.progressUpdatedAt = Date.now();
|
|
872
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
873
|
+
|
|
874
|
+
let updatedRendered = widget.render(160).join("\n");
|
|
875
|
+
expect(updatedRendered).toContain("Reviewing focused test coverage");
|
|
876
|
+
expect(updatedRendered).not.toContain("Writing the final report");
|
|
877
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
878
|
+
updatedRendered = widget.render(160).join("\n");
|
|
879
|
+
expect(updatedRendered).toMatch(/\(reader\) model\/high · read-review · 3s · 80k tok \(40%\) · Writing the final report\.{1,3}/);
|
|
880
|
+
expect(updatedRendered).not.toContain("2.3M tok");
|
|
881
|
+
expect(requestRender).toHaveBeenCalled();
|
|
882
|
+
expect(ctx.ui.setWidget.mock.calls.filter((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function")).toHaveLength(1);
|
|
883
|
+
expect(ctx.ui.setWidget.mock.calls.slice(firstWidgetCallIndex + 1).some((call: any[]) => call[0] === "01-pi-extended-teams-readers" && call[1] === undefined)).toBe(false);
|
|
884
|
+
|
|
885
|
+
for (const handler of setup.eventHandlers.get("session_shutdown") ?? []) await handler({ reason: "reload" }, ctx);
|
|
886
|
+
expect(ctx.ui.setWidget).toHaveBeenCalledWith("01-pi-extended-teams-readers", undefined);
|
|
887
|
+
expect(abortAgent).toHaveBeenCalledOnce();
|
|
888
|
+
expect(disposeAgent).toHaveBeenCalledOnce();
|
|
889
|
+
const callCountAfterShutdown = ctx.ui.setWidget.mock.calls.length;
|
|
890
|
+
await vi.advanceTimersByTimeAsync(6_000);
|
|
891
|
+
expect(ctx.ui.setWidget).toHaveBeenCalledTimes(callCountAfterShutdown);
|
|
892
|
+
expect(heartbeatWork).not.toHaveBeenCalled();
|
|
893
|
+
} finally {
|
|
894
|
+
setup.restoreEnv();
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("adds active depth-1 read helper counts to writer rows without changing global counts", async () => {
|
|
899
|
+
const setup = await setupExtension();
|
|
900
|
+
try {
|
|
901
|
+
const runtime = await import("../src/utils/runtime.js");
|
|
902
|
+
let writerState: any;
|
|
903
|
+
let activeHelperState: any;
|
|
904
|
+
let runningAgents!: Map<string, any>;
|
|
905
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
906
|
+
runningAgents = options.runningReadAgents;
|
|
907
|
+
const state = {
|
|
908
|
+
runId: member.lifecycleRunId,
|
|
909
|
+
name: member.name,
|
|
910
|
+
teamName,
|
|
911
|
+
startedAt: Date.now() - 1_000,
|
|
912
|
+
tokensUsed: 25,
|
|
913
|
+
status: "working",
|
|
914
|
+
recentEvents: [],
|
|
915
|
+
lastActivityAt: Date.now(),
|
|
916
|
+
role: member.role,
|
|
917
|
+
model: member.model,
|
|
918
|
+
thinking: member.thinking,
|
|
919
|
+
modelSlot: member.modelSlot,
|
|
920
|
+
latestProgress: "Implementing parent work",
|
|
921
|
+
session: { getSessionStats: () => ({
|
|
922
|
+
tokens: { total: 25 },
|
|
923
|
+
contextUsage: { tokens: 25, contextWindow: 200_000, percent: 0.0125 },
|
|
924
|
+
}) },
|
|
925
|
+
};
|
|
926
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), state);
|
|
927
|
+
if (member.name === "writer") writerState = state;
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const ctx = makeCtx(setup.root, "nested-helper-activity-session");
|
|
931
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
932
|
+
writeFavoriteLevels(setup.root);
|
|
933
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
934
|
+
name: "writer",
|
|
935
|
+
prompt: "Implement a bounded feature",
|
|
936
|
+
cwd: setup.root,
|
|
937
|
+
model_slot: "write-critical",
|
|
938
|
+
allow_nested_read_agents: true,
|
|
939
|
+
}, new AbortController().signal, undefined, ctx);
|
|
940
|
+
|
|
941
|
+
const teamName = "session-nested-helper-activity-session";
|
|
942
|
+
const writer = (await setup.teams.readConfig(teamName)).members.find((member: any) => member.name === "writer")!;
|
|
943
|
+
const helperBase = {
|
|
944
|
+
agentType: "teammate",
|
|
945
|
+
role: "read" as const,
|
|
946
|
+
model: "provider/model",
|
|
947
|
+
thinking: "high" as const,
|
|
948
|
+
modelSlot: "read-review",
|
|
949
|
+
joinedAt: Date.now() - 500,
|
|
950
|
+
tmuxPaneId: "",
|
|
951
|
+
cwd: setup.root,
|
|
952
|
+
subscriptions: [],
|
|
953
|
+
delegationDepth: 1,
|
|
954
|
+
allowNestedReadAgents: false,
|
|
955
|
+
parentAgentName: "writer",
|
|
956
|
+
parentLifecycleRunId: writer.lifecycleRunId,
|
|
957
|
+
requestedBy: "writer",
|
|
958
|
+
helperKind: "read_helper" as const,
|
|
959
|
+
};
|
|
960
|
+
const activeHelper: any = { ...helperBase, agentId: "active-helper@team", name: "active-helper" };
|
|
961
|
+
const runtimeHelper: any = { ...helperBase, agentId: "runtime-helper@team", name: "runtime-helper" };
|
|
962
|
+
const completedHelper: any = { ...helperBase, agentId: "completed-helper@team", name: "completed-helper", isActive: false };
|
|
963
|
+
await setup.teams.addMember(teamName, activeHelper);
|
|
964
|
+
await setup.teams.addMember(teamName, runtimeHelper);
|
|
965
|
+
await setup.teams.addMember(teamName, completedHelper);
|
|
966
|
+
|
|
967
|
+
activeHelperState = {
|
|
968
|
+
runId: activeHelper.lifecycleRunId,
|
|
969
|
+
name: activeHelper.name,
|
|
970
|
+
teamName,
|
|
971
|
+
startedAt: Date.now() - 500,
|
|
972
|
+
tokensUsed: 5,
|
|
973
|
+
status: "working",
|
|
974
|
+
recentEvents: [],
|
|
975
|
+
lastActivityAt: Date.now(),
|
|
976
|
+
role: "read",
|
|
977
|
+
model: activeHelper.model,
|
|
978
|
+
thinking: activeHelper.thinking,
|
|
979
|
+
modelSlot: activeHelper.modelSlot,
|
|
980
|
+
session: { getSessionStats: () => ({
|
|
981
|
+
tokens: { total: 5 },
|
|
982
|
+
contextUsage: { tokens: 5, contextWindow: 200_000, percent: 0.0025 },
|
|
983
|
+
}) },
|
|
984
|
+
};
|
|
985
|
+
runningAgents.set(`${teamName}:${activeHelper.name}`, activeHelperState);
|
|
986
|
+
await runtime.writeRuntimeStatus(teamName, runtimeHelper.name, runtimeHelper.lifecycleRunId!, {
|
|
987
|
+
ready: true,
|
|
988
|
+
startedAt: Date.now() - 500,
|
|
989
|
+
lastHeartbeatAt: Date.now(),
|
|
990
|
+
currentAction: "working",
|
|
991
|
+
tokensUsed: 7,
|
|
992
|
+
contextUsage: { tokens: 7, contextWindow: 200_000, percent: 0.0035 },
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
996
|
+
const widgetCall = [...ctx.ui.setWidget.mock.calls]
|
|
997
|
+
.reverse()
|
|
998
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function");
|
|
999
|
+
const widget = widgetCall });
|
|
1000
|
+
const activeRendered = widget.render(180).join("\n");
|
|
1001
|
+
expect(activeRendered).toContain("3 active · 2 read · 1 write");
|
|
1002
|
+
expect(activeRendered).toMatch(/\(writer\) model\/xhigh · \+2 · write-critical · .* · 25 tok \(0%\) · Implementing parent work\.{1,3}/);
|
|
1003
|
+
expect(activeRendered).not.toContain("completed-helper");
|
|
1004
|
+
|
|
1005
|
+
// Failed helpers remain in their live runner/runtime records until async
|
|
1006
|
+
// teardown, but must stop contributing to the parent's helper suffix.
|
|
1007
|
+
activeHelperState.lastError = { message: "local helper failed", timestamp: Date.now() };
|
|
1008
|
+
await runtime.writeRuntimeStatus(teamName, runtimeHelper.name, runtimeHelper.lifecycleRunId!, {
|
|
1009
|
+
lastHeartbeatAt: Date.now(),
|
|
1010
|
+
lastError: { message: "runtime helper failed", timestamp: Date.now() },
|
|
1011
|
+
});
|
|
1012
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1013
|
+
const failedRendered = widget.render(180).join("\n");
|
|
1014
|
+
expect(failedRendered).toContain("3 active · 2 read · 1 write");
|
|
1015
|
+
expect(failedRendered).not.toContain("(writer) model/xhigh · +");
|
|
1016
|
+
|
|
1017
|
+
activeHelperState.teardownState = "finalized";
|
|
1018
|
+
await runtime.writeRuntimeStatus(teamName, runtimeHelper.name, runtimeHelper.lifecycleRunId!, {
|
|
1019
|
+
lastHeartbeatAt: Date.now() - runtime.HEARTBEAT_STALE_MS - 1,
|
|
1020
|
+
});
|
|
1021
|
+
writerState.latestProgress = undefined;
|
|
1022
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1023
|
+
|
|
1024
|
+
const finishedRendered = widget.render(180).join("\n");
|
|
1025
|
+
expect(finishedRendered).toContain("1 active · 1 write");
|
|
1026
|
+
expect(finishedRendered).not.toContain(" read");
|
|
1027
|
+
expect(finishedRendered).not.toContain("(writer) +");
|
|
1028
|
+
expect(finishedRendered).not.toContain("active-helper");
|
|
1029
|
+
expect(finishedRendered).not.toContain("runtime-helper");
|
|
1030
|
+
} finally {
|
|
1031
|
+
setup.restoreEnv();
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("removes a failed helper suffix while a real rejected-prompt run remains live for delivery", async () => {
|
|
1036
|
+
const setup = await setupExtension();
|
|
1037
|
+
try {
|
|
1038
|
+
let writerState: any;
|
|
1039
|
+
let runningAgents!: Map<string, any>;
|
|
1040
|
+
let readAgentOptions: any;
|
|
1041
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
1042
|
+
runningAgents = options.runningReadAgents;
|
|
1043
|
+
readAgentOptions = options;
|
|
1044
|
+
writerState = {
|
|
1045
|
+
runId: member.lifecycleRunId,
|
|
1046
|
+
name: member.name,
|
|
1047
|
+
teamName,
|
|
1048
|
+
startedAt: Date.now() - 1_000,
|
|
1049
|
+
tokensUsed: 25,
|
|
1050
|
+
status: "working",
|
|
1051
|
+
recentEvents: [],
|
|
1052
|
+
lastActivityAt: Date.now(),
|
|
1053
|
+
role: member.role,
|
|
1054
|
+
model: member.model,
|
|
1055
|
+
thinking: member.thinking,
|
|
1056
|
+
modelSlot: member.modelSlot,
|
|
1057
|
+
latestProgress: "Implementing parent work",
|
|
1058
|
+
session: { getSessionStats: () => ({ tokens: { total: 25 } }) },
|
|
1059
|
+
};
|
|
1060
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), writerState);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
const ctx = makeCtx(setup.root, "real-helper-failure-session");
|
|
1064
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1065
|
+
writeFavoriteLevels(setup.root);
|
|
1066
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1067
|
+
name: "writer",
|
|
1068
|
+
prompt: "Implement a bounded feature",
|
|
1069
|
+
cwd: setup.root,
|
|
1070
|
+
model_slot: "write-critical",
|
|
1071
|
+
allow_nested_read_agents: true,
|
|
1072
|
+
}, new AbortController().signal, undefined, ctx);
|
|
1073
|
+
|
|
1074
|
+
const teamName = "session-real-helper-failure-session";
|
|
1075
|
+
const writer = (await setup.teams.readConfig(teamName)).members.find((member: any) => member.name === "writer")!;
|
|
1076
|
+
const helper: any = {
|
|
1077
|
+
agentId: `failing-helper@${teamName}`,
|
|
1078
|
+
name: "failing-helper",
|
|
1079
|
+
agentType: "teammate",
|
|
1080
|
+
role: "read",
|
|
1081
|
+
model: "provider/model",
|
|
1082
|
+
thinking: "high",
|
|
1083
|
+
modelSlot: "read-review",
|
|
1084
|
+
joinedAt: Date.now(),
|
|
1085
|
+
tmuxPaneId: "",
|
|
1086
|
+
cwd: setup.root,
|
|
1087
|
+
subscriptions: [],
|
|
1088
|
+
delegationDepth: 1,
|
|
1089
|
+
allowNestedReadAgents: false,
|
|
1090
|
+
parentAgentName: "writer",
|
|
1091
|
+
parentLifecycleRunId: writer.lifecycleRunId,
|
|
1092
|
+
requestedBy: "writer",
|
|
1093
|
+
helperKind: "read_helper",
|
|
1094
|
+
};
|
|
1095
|
+
await setup.teams.addMember(teamName, helper);
|
|
1096
|
+
|
|
1097
|
+
let markPromptStarted!: () => void;
|
|
1098
|
+
const promptStarted = new Promise<void>((resolve) => { markPromptStarted = resolve; });
|
|
1099
|
+
let rejectPrompt!: (error: Error) => void;
|
|
1100
|
+
const promptGate = new Promise<void>((_resolve, reject) => { rejectPrompt = reject; });
|
|
1101
|
+
const session = {
|
|
1102
|
+
messages: [],
|
|
1103
|
+
getSessionStats: vi.fn(() => ({ tokens: { total: 5 }, cost: 0 })),
|
|
1104
|
+
subscribe: vi.fn(),
|
|
1105
|
+
prompt: vi.fn(() => {
|
|
1106
|
+
markPromptStarted();
|
|
1107
|
+
return promptGate;
|
|
1108
|
+
}),
|
|
1109
|
+
bindExtensions: vi.fn(async () => {}),
|
|
1110
|
+
sendUserMessage: vi.fn(async () => {}),
|
|
1111
|
+
isStreaming: true,
|
|
1112
|
+
hasExtensionHandlers: vi.fn(() => false),
|
|
1113
|
+
extensionRunner: { emit: vi.fn(async () => {}) },
|
|
1114
|
+
clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
|
|
1115
|
+
abort: vi.fn(async () => {}),
|
|
1116
|
+
dispose: vi.fn(),
|
|
1117
|
+
};
|
|
1118
|
+
vi.doMock("./internal/pi-runtime-api.js", () => ({
|
|
1119
|
+
loadPiRuntimeApi: async () => ({
|
|
1120
|
+
createAgentSession: vi.fn(async () => ({ session })),
|
|
1121
|
+
DefaultResourceLoader: class {
|
|
1122
|
+
async reload() {}
|
|
1123
|
+
getExtensions() { return { extensions: [], errors: [], runtime: {} }; }
|
|
1124
|
+
},
|
|
1125
|
+
getAgentDir: () => "/mock-agent-dir",
|
|
1126
|
+
SettingsManager: {
|
|
1127
|
+
create: vi.fn(() => ({
|
|
1128
|
+
getGlobalSettings: () => ({}),
|
|
1129
|
+
getProjectSettings: () => ({}),
|
|
1130
|
+
})),
|
|
1131
|
+
},
|
|
1132
|
+
SessionManager: {
|
|
1133
|
+
create: vi.fn(() => ({
|
|
1134
|
+
getSessionFile: vi.fn(() => undefined),
|
|
1135
|
+
getSessionId: vi.fn(() => "failing-helper-session"),
|
|
1136
|
+
})),
|
|
1137
|
+
},
|
|
1138
|
+
}),
|
|
1139
|
+
}));
|
|
1140
|
+
const actualReadAgent = await vi.importActual<typeof import("./agents/read-agent.js")>("./agents/read-agent.js");
|
|
1141
|
+
let markFailureDeliveryStarted!: () => void;
|
|
1142
|
+
const failureDeliveryStarted = new Promise<void>((resolve) => { markFailureDeliveryStarted = resolve; });
|
|
1143
|
+
let releaseFailureDelivery!: () => void;
|
|
1144
|
+
const failureDeliveryGate = new Promise<void>((resolve) => { releaseFailureDelivery = resolve; });
|
|
1145
|
+
const helperOptions = {
|
|
1146
|
+
...readAgentOptions,
|
|
1147
|
+
deliverMessageToActiveAgent: vi.fn(async () => {
|
|
1148
|
+
markFailureDeliveryStarted();
|
|
1149
|
+
await failureDeliveryGate;
|
|
1150
|
+
return true;
|
|
1151
|
+
}),
|
|
1152
|
+
renderLeadInboxStatus: vi.fn(async () => {}),
|
|
1153
|
+
notifyLeadOfInboxReports: vi.fn(async () => {}),
|
|
1154
|
+
createResourcePlan: vi.fn(async () => ({
|
|
1155
|
+
selectionMode: "default" as const,
|
|
1156
|
+
extensionPaths: [],
|
|
1157
|
+
extensions: [],
|
|
1158
|
+
diagnostics: [],
|
|
1159
|
+
skills: "all" as const,
|
|
1160
|
+
trust: { cwd: setup.root, projectTrusted: true },
|
|
1161
|
+
})),
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const run = actualReadAgent.runReadAgentInProcess(teamName, helper, "fail after starting", ctx, helperOptions);
|
|
1165
|
+
await promptStarted;
|
|
1166
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1167
|
+
const widgetCall = [...ctx.ui.setWidget.mock.calls]
|
|
1168
|
+
.reverse()
|
|
1169
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function");
|
|
1170
|
+
const widget = widgetCall });
|
|
1171
|
+
expect(widget.render(180).join("\n")).toMatch(/\(writer\) model\/xhigh · \+1 · write-critical/);
|
|
1172
|
+
|
|
1173
|
+
rejectPrompt(new Error("provider rejected prompt"));
|
|
1174
|
+
await failureDeliveryStarted;
|
|
1175
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1176
|
+
const liveFailureState = runningAgents.get(`${teamName}:failing-helper`);
|
|
1177
|
+
expect(liveFailureState?.lastError).toMatchObject({ message: "provider rejected prompt" });
|
|
1178
|
+
expect(runningAgents.get(`${teamName}:failing-helper`)).toBe(liveFailureState);
|
|
1179
|
+
const failedRendered = widget.render(180).join("\n");
|
|
1180
|
+
expect(failedRendered).toContain("2 active · 1 read · 1 write");
|
|
1181
|
+
expect(failedRendered).not.toMatch(/\(writer\) model\/xhigh · \+/);
|
|
1182
|
+
|
|
1183
|
+
releaseFailureDelivery();
|
|
1184
|
+
await run;
|
|
1185
|
+
expect(runningAgents.has(`${teamName}:failing-helper`)).toBe(false);
|
|
1186
|
+
expect(writerState.latestProgress).toBe("Implementing parent work");
|
|
1187
|
+
} finally {
|
|
1188
|
+
setup.restoreEnv();
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("quarantines a parent-shutdown delivery timeout and defers child cleanup", async () => {
|
|
1193
|
+
const setup = await setupExtension();
|
|
1194
|
+
try {
|
|
1195
|
+
let settleDelivery!: () => void;
|
|
1196
|
+
const rawDelivery = new Promise<void>((resolve) => { settleDelivery = resolve; });
|
|
1197
|
+
const disposeAgent = vi.fn();
|
|
1198
|
+
const emitShutdown = vi.fn(async () => {});
|
|
1199
|
+
let runningState: any;
|
|
1200
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
1201
|
+
runningState = {
|
|
1202
|
+
runId: member.lifecycleRunId,
|
|
1203
|
+
name: member.name,
|
|
1204
|
+
teamName,
|
|
1205
|
+
startedAt: Date.now(),
|
|
1206
|
+
tokensUsed: 0,
|
|
1207
|
+
status: "working",
|
|
1208
|
+
recentEvents: [],
|
|
1209
|
+
lastActivityAt: Date.now(),
|
|
1210
|
+
role: member.role,
|
|
1211
|
+
model: member.model,
|
|
1212
|
+
thinking: member.thinking,
|
|
1213
|
+
modelSlot: member.modelSlot,
|
|
1214
|
+
acceptingMessages: true,
|
|
1215
|
+
messageDeliveryClosed: false,
|
|
1216
|
+
messageDeliveryTail: rawDelivery,
|
|
1217
|
+
session: {
|
|
1218
|
+
hasExtensionHandlers: vi.fn(() => true),
|
|
1219
|
+
extensionRunner: { emit: emitShutdown },
|
|
1220
|
+
clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
|
|
1221
|
+
abort: vi.fn(async () => {}),
|
|
1222
|
+
dispose: disposeAgent,
|
|
1223
|
+
getSessionStats: () => ({ tokens: { total: 0 } }),
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), runningState);
|
|
1227
|
+
});
|
|
1228
|
+
const ctx = makeCtx(setup.root, "shutdown-timeout-session");
|
|
1229
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1230
|
+
writeFavoriteLevels(setup.root);
|
|
1231
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1232
|
+
name: "reader",
|
|
1233
|
+
prompt: "Inspect this",
|
|
1234
|
+
cwd: setup.root,
|
|
1235
|
+
model_slot: "read-review",
|
|
1236
|
+
}, new AbortController().signal, undefined, ctx);
|
|
1237
|
+
|
|
1238
|
+
const shutdown = Promise.all(
|
|
1239
|
+
(setup.eventHandlers.get("session_shutdown") ?? []).map(handler => handler({ reason: "reload" }, ctx))
|
|
1240
|
+
);
|
|
1241
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1242
|
+
|
|
1243
|
+
const teamName = "session-shutdown-timeout-session";
|
|
1244
|
+
const stoppingMember = (await setup.teams.readConfig(teamName)).members.find((item: any) => item.name === "reader");
|
|
1245
|
+
expect(stoppingMember?.isActive).toBe(false);
|
|
1246
|
+
const messaging = await import("../src/utils/messaging.js");
|
|
1247
|
+
await expect(
|
|
1248
|
+
messaging.sendPlainMessageIfRunning(teamName, "team-lead", "reader", "late message", "Late")
|
|
1249
|
+
).rejects.toThrow("lifecycle-quarantined");
|
|
1250
|
+
|
|
1251
|
+
await vi.advanceTimersByTimeAsync(2_500);
|
|
1252
|
+
await shutdown;
|
|
1253
|
+
expect(runningState.teardownState).toBe("quarantined");
|
|
1254
|
+
expect((await setup.teams.readConfig(teamName)).members.map((item: any) => item.name)).toEqual(["team-lead", "reader"]);
|
|
1255
|
+
expect(emitShutdown).toHaveBeenCalledOnce();
|
|
1256
|
+
expect(emitShutdown).toHaveBeenCalledWith({ type: "session_shutdown", reason: "reload" });
|
|
1257
|
+
expect(disposeAgent).not.toHaveBeenCalled();
|
|
1258
|
+
|
|
1259
|
+
// A repeated parent shutdown consumes the cached bounded proof and does
|
|
1260
|
+
// not wait for or advance the raw teardown timer.
|
|
1261
|
+
const repeatedShutdown = Promise.all(
|
|
1262
|
+
(setup.eventHandlers.get("session_shutdown") ?? []).map(handler => handler({ reason: "quit" }, ctx))
|
|
1263
|
+
);
|
|
1264
|
+
await repeatedShutdown;
|
|
1265
|
+
expect(emitShutdown).toHaveBeenCalledOnce();
|
|
1266
|
+
expect(disposeAgent).not.toHaveBeenCalled();
|
|
1267
|
+
|
|
1268
|
+
settleDelivery();
|
|
1269
|
+
await runningState.teardownFinalizationPromise;
|
|
1270
|
+
expect((await setup.teams.readConfig(teamName)).members.map((item: any) => item.name)).toEqual(["team-lead"]);
|
|
1271
|
+
expect(emitShutdown).toHaveBeenCalledOnce();
|
|
1272
|
+
expect(disposeAgent).toHaveBeenCalledOnce();
|
|
1273
|
+
} finally {
|
|
1274
|
+
setup.restoreEnv();
|
|
1275
|
+
}
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it("does not dispose agents when extension shutdown cannot close persisted admission", async () => {
|
|
1279
|
+
const setup = await setupExtension();
|
|
1280
|
+
try {
|
|
1281
|
+
const abortAgent = vi.fn(async () => {});
|
|
1282
|
+
const disposeAgent = vi.fn();
|
|
1283
|
+
const emitShutdown = vi.fn(async () => {});
|
|
1284
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
1285
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), {
|
|
1286
|
+
runId: member.lifecycleRunId,
|
|
1287
|
+
name: member.name,
|
|
1288
|
+
teamName,
|
|
1289
|
+
startedAt: Date.now(),
|
|
1290
|
+
tokensUsed: 0,
|
|
1291
|
+
status: "working",
|
|
1292
|
+
recentEvents: [],
|
|
1293
|
+
lastActivityAt: Date.now(),
|
|
1294
|
+
acceptingMessages: true,
|
|
1295
|
+
messageDeliveryClosed: false,
|
|
1296
|
+
session: {
|
|
1297
|
+
hasExtensionHandlers: vi.fn(() => true),
|
|
1298
|
+
extensionRunner: { emit: emitShutdown },
|
|
1299
|
+
clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
|
|
1300
|
+
abort: abortAgent,
|
|
1301
|
+
dispose: disposeAgent,
|
|
1302
|
+
getSessionStats: () => ({ tokens: { total: 0 } }),
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
const ctx = makeCtx(setup.root, "shutdown-close-failure-session");
|
|
1307
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1308
|
+
writeFavoriteLevels(setup.root);
|
|
1309
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1310
|
+
name: "reader",
|
|
1311
|
+
prompt: "Inspect this",
|
|
1312
|
+
cwd: setup.root,
|
|
1313
|
+
model_slot: "read-review",
|
|
1314
|
+
}, new AbortController().signal, undefined, ctx);
|
|
1315
|
+
|
|
1316
|
+
const paths = await import("../src/utils/paths.js");
|
|
1317
|
+
const configPath = paths.configPath("session-shutdown-close-failure-session");
|
|
1318
|
+
fs.writeFileSync(configPath, "{ malformed config");
|
|
1319
|
+
const shutdown = Promise.all(
|
|
1320
|
+
(setup.eventHandlers.get("session_shutdown") ?? []).map(handler => handler({ reason: "reload" }, ctx))
|
|
1321
|
+
);
|
|
1322
|
+
await expect(shutdown).rejects.toThrow(
|
|
1323
|
+
"Could not close 1 agent recipient(s) during extension shutdown"
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
expect(fs.readFileSync(configPath, "utf-8")).toBe("{ malformed config");
|
|
1327
|
+
expect(emitShutdown).not.toHaveBeenCalled();
|
|
1328
|
+
expect(abortAgent).not.toHaveBeenCalled();
|
|
1329
|
+
expect(disposeAgent).not.toHaveBeenCalled();
|
|
1330
|
+
} finally {
|
|
1331
|
+
setup.restoreEnv();
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
it("opens a full-window live agent view with down and returns to main with up", async () => {
|
|
1336
|
+
const setup = await setupExtension();
|
|
1337
|
+
try {
|
|
1338
|
+
let followedComponent: any;
|
|
1339
|
+
const done = vi.fn();
|
|
1340
|
+
const baseEditor = { getText: vi.fn(() => ""), handleInput: vi.fn() };
|
|
1341
|
+
const ctx = makeCtx(setup.root, "follow-session");
|
|
1342
|
+
ctx.ui.getEditorComponent.mockReturnValue(() => baseEditor);
|
|
1343
|
+
ctx.ui.custom.mockImplementation(async (factory: any, options: any) => {
|
|
1344
|
+
expect(options).toMatchObject({
|
|
1345
|
+
overlay: true,
|
|
1346
|
+
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center", margin: 0 },
|
|
1347
|
+
});
|
|
1348
|
+
followedComponent = factory({ terminal: { rows: 30 }, requestRender: vi.fn() }, {}, {}, done);
|
|
1349
|
+
});
|
|
1350
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
1351
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), {
|
|
1352
|
+
runId: member.lifecycleRunId,
|
|
1353
|
+
name: member.name,
|
|
1354
|
+
teamName,
|
|
1355
|
+
startedAt: Date.now() - 2_000,
|
|
1356
|
+
tokensUsed: 88,
|
|
1357
|
+
status: "working",
|
|
1358
|
+
recentEvents: [],
|
|
1359
|
+
lastActivityAt: Date.now(),
|
|
1360
|
+
role: member.role,
|
|
1361
|
+
model: member.model,
|
|
1362
|
+
thinking: member.thinking,
|
|
1363
|
+
modelSlot: member.modelSlot,
|
|
1364
|
+
latestProgress: "Inspecting the codebase",
|
|
1365
|
+
session: {
|
|
1366
|
+
messages: [{ role: "assistant", content: [{ type: "text", text: "Live agent output" }] }],
|
|
1367
|
+
getSessionStats: () => ({ tokens: { total: 88 } }),
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1373
|
+
writeFavoriteLevels(setup.root);
|
|
1374
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1375
|
+
name: "reader",
|
|
1376
|
+
prompt: "Inspect this",
|
|
1377
|
+
cwd: setup.root,
|
|
1378
|
+
model_slot: "read-review",
|
|
1379
|
+
}, new AbortController().signal, undefined, ctx);
|
|
1380
|
+
|
|
1381
|
+
const editorFactory = ctx.ui.setEditorComponent.mock.calls.at(-1)?.[0];
|
|
1382
|
+
const editor = editorFactory({}, {}, {});
|
|
1383
|
+
editor.handleInput("\x1b[B");
|
|
1384
|
+
|
|
1385
|
+
expect(ctx.ui.custom).toHaveBeenCalledOnce();
|
|
1386
|
+
expect(followedComponent.render(140).join("\n")).toContain("Live agent output");
|
|
1387
|
+
|
|
1388
|
+
followedComponent.handleInput("x");
|
|
1389
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
1390
|
+
const messaging = await import("../src/utils/messaging.js");
|
|
1391
|
+
const leadMessages = await messaging.readInbox("session-follow-session", "team-lead", false, false);
|
|
1392
|
+
expect(leadMessages).toContainEqual(expect.objectContaining({
|
|
1393
|
+
from: "system",
|
|
1394
|
+
summary: "Subagent reader kill incomplete",
|
|
1395
|
+
text: "The user tried to kill subagent reader, but teardown ended with status cleanup_failed.",
|
|
1396
|
+
read: false,
|
|
1397
|
+
}));
|
|
1398
|
+
|
|
1399
|
+
followedComponent.handleInput("\x1b[A");
|
|
1400
|
+
expect(done).toHaveBeenCalledOnce();
|
|
1401
|
+
followedComponent.dispose();
|
|
1402
|
+
} finally {
|
|
1403
|
+
setup.restoreEnv();
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
it("clears the activity card instead of showing stale ready runtime JSON", async () => {
|
|
1408
|
+
const setup = await setupExtension();
|
|
1409
|
+
try {
|
|
1410
|
+
const runtime = await import("../src/utils/runtime.js");
|
|
1411
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation(() => {});
|
|
1412
|
+
const ctx = makeCtx(setup.root, "runtime-only-status-session");
|
|
1413
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1414
|
+
const abort = new AbortController().signal;
|
|
1415
|
+
|
|
1416
|
+
writeFavoriteLevels(setup.root);
|
|
1417
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1418
|
+
name: "reader",
|
|
1419
|
+
prompt: "Think about this",
|
|
1420
|
+
cwd: setup.root,
|
|
1421
|
+
model_slot: "reading-default",
|
|
1422
|
+
}, abort, undefined, ctx);
|
|
1423
|
+
|
|
1424
|
+
const now = Date.now();
|
|
1425
|
+
const readerRunId = (await setup.teams.readConfig("session-runtime-only-status-session")).members
|
|
1426
|
+
.find((member: any) => member.name === "reader")!.lifecycleRunId!;
|
|
1427
|
+
await runtime.writeRuntimeStatus("session-runtime-only-status-session", "reader", readerRunId, {
|
|
1428
|
+
pid: process.pid,
|
|
1429
|
+
startedAt: now - 120_000,
|
|
1430
|
+
lastHeartbeatAt: now - runtime.HEARTBEAT_STALE_MS - 1_000,
|
|
1431
|
+
ready: true,
|
|
1432
|
+
currentAction: "thinking",
|
|
1433
|
+
tokensUsed: 7,
|
|
1434
|
+
latestProgress: "Tracing runtime visibility",
|
|
1435
|
+
progressUpdatedAt: now - 5_000,
|
|
1436
|
+
});
|
|
1437
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1438
|
+
|
|
1439
|
+
const latestActivityCall = [...ctx.ui.setWidget.mock.calls]
|
|
1440
|
+
.reverse()
|
|
1441
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers");
|
|
1442
|
+
expect(latestActivityCall?.[1]).toBeUndefined();
|
|
1443
|
+
expect(ctx.ui.setWidget.mock.calls.some((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function")).toBe(false);
|
|
1444
|
+
} finally {
|
|
1445
|
+
setup.restoreEnv();
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it("renders an orphaned occupied tombstone as inactive quarantine, then clears the latest activity widget after matching release", async () => {
|
|
1450
|
+
const setup = await setupExtension();
|
|
1451
|
+
try {
|
|
1452
|
+
const lifecycle = await import("../src/utils/lifecycle-tombstone.js");
|
|
1453
|
+
const paths = await import("../src/utils/paths.js");
|
|
1454
|
+
const teamName = "session-tombstone-activity-session";
|
|
1455
|
+
setup.teams.createTeam(teamName, "tombstone-activity-session", "lead", "", "provider/model");
|
|
1456
|
+
fs.writeFileSync(paths.leadSessionPath(teamName), JSON.stringify({
|
|
1457
|
+
pid: process.pid,
|
|
1458
|
+
sessionId: "tombstone-activity-session",
|
|
1459
|
+
startedAt: Date.now(),
|
|
1460
|
+
}));
|
|
1461
|
+
await lifecycle.withLifecycleTombstoneLock(teamName, "orphan-writer", async lock => {
|
|
1462
|
+
lock.occupy({
|
|
1463
|
+
team: teamName,
|
|
1464
|
+
agent: "orphan-writer",
|
|
1465
|
+
runId: "orphan-run",
|
|
1466
|
+
role: "write",
|
|
1467
|
+
reason: "quit",
|
|
1468
|
+
extensionInstanceId: "index-production-test",
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
const ctx = makeCtx(setup.root, "tombstone-activity-session");
|
|
1472
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1473
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1474
|
+
|
|
1475
|
+
const mountedActivityCall = [...ctx.ui.setWidget.mock.calls]
|
|
1476
|
+
.reverse()
|
|
1477
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function");
|
|
1478
|
+
const widget = mountedActivityCall });
|
|
1479
|
+
const rendered = widget.render(160).join("\n");
|
|
1480
|
+
expect(rendered).toContain("orphan-writer");
|
|
1481
|
+
expect(rendered).toContain("inactive");
|
|
1482
|
+
expect(rendered).toContain("quarantined");
|
|
1483
|
+
expect(rendered).toContain("run orphan-run");
|
|
1484
|
+
|
|
1485
|
+
await lifecycle.withLifecycleTombstoneLock(teamName, "orphan-writer", async lock => {
|
|
1486
|
+
expect(lock.clearMatching("orphan-run")).toBe(true);
|
|
1487
|
+
});
|
|
1488
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1489
|
+
const latestActivityCall = [...ctx.ui.setWidget.mock.calls]
|
|
1490
|
+
.reverse()
|
|
1491
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers");
|
|
1492
|
+
expect(latestActivityCall?.[1]).toBeUndefined();
|
|
1493
|
+
} finally {
|
|
1494
|
+
setup.restoreEnv();
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
it.each(["stopping", "quarantined", "persistence_failed", "finalized"] as const)(
|
|
1499
|
+
"excludes %s read teardown state from the active card and clears it",
|
|
1500
|
+
async (teardownState) => {
|
|
1501
|
+
const setup = await setupExtension();
|
|
1502
|
+
try {
|
|
1503
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation((teamName: string, member: any, _prompt: string, _ctx: any, options: any) => {
|
|
1504
|
+
options.runningReadAgents.set(options.readAgentKey(teamName, member.name), {
|
|
1505
|
+
runId: member.lifecycleRunId,
|
|
1506
|
+
name: member.name,
|
|
1507
|
+
teamName,
|
|
1508
|
+
startedAt: Date.now(),
|
|
1509
|
+
tokensUsed: 0,
|
|
1510
|
+
status: "finishing",
|
|
1511
|
+
recentEvents: [],
|
|
1512
|
+
lastActivityAt: Date.now(),
|
|
1513
|
+
role: member.role,
|
|
1514
|
+
teardownState,
|
|
1515
|
+
});
|
|
1516
|
+
});
|
|
1517
|
+
const ctx = makeCtx(setup.root, `teardown-${teardownState}-session`);
|
|
1518
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1519
|
+
writeFavoriteLevels(setup.root);
|
|
1520
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1521
|
+
name: "reader",
|
|
1522
|
+
prompt: "Think about this",
|
|
1523
|
+
cwd: setup.root,
|
|
1524
|
+
model_slot: "reading-default",
|
|
1525
|
+
}, new AbortController().signal, undefined, ctx);
|
|
1526
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1527
|
+
|
|
1528
|
+
expect(ctx.ui.setWidget).toHaveBeenCalledWith("01-pi-extended-teams-readers", undefined);
|
|
1529
|
+
expect(ctx.ui.setWidget.mock.calls.some((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function")).toBe(false);
|
|
1530
|
+
} finally {
|
|
1531
|
+
setup.restoreEnv();
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1536
|
+
it("shows fresh runtime and active legacy panes while omitting inactive live panes", async () => {
|
|
1537
|
+
const setup = await setupExtension();
|
|
1538
|
+
try {
|
|
1539
|
+
const runtime = await import("../src/utils/runtime.js");
|
|
1540
|
+
setup.readAgentMock.runReadAgentInProcess.mockImplementation(() => {});
|
|
1541
|
+
const ctx = makeCtx(setup.root, "activity-classification-session");
|
|
1542
|
+
for (const handler of setup.eventHandlers.get("session_start") ?? []) await handler({}, ctx);
|
|
1543
|
+
writeFavoriteLevels(setup.root);
|
|
1544
|
+
await setup.tools.get("spawn_agent")!.execute("spawn", {
|
|
1545
|
+
name: "fresh-reader",
|
|
1546
|
+
prompt: "Think about this",
|
|
1547
|
+
cwd: setup.root,
|
|
1548
|
+
model_slot: "reading-default",
|
|
1549
|
+
}, new AbortController().signal, undefined, ctx);
|
|
1550
|
+
|
|
1551
|
+
const teamName = "session-activity-classification-session";
|
|
1552
|
+
const now = Date.now();
|
|
1553
|
+
await setup.teams.addMember(teamName, {
|
|
1554
|
+
agentId: "legacy-writer@team",
|
|
1555
|
+
name: "legacy-writer",
|
|
1556
|
+
agentType: "teammate",
|
|
1557
|
+
role: "write",
|
|
1558
|
+
model: "provider/model",
|
|
1559
|
+
thinking: "high",
|
|
1560
|
+
modelSlot: "writing-hard",
|
|
1561
|
+
joinedAt: now - 60_000,
|
|
1562
|
+
tmuxPaneId: "%legacy",
|
|
1563
|
+
cwd: setup.root,
|
|
1564
|
+
subscriptions: [],
|
|
1565
|
+
isActive: true,
|
|
1566
|
+
});
|
|
1567
|
+
await setup.teams.addMember(teamName, {
|
|
1568
|
+
agentId: "runtime-writer@team",
|
|
1569
|
+
name: "runtime-writer",
|
|
1570
|
+
agentType: "teammate",
|
|
1571
|
+
role: "write",
|
|
1572
|
+
model: "provider/model",
|
|
1573
|
+
thinking: "high",
|
|
1574
|
+
modelSlot: "writing-hard",
|
|
1575
|
+
joinedAt: now - 60_000,
|
|
1576
|
+
tmuxPaneId: "",
|
|
1577
|
+
cwd: setup.root,
|
|
1578
|
+
subscriptions: [],
|
|
1579
|
+
isActive: true,
|
|
1580
|
+
});
|
|
1581
|
+
await setup.teams.addMember(teamName, {
|
|
1582
|
+
agentId: "inactive-writer@team",
|
|
1583
|
+
name: "inactive-writer",
|
|
1584
|
+
agentType: "teammate",
|
|
1585
|
+
role: "write",
|
|
1586
|
+
model: "provider/model",
|
|
1587
|
+
joinedAt: now - 60_000,
|
|
1588
|
+
tmuxPaneId: "%inactive",
|
|
1589
|
+
cwd: setup.root,
|
|
1590
|
+
subscriptions: [],
|
|
1591
|
+
isActive: false,
|
|
1592
|
+
});
|
|
1593
|
+
const activityConfig = await setup.teams.readConfig(teamName);
|
|
1594
|
+
const runIdFor = (name: string) => activityConfig.members.find((member: any) => member.name === name)!.lifecycleRunId!;
|
|
1595
|
+
await runtime.writeRuntimeStatus(teamName, "fresh-reader", runIdFor("fresh-reader"), {
|
|
1596
|
+
ready: true,
|
|
1597
|
+
startedAt: now - 60_000,
|
|
1598
|
+
lastHeartbeatAt: now,
|
|
1599
|
+
currentAction: "thinking",
|
|
1600
|
+
latestProgress: "Fresh runtime work",
|
|
1601
|
+
});
|
|
1602
|
+
await runtime.writeRuntimeStatus(teamName, "runtime-writer", runIdFor("runtime-writer"), {
|
|
1603
|
+
ready: true,
|
|
1604
|
+
startedAt: now - 60_000,
|
|
1605
|
+
lastHeartbeatAt: now,
|
|
1606
|
+
currentAction: "working",
|
|
1607
|
+
latestProgress: "Canonical runtime slot",
|
|
1608
|
+
});
|
|
1609
|
+
await runtime.writeRuntimeStatus(teamName, "legacy-writer", runIdFor("legacy-writer"), {
|
|
1610
|
+
ready: true,
|
|
1611
|
+
startedAt: now - 60_000,
|
|
1612
|
+
lastHeartbeatAt: now - runtime.HEARTBEAT_STALE_MS - 1,
|
|
1613
|
+
currentAction: "working",
|
|
1614
|
+
});
|
|
1615
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
1616
|
+
|
|
1617
|
+
const widgetCall = [...ctx.ui.setWidget.mock.calls]
|
|
1618
|
+
.reverse()
|
|
1619
|
+
.find((call: any[]) => call[0] === "01-pi-extended-teams-readers" && typeof call[1] === "function");
|
|
1620
|
+
const widget = widgetCall });
|
|
1621
|
+
const rendered = widget.render(160).join("\n");
|
|
1622
|
+
expect(rendered).toContain("3 active · 1 read · 2 write");
|
|
1623
|
+
expect(rendered).toContain("fresh-reader");
|
|
1624
|
+
expect(rendered).toContain("Fresh runtime work");
|
|
1625
|
+
expect(rendered).toContain("runtime-writer");
|
|
1626
|
+
expect(rendered).toContain("Canonical runtime slot");
|
|
1627
|
+
expect(rendered).toContain("legacy-writer");
|
|
1628
|
+
expect(rendered).toContain("write-system");
|
|
1629
|
+
expect(rendered).not.toContain("writing-hard");
|
|
1630
|
+
expect(rendered).not.toContain("inactive-writer");
|
|
1631
|
+
} finally {
|
|
1632
|
+
setup.restoreEnv();
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it("spawn_swarm_agents starts a batch with default and per-agent levels", async () => {
|
|
1637
|
+
const setup = await setupExtension();
|
|
1638
|
+
try {
|
|
1639
|
+
writeFavoriteLevels(setup.root);
|
|
1640
|
+
const ctx = makeCtx(setup.root, "swarm-session");
|
|
1641
|
+
const abort = new AbortController().signal;
|
|
1642
|
+
|
|
1643
|
+
const result = await setup.tools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
1644
|
+
defaults: { cwd: setup.root, model_slot: "read-review" },
|
|
1645
|
+
agents: [
|
|
1646
|
+
{ name: "one", prompt: "Inspect one" },
|
|
1647
|
+
{ name: "two", prompt: "Inspect two", model_slot: "read-critical" },
|
|
1648
|
+
],
|
|
1649
|
+
}, abort, undefined, ctx);
|
|
1650
|
+
|
|
1651
|
+
expect(result.details.spawned).toHaveLength(2);
|
|
1652
|
+
expect(setup.readAgentMock.runReadAgentInProcess).toHaveBeenCalledTimes(2);
|
|
1653
|
+
expect(setup.readAgentMock.runReadAgentInProcess.mock.calls[0][1]).toMatchObject({ name: "one", thinking: "high", modelSlot: "read-review" });
|
|
1654
|
+
expect(setup.readAgentMock.runReadAgentInProcess.mock.calls[1][1]).toMatchObject({ name: "two", thinking: "xhigh", modelSlot: "read-critical" });
|
|
1655
|
+
} finally {
|
|
1656
|
+
setup.restoreEnv();
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
});
|