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,3077 @@
|
|
|
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 * as paths from "../../src/utils/paths.js";
|
|
6
|
+
import * as claims from "../../src/utils/claims.js";
|
|
7
|
+
import * as teams from "../../src/utils/teams.js";
|
|
8
|
+
import * as runtime from "../../src/utils/runtime.js";
|
|
9
|
+
import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone.js";
|
|
10
|
+
import { readInbox, requireRunningMessageRecipient, sendPlainMessage, sendPlainMessageIfRunning } from "../../src/utils/messaging.js";
|
|
11
|
+
import { listTeamReportEvents } from "../../src/utils/report-events.js";
|
|
12
|
+
import * as reportEvents from "../../src/utils/report-events.js";
|
|
13
|
+
import type { Member } from "../../src/utils/models.js";
|
|
14
|
+
|
|
15
|
+
const piMocks = vi.hoisted(() => ({
|
|
16
|
+
createAgentSession: vi.fn(),
|
|
17
|
+
loaderOptions: [] as any[],
|
|
18
|
+
loaderExtensions: [] as any[],
|
|
19
|
+
settingsManagers: [] as any[],
|
|
20
|
+
sessionManagerCreate: vi.fn(),
|
|
21
|
+
sessionManagerOpen: vi.fn(),
|
|
22
|
+
persistedSessionEntries: new Map<string, any[]>(),
|
|
23
|
+
sessionManagerCounter: 0,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function mockedPiRuntimeApi() {
|
|
27
|
+
return {
|
|
28
|
+
createAgentSession: piMocks.createAgentSession,
|
|
29
|
+
DefaultResourceLoader: class {
|
|
30
|
+
constructor(options: any) {
|
|
31
|
+
piMocks.loaderOptions.push(options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async reload() {}
|
|
35
|
+
|
|
36
|
+
getExtensions() {
|
|
37
|
+
return { extensions: piMocks.loaderExtensions, errors: [], runtime: {} };
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
getAgentDir: () => "/mock-agent-dir",
|
|
41
|
+
SettingsManager: {
|
|
42
|
+
create: vi.fn((_cwd: string, _agentDir: string, options?: any) => {
|
|
43
|
+
const manager = {
|
|
44
|
+
projectTrusted: options?.projectTrusted ?? false,
|
|
45
|
+
setProjectTrusted(trusted: boolean) { this.projectTrusted = trusted; },
|
|
46
|
+
isProjectTrusted() { return this.projectTrusted; },
|
|
47
|
+
getGlobalSettings: () => ({}),
|
|
48
|
+
getProjectSettings: () => ({}),
|
|
49
|
+
};
|
|
50
|
+
piMocks.settingsManagers.push(manager);
|
|
51
|
+
return manager;
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
SessionManager: {
|
|
55
|
+
create: piMocks.sessionManagerCreate,
|
|
56
|
+
open: piMocks.sessionManagerOpen,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
vi.mock("@mariozechner/pi-coding-agent", mockedPiRuntimeApi);
|
|
62
|
+
vi.mock("../internal/pi-runtime-api", () => ({
|
|
63
|
+
loadPiRuntimeApi: async () => mockedPiRuntimeApi(),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
import { closeReadAgentMessageDelivery, handleReadAgentSessionEvent, runReadAgentInProcess, sendMessageToRunningReadAgent } from "./read-agent.js";
|
|
67
|
+
import { createLifecycleRuntime } from "../team/lifecycle.js";
|
|
68
|
+
import { sanitizeTuiLine } from "../ui/renderers.js";
|
|
69
|
+
import { NESTED_SESSION_TEARDOWN_TIMEOUT_MS } from "./read-agent-session-lifecycle.js";
|
|
70
|
+
import type { RunningReadAgent } from "../runtime/types.js";
|
|
71
|
+
import { createPendingChildController, type PendingChildRun } from "../runtime/pending-child-controller.js";
|
|
72
|
+
|
|
73
|
+
let root: string;
|
|
74
|
+
|
|
75
|
+
function installPathSpies() {
|
|
76
|
+
vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName))));
|
|
77
|
+
vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "config.json"));
|
|
78
|
+
vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: unknown, agentName: unknown) => {
|
|
79
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`);
|
|
80
|
+
});
|
|
81
|
+
vi.spyOn(paths, "inboxPath").mockImplementation((teamName: unknown, agentName: unknown) => {
|
|
82
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`);
|
|
83
|
+
});
|
|
84
|
+
vi.spyOn(paths, "claimsPath").mockImplementation((teamName: unknown) => {
|
|
85
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "claims.json");
|
|
86
|
+
});
|
|
87
|
+
vi.spyOn(paths, "reportEventsPath").mockImplementation((teamName: unknown) => {
|
|
88
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "reports.json");
|
|
89
|
+
});
|
|
90
|
+
vi.spyOn(paths, "reportFilesDir").mockReturnValue(path.join(root, "agent", "reports"));
|
|
91
|
+
vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: unknown, agentName: unknown) => {
|
|
92
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeFavoriteLevels() {
|
|
97
|
+
const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
|
|
98
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
99
|
+
fs.writeFileSync(settingsPath, JSON.stringify({
|
|
100
|
+
favoriteModels: {
|
|
101
|
+
"reading-default": { model: "provider/model", thinking: "high" },
|
|
102
|
+
"writing-basic": { model: "provider/model", thinking: "high" },
|
|
103
|
+
"writing-hard": { model: "provider/model", thinking: "xhigh" },
|
|
104
|
+
"write-feature": { model: "provider/model", thinking: "medium" },
|
|
105
|
+
"write-critical": { model: "provider/model", thinking: "xhigh" },
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeTeamConfig(teamName: string, teammate: Member, additionalMembers: Member[] = []) {
|
|
111
|
+
const configFile = paths.configPath(teamName);
|
|
112
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
113
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
114
|
+
name: teamName,
|
|
115
|
+
description: "",
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
leadAgentId: "lead",
|
|
118
|
+
leadSessionId: "lead-session",
|
|
119
|
+
members: [
|
|
120
|
+
{
|
|
121
|
+
agentId: `team-lead@${teamName}`,
|
|
122
|
+
name: "team-lead",
|
|
123
|
+
agentType: "lead",
|
|
124
|
+
joinedAt: Date.now(),
|
|
125
|
+
tmuxPaneId: "",
|
|
126
|
+
cwd: root,
|
|
127
|
+
subscriptions: [],
|
|
128
|
+
},
|
|
129
|
+
...additionalMembers,
|
|
130
|
+
teammate,
|
|
131
|
+
],
|
|
132
|
+
}, null, 2));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fixtureMember(name: string, role: "read" | "write" = "read", modelSlot?: string): Member {
|
|
136
|
+
return {
|
|
137
|
+
agentId: `${name}@fixture`,
|
|
138
|
+
name,
|
|
139
|
+
agentType: "teammate",
|
|
140
|
+
role,
|
|
141
|
+
model: "provider/model",
|
|
142
|
+
thinking: role === "write" && modelSlot !== "writing-basic" ? "xhigh" : "high",
|
|
143
|
+
modelSlot: modelSlot ?? (role === "write" ? "writing-hard" : "reading-default"),
|
|
144
|
+
joinedAt: Date.now(),
|
|
145
|
+
tmuxPaneId: "",
|
|
146
|
+
cwd: root,
|
|
147
|
+
subscriptions: [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function eligibleNestedParent(name = "feature-writer"): Member {
|
|
152
|
+
return {
|
|
153
|
+
...fixtureMember(name, "write", "write-feature"),
|
|
154
|
+
thinking: "medium",
|
|
155
|
+
prompt: "implement a bounded feature",
|
|
156
|
+
delegationDepth: 0,
|
|
157
|
+
allowNestedReadAgents: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeSession() {
|
|
162
|
+
return {
|
|
163
|
+
messages: [{ role: "assistant", content: "final report" }],
|
|
164
|
+
getSessionStats: vi.fn(() => ({ tokens: { total: 42 } })),
|
|
165
|
+
subscribe: vi.fn(),
|
|
166
|
+
prompt: vi.fn(async () => {}),
|
|
167
|
+
bindExtensions: vi.fn(async () => {}),
|
|
168
|
+
sendUserMessage: vi.fn(async () => {}),
|
|
169
|
+
isStreaming: true,
|
|
170
|
+
hasExtensionHandlers: vi.fn(() => false),
|
|
171
|
+
extensionRunner: { emit: vi.fn(async () => {}) },
|
|
172
|
+
clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
|
|
173
|
+
abort: vi.fn(async () => {}),
|
|
174
|
+
dispose: vi.fn(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function makeRunOptions(runningReadAgents = new Map<string, RunningReadAgent>()) {
|
|
179
|
+
return {
|
|
180
|
+
isTeammate: false,
|
|
181
|
+
getTeamName: () => "team",
|
|
182
|
+
runningReadAgents,
|
|
183
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
184
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
185
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
186
|
+
renderReadAgentStatus: vi.fn(),
|
|
187
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
188
|
+
emitAgentReport: vi.fn(),
|
|
189
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describe("in-process read agent tool wiring", () => {
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-read-agent-"));
|
|
196
|
+
piMocks.loaderOptions.length = 0;
|
|
197
|
+
piMocks.loaderExtensions.length = 0;
|
|
198
|
+
piMocks.settingsManagers.length = 0;
|
|
199
|
+
piMocks.createAgentSession.mockReset();
|
|
200
|
+
piMocks.sessionManagerCreate.mockReset();
|
|
201
|
+
piMocks.sessionManagerOpen.mockReset();
|
|
202
|
+
piMocks.persistedSessionEntries.clear();
|
|
203
|
+
piMocks.sessionManagerCounter = 0;
|
|
204
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
205
|
+
const id = `child-session-${++piMocks.sessionManagerCounter}`;
|
|
206
|
+
const sessionFile = path.join(sessionDir ?? path.join(root, "child-sessions"), `${id}.jsonl`);
|
|
207
|
+
return {
|
|
208
|
+
cwd,
|
|
209
|
+
getSessionId: () => id,
|
|
210
|
+
getSessionDir: () => sessionDir,
|
|
211
|
+
getSessionFile: () => sessionFile,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
piMocks.sessionManagerOpen.mockImplementation((sessionFile: string) => ({
|
|
215
|
+
getEntries: () => piMocks.persistedSessionEntries.get(sessionFile) ?? [],
|
|
216
|
+
}));
|
|
217
|
+
vi.spyOn(os, "homedir").mockReturnValue(root);
|
|
218
|
+
installPathSpies();
|
|
219
|
+
writeFavoriteLevels();
|
|
220
|
+
writeTeamConfig("team", fixtureMember("reader"), [
|
|
221
|
+
fixtureMember("writer", "write"),
|
|
222
|
+
fixtureMember("workflow-reader"),
|
|
223
|
+
fixtureMember("writer-reader"),
|
|
224
|
+
]);
|
|
225
|
+
writeTeamConfig("prompt-build-123", fixtureMember("prompt-branch-1"));
|
|
226
|
+
writeTeamConfig("session-main", fixtureMember("planner", "write", "writing-basic"));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
afterEach(() => {
|
|
230
|
+
vi.restoreAllMocks();
|
|
231
|
+
if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("isolates completed report persistence in the test temp directory", () => {
|
|
235
|
+
expect(paths.reportFilesDir()).toBe(path.join(root, "agent", "reports"));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("steers scope updates directly into an active in-process agent", async () => {
|
|
239
|
+
const session = makeSession();
|
|
240
|
+
const state: RunningReadAgent = {
|
|
241
|
+
runId: "run-1",
|
|
242
|
+
name: "reader",
|
|
243
|
+
teamName: "team",
|
|
244
|
+
startedAt: Date.now(),
|
|
245
|
+
tokensUsed: 0,
|
|
246
|
+
status: "working",
|
|
247
|
+
recentEvents: [],
|
|
248
|
+
lastActivityAt: Date.now(),
|
|
249
|
+
session: session as any,
|
|
250
|
+
acceptingMessages: true,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
await expect(sendMessageToRunningReadAgent(state, "Inspect the new bash screenshot too.")).resolves.toBe(true);
|
|
254
|
+
|
|
255
|
+
expect(session.sendUserMessage).toHaveBeenCalledWith(
|
|
256
|
+
"Inspect the new bash screenshot too.",
|
|
257
|
+
{ deliverAs: "steer" }
|
|
258
|
+
);
|
|
259
|
+
expect(state.status).toBe("thinking");
|
|
260
|
+
expect(state.recentEvents).toContain("received lead message");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("closes message admission without waiting for an in-flight session delivery", async () => {
|
|
264
|
+
const session = makeSession();
|
|
265
|
+
let finishDelivery!: () => void;
|
|
266
|
+
session.sendUserMessage.mockImplementation(() => new Promise<void>((resolve) => { finishDelivery = resolve; }));
|
|
267
|
+
const state: RunningReadAgent = {
|
|
268
|
+
runId: "run-1",
|
|
269
|
+
name: "reader",
|
|
270
|
+
teamName: "team",
|
|
271
|
+
startedAt: Date.now(),
|
|
272
|
+
tokensUsed: 0,
|
|
273
|
+
status: "working",
|
|
274
|
+
recentEvents: [],
|
|
275
|
+
lastActivityAt: Date.now(),
|
|
276
|
+
session: session as any,
|
|
277
|
+
acceptingMessages: true,
|
|
278
|
+
messageDeliveryClosed: false,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const delivery = sendMessageToRunningReadAgent(state, "Finish this evidence source.");
|
|
282
|
+
const deliveryOutcome = delivery.then(() => "delivered", (error: Error) => error.message);
|
|
283
|
+
await vi.waitFor(() => expect(session.sendUserMessage).toHaveBeenCalledOnce());
|
|
284
|
+
const close = closeReadAgentMessageDelivery(state);
|
|
285
|
+
|
|
286
|
+
expect(state.messageDeliveryClosed).toBe(true);
|
|
287
|
+
expect(close.cancelledDeliveries).toBe(1);
|
|
288
|
+
await expect(deliveryOutcome).resolves.toContain("was cancelled");
|
|
289
|
+
await expect(sendMessageToRunningReadAgent(state, "New work.")).rejects.toThrow("agent is finishing");
|
|
290
|
+
|
|
291
|
+
finishDelivery();
|
|
292
|
+
await close.rawDeliverySettlement;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("rejects scope updates once an in-process agent is finishing", async () => {
|
|
296
|
+
const session = makeSession();
|
|
297
|
+
const state: RunningReadAgent = {
|
|
298
|
+
runId: "run-1",
|
|
299
|
+
name: "reader",
|
|
300
|
+
teamName: "team",
|
|
301
|
+
startedAt: Date.now(),
|
|
302
|
+
tokensUsed: 0,
|
|
303
|
+
status: "finishing",
|
|
304
|
+
recentEvents: [],
|
|
305
|
+
lastActivityAt: Date.now(),
|
|
306
|
+
session: session as any,
|
|
307
|
+
acceptingMessages: false,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
await expect(sendMessageToRunningReadAgent(state, "Continue.")).rejects.toThrow(
|
|
311
|
+
"Cannot send message to reader: agent is finishing."
|
|
312
|
+
);
|
|
313
|
+
expect(session.sendUserMessage).not.toHaveBeenCalled();
|
|
314
|
+
await expect(sendMessageToRunningReadAgent(undefined, "Continue.")).resolves.toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("closes recipient delivery before asynchronous in-process cleanup", async () => {
|
|
318
|
+
const session = makeSession();
|
|
319
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
320
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
321
|
+
let finishClaimRelease!: () => void;
|
|
322
|
+
const releaseAllClaimsForAgent = vi.fn(() => new Promise<string[]>((resolve) => {
|
|
323
|
+
finishClaimRelease = () => resolve([]);
|
|
324
|
+
}));
|
|
325
|
+
const member: Member = {
|
|
326
|
+
agentId: "reader@team",
|
|
327
|
+
name: "reader",
|
|
328
|
+
agentType: "teammate",
|
|
329
|
+
role: "read",
|
|
330
|
+
model: "provider/model",
|
|
331
|
+
thinking: "high",
|
|
332
|
+
modelSlot: "reading-default",
|
|
333
|
+
joinedAt: Date.now(),
|
|
334
|
+
tmuxPaneId: "",
|
|
335
|
+
cwd: root,
|
|
336
|
+
subscriptions: [],
|
|
337
|
+
prompt: "investigate",
|
|
338
|
+
};
|
|
339
|
+
writeTeamConfig("team", member);
|
|
340
|
+
const run = runReadAgentInProcess("team", member, "investigate", {
|
|
341
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
342
|
+
}, {
|
|
343
|
+
isTeammate: false,
|
|
344
|
+
getTeamName: () => "team",
|
|
345
|
+
runningReadAgents,
|
|
346
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
347
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
348
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
349
|
+
renderReadAgentStatus: vi.fn(),
|
|
350
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
351
|
+
emitAgentReport: vi.fn(),
|
|
352
|
+
releaseAllClaimsForAgent,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await vi.waitFor(() => expect(releaseAllClaimsForAgent).toHaveBeenCalledWith("team", "reader"));
|
|
356
|
+
const duringCleanup = JSON.parse(fs.readFileSync(paths.configPath("team"), "utf-8"));
|
|
357
|
+
expect(duringCleanup.members.find((item: Member) => item.name === "reader")?.isActive).toBe(false);
|
|
358
|
+
expect(runningReadAgents.has("team:reader")).toBe(true);
|
|
359
|
+
const rejectedRecipient = expect(requireRunningMessageRecipient("team", "reader"))
|
|
360
|
+
.rejects.toThrow("agent is not running");
|
|
361
|
+
|
|
362
|
+
finishClaimRelease();
|
|
363
|
+
await run;
|
|
364
|
+
await rejectedRecipient;
|
|
365
|
+
expect(runningReadAgents.size).toBe(0);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("stops heartbeating when lifecycle teardown is refused by an unreadable fence", async () => {
|
|
369
|
+
const session = makeSession();
|
|
370
|
+
session.prompt.mockImplementation(async () => {
|
|
371
|
+
const fence = paths.lifecycleTombstonePath("team", "reader");
|
|
372
|
+
fs.mkdirSync(path.dirname(fence), { recursive: true });
|
|
373
|
+
fs.writeFileSync(fence, "{ malformed");
|
|
374
|
+
});
|
|
375
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
376
|
+
const member = { ...fixtureMember("reader"), prompt: "investigate" };
|
|
377
|
+
writeTeamConfig("team", member);
|
|
378
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
379
|
+
let observed: RunningReadAgent | undefined;
|
|
380
|
+
|
|
381
|
+
await expect(runReadAgentInProcess("team", member, "investigate", {
|
|
382
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
383
|
+
}, {
|
|
384
|
+
isTeammate: false,
|
|
385
|
+
getTeamName: () => "team",
|
|
386
|
+
runningReadAgents,
|
|
387
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
388
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => {
|
|
389
|
+
observed = state;
|
|
390
|
+
return runningReadAgents.get(key) === state;
|
|
391
|
+
},
|
|
392
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
393
|
+
renderReadAgentStatus: vi.fn(),
|
|
394
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
395
|
+
emitAgentReport: vi.fn(),
|
|
396
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
397
|
+
})).rejects.toThrow(/lifecycle-quarantined by a corrupt tombstone/);
|
|
398
|
+
|
|
399
|
+
expect(observed).toBeDefined();
|
|
400
|
+
expect(observed!.heartbeatTimer).toBeUndefined();
|
|
401
|
+
expect(observed!.messageDeliveryClosed).toBe(true);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("injects teammate-safe communication tools and guidance into nested read-agent sessions", async () => {
|
|
405
|
+
const session = makeSession();
|
|
406
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
407
|
+
const modelRuntime = {};
|
|
408
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
409
|
+
const options = {
|
|
410
|
+
isTeammate: false,
|
|
411
|
+
getTeamName: () => "team",
|
|
412
|
+
runningReadAgents,
|
|
413
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
414
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
415
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
416
|
+
renderReadAgentStatus: vi.fn(),
|
|
417
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
418
|
+
emitAgentReport: vi.fn(),
|
|
419
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
await runReadAgentInProcess("team", {
|
|
423
|
+
agentId: "reader@team",
|
|
424
|
+
name: "reader",
|
|
425
|
+
agentType: "teammate",
|
|
426
|
+
role: "read",
|
|
427
|
+
model: "provider/model",
|
|
428
|
+
thinking: "high",
|
|
429
|
+
modelSlot: "reading-default",
|
|
430
|
+
joinedAt: Date.now(),
|
|
431
|
+
tmuxPaneId: "",
|
|
432
|
+
cwd: root,
|
|
433
|
+
subscriptions: [],
|
|
434
|
+
prompt: "investigate",
|
|
435
|
+
}, "investigate", {
|
|
436
|
+
modelRegistry: {
|
|
437
|
+
runtime: modelRuntime,
|
|
438
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
439
|
+
},
|
|
440
|
+
}, options);
|
|
441
|
+
|
|
442
|
+
expect(piMocks.createAgentSession).toHaveBeenCalledTimes(1);
|
|
443
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
444
|
+
expect(sessionOptions.modelRuntime).toBe(modelRuntime);
|
|
445
|
+
const communicationToolNames = ["send_message", "report_progress", "read_inbox", "report_and_exit"];
|
|
446
|
+
expect(sessionOptions.tools).toEqual([
|
|
447
|
+
"read",
|
|
448
|
+
"bash",
|
|
449
|
+
"edit",
|
|
450
|
+
"write",
|
|
451
|
+
"grep",
|
|
452
|
+
"find",
|
|
453
|
+
"ls",
|
|
454
|
+
...communicationToolNames,
|
|
455
|
+
]);
|
|
456
|
+
expect(sessionOptions.customTools.map((tool: any) => tool.name).sort()).toEqual([...communicationToolNames].sort());
|
|
457
|
+
|
|
458
|
+
expect(piMocks.loaderOptions).toHaveLength(1);
|
|
459
|
+
expect(piMocks.loaderOptions[0]).toMatchObject({
|
|
460
|
+
noExtensions: true,
|
|
461
|
+
additionalExtensionPaths: [],
|
|
462
|
+
noSkills: false,
|
|
463
|
+
});
|
|
464
|
+
expect(piMocks.loaderOptions[0].skillsOverride).toBeUndefined();
|
|
465
|
+
expect(piMocks.loaderOptions[0].noPromptTemplates).toBeUndefined();
|
|
466
|
+
expect(piMocks.loaderOptions[0].noThemes).toBeUndefined();
|
|
467
|
+
expect(sessionOptions.settingsManager).toBe(piMocks.loaderOptions[0].settingsManager);
|
|
468
|
+
const privateSessionDir = piMocks.sessionManagerCreate.mock.calls[0][1];
|
|
469
|
+
const expectedAgentRoot = path.join(root, "teams", "team", "agent-sessions", "reader");
|
|
470
|
+
expect(typeof privateSessionDir).toBe("string");
|
|
471
|
+
expect(path.dirname(privateSessionDir)).toBe(expectedAgentRoot);
|
|
472
|
+
expect(privateSessionDir).not.toContain(path.join(".pi", "agent", "sessions"));
|
|
473
|
+
expect(sessionOptions.sessionManager).toBe(piMocks.sessionManagerCreate.mock.results[0].value);
|
|
474
|
+
const promptText = piMocks.loaderOptions[0].appendSystemPrompt.join("\n");
|
|
475
|
+
expect(promptText).toContain("Use send_message for direct communication and read_inbox only when you were told a reply is waiting");
|
|
476
|
+
expect(promptText).toContain("If another agent is needed, use send_message to ask team-lead");
|
|
477
|
+
expect(promptText).toContain("only the lead decides and performs the spawn");
|
|
478
|
+
expect(promptText).toContain("Progress reporting is required, not optional UI polish");
|
|
479
|
+
expect(promptText).toContain("Call report_progress before your first work tool");
|
|
480
|
+
expect(promptText).toContain("never make more than 3 work-tool calls without a fresh progress update");
|
|
481
|
+
expect(promptText).toContain("Use a new phrase describing what you are doing now");
|
|
482
|
+
expect(promptText).toContain("without messaging or waking the lead");
|
|
483
|
+
expect(promptText).toContain("use report_and_exit with the complete required deliverable");
|
|
484
|
+
expect(promptText).toContain("Never replace required output with a summary");
|
|
485
|
+
|
|
486
|
+
expect(session.bindExtensions).toHaveBeenCalledWith({ mode: "print" });
|
|
487
|
+
expect(session.bindExtensions.mock.invocationCallOrder[0]).toBeLessThan(session.prompt.mock.invocationCallOrder[0]);
|
|
488
|
+
expect(session.prompt).toHaveBeenCalledWith("investigate", { source: "extension" });
|
|
489
|
+
expect(options.emitAgentReport).toHaveBeenCalledWith("team", "reader", expect.any(Number), 42, "final report", true);
|
|
490
|
+
expect(await readInbox("team", "team-lead", true, false)).toEqual([]);
|
|
491
|
+
expect(await readInbox("team", "team-lead", false, false)).toEqual([]);
|
|
492
|
+
expect(options.releaseAllClaimsForAgent).toHaveBeenCalledWith("team", "reader");
|
|
493
|
+
expect(runningReadAgents.size).toBe(0);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("uses Pi's resumable session store only after an explicit opt-in", async () => {
|
|
497
|
+
const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
|
|
498
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
499
|
+
settings.agentSessions = { showInResume: true };
|
|
500
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings));
|
|
501
|
+
const session = makeSession();
|
|
502
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
503
|
+
|
|
504
|
+
await runReadAgentInProcess(
|
|
505
|
+
"team",
|
|
506
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
507
|
+
"investigate",
|
|
508
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
509
|
+
makeRunOptions(),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
expect(piMocks.sessionManagerCreate).toHaveBeenCalledWith(root);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("removes a normal completed private session only after reporting and teardown", async () => {
|
|
516
|
+
const session = makeSession();
|
|
517
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
518
|
+
let privateSessionDir = "";
|
|
519
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
520
|
+
privateSessionDir = sessionDir!;
|
|
521
|
+
const sessionFile = path.join(privateSessionDir, "child-session.jsonl");
|
|
522
|
+
fs.writeFileSync(sessionFile, "private transcript\n");
|
|
523
|
+
return {
|
|
524
|
+
cwd,
|
|
525
|
+
getSessionId: () => "child-session",
|
|
526
|
+
getSessionDir: () => privateSessionDir,
|
|
527
|
+
getSessionFile: () => sessionFile,
|
|
528
|
+
};
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await runReadAgentInProcess(
|
|
532
|
+
"team",
|
|
533
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
534
|
+
"investigate",
|
|
535
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
536
|
+
makeRunOptions(),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
expect(privateSessionDir).not.toBe("");
|
|
540
|
+
expect(session.dispose).toHaveBeenCalledOnce();
|
|
541
|
+
expect(fs.existsSync(privateSessionDir)).toBe(false);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("cleans a private session after completed report emission fails without stale recovery pointers", async () => {
|
|
545
|
+
const session = makeSession();
|
|
546
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
547
|
+
let privateSessionDir = "";
|
|
548
|
+
session.dispose.mockImplementation(() => {
|
|
549
|
+
expect(fs.existsSync(privateSessionDir)).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
const options = makeRunOptions();
|
|
552
|
+
options.emitAgentReport.mockImplementation(() => {
|
|
553
|
+
throw new Error("lead notification failed");
|
|
554
|
+
});
|
|
555
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
556
|
+
privateSessionDir = sessionDir!;
|
|
557
|
+
const sessionFile = path.join(privateSessionDir, "child-session.jsonl");
|
|
558
|
+
fs.writeFileSync(sessionFile, "private transcript\n");
|
|
559
|
+
return {
|
|
560
|
+
cwd,
|
|
561
|
+
getSessionId: () => "child-session",
|
|
562
|
+
getSessionDir: () => privateSessionDir,
|
|
563
|
+
getSessionFile: () => sessionFile,
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await runReadAgentInProcess(
|
|
568
|
+
"team",
|
|
569
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
570
|
+
"investigate",
|
|
571
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
572
|
+
options,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
expect(session.dispose).toHaveBeenCalledOnce();
|
|
576
|
+
expect(fs.existsSync(privateSessionDir)).toBe(false);
|
|
577
|
+
expect(options.rememberCompletedAgentReport).toHaveBeenCalledOnce();
|
|
578
|
+
expect(options.rememberCompletedAgentReport.mock.calls[0][1]).toMatchObject({
|
|
579
|
+
status: "completed",
|
|
580
|
+
reportSource: "assistant-text",
|
|
581
|
+
});
|
|
582
|
+
expect(options.rememberCompletedAgentReport.mock.calls[0][1]).not.toHaveProperty("recoverySessionId");
|
|
583
|
+
expect(options.rememberCompletedAgentReport.mock.calls[0][1]).not.toHaveProperty("recoverySessionFile");
|
|
584
|
+
const reports = await listTeamReportEvents("team");
|
|
585
|
+
expect(reports).toHaveLength(1);
|
|
586
|
+
expect(reports[0]).toMatchObject({
|
|
587
|
+
status: "completed",
|
|
588
|
+
metadata: { reportSource: "assistant-text", recoveryAttempted: false },
|
|
589
|
+
});
|
|
590
|
+
expect(reports[0]?.metadata).not.toHaveProperty("recoverySessionId");
|
|
591
|
+
expect(reports[0]?.metadata).not.toHaveProperty("recoverySessionFile");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("cleans a private session after report-only recovery without stale recovery pointers", async () => {
|
|
595
|
+
const session = makeSession();
|
|
596
|
+
session.messages = [];
|
|
597
|
+
let privateSessionDir = "";
|
|
598
|
+
const lifecycleOrder: string[] = [];
|
|
599
|
+
session.dispose.mockImplementation(() => {
|
|
600
|
+
lifecycleOrder.push("dispose");
|
|
601
|
+
expect(fs.existsSync(privateSessionDir)).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
session.prompt.mockImplementation(async () => {
|
|
604
|
+
if (session.prompt.mock.calls.length === 1) return;
|
|
605
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
606
|
+
const reportTool = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
607
|
+
await reportTool.execute("recovered-report", {
|
|
608
|
+
content: "Recovered on the report-only turn",
|
|
609
|
+
summary: "Recovered",
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
613
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
614
|
+
privateSessionDir = sessionDir!;
|
|
615
|
+
const sessionFile = path.join(privateSessionDir, "child-session.jsonl");
|
|
616
|
+
fs.writeFileSync(sessionFile, "private recovery transcript\n");
|
|
617
|
+
return {
|
|
618
|
+
cwd,
|
|
619
|
+
getSessionId: () => "child-session",
|
|
620
|
+
getSessionDir: () => privateSessionDir,
|
|
621
|
+
getSessionFile: () => sessionFile,
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
const options = makeRunOptions();
|
|
625
|
+
|
|
626
|
+
await runReadAgentInProcess(
|
|
627
|
+
"team",
|
|
628
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
629
|
+
"investigate",
|
|
630
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
631
|
+
options,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
expect(session.prompt).toHaveBeenCalledTimes(2);
|
|
635
|
+
expect(lifecycleOrder).toEqual(["dispose"]);
|
|
636
|
+
expect(fs.existsSync(privateSessionDir)).toBe(false);
|
|
637
|
+
const remembered = options.rememberCompletedAgentReport.mock.calls[0][1];
|
|
638
|
+
expect(remembered).toMatchObject({
|
|
639
|
+
status: "completed",
|
|
640
|
+
reportSource: "report_and_exit",
|
|
641
|
+
recoveryAttempted: true,
|
|
642
|
+
});
|
|
643
|
+
expect(remembered).not.toHaveProperty("recoverySessionId");
|
|
644
|
+
expect(remembered).not.toHaveProperty("recoverySessionFile");
|
|
645
|
+
const reports = await listTeamReportEvents("team");
|
|
646
|
+
expect(reports.at(-1)).toMatchObject({
|
|
647
|
+
status: "completed",
|
|
648
|
+
metadata: { reportSource: "report_and_exit", recoveryAttempted: true },
|
|
649
|
+
});
|
|
650
|
+
expect(reports.at(-1)?.metadata).not.toHaveProperty("recoverySessionId");
|
|
651
|
+
expect(reports.at(-1)?.metadata).not.toHaveProperty("recoverySessionFile");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("cleans a private session after persisted report recovery without stale recovery pointers", async () => {
|
|
655
|
+
const session = makeSession();
|
|
656
|
+
session.messages = [];
|
|
657
|
+
let privateSessionDir = "";
|
|
658
|
+
const lifecycleOrder: string[] = [];
|
|
659
|
+
session.dispose.mockImplementation(() => {
|
|
660
|
+
lifecycleOrder.push("dispose");
|
|
661
|
+
expect(fs.existsSync(privateSessionDir)).toBe(true);
|
|
662
|
+
});
|
|
663
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
664
|
+
const sessionFile = () => path.join(privateSessionDir, "durable-child.jsonl");
|
|
665
|
+
const options = makeRunOptions();
|
|
666
|
+
const member = { ...fixtureMember("reader"), prompt: "investigate" };
|
|
667
|
+
writeTeamConfig("team", member);
|
|
668
|
+
|
|
669
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
670
|
+
privateSessionDir = sessionDir!;
|
|
671
|
+
const persistedFile = sessionFile();
|
|
672
|
+
fs.writeFileSync(persistedFile, "durable private transcript\n");
|
|
673
|
+
piMocks.persistedSessionEntries.set(persistedFile, [
|
|
674
|
+
{
|
|
675
|
+
type: "message",
|
|
676
|
+
message: {
|
|
677
|
+
role: "assistant",
|
|
678
|
+
content: [{
|
|
679
|
+
type: "toolCall",
|
|
680
|
+
id: "accepted-report-call",
|
|
681
|
+
name: "report_and_exit",
|
|
682
|
+
arguments: { content: "Recovered durable report", summary: "Durable" },
|
|
683
|
+
}],
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
type: "message",
|
|
688
|
+
message: {
|
|
689
|
+
role: "toolResult",
|
|
690
|
+
toolCallId: "accepted-report-call",
|
|
691
|
+
toolName: "report_and_exit",
|
|
692
|
+
content: [{ type: "text", text: "Final report accepted." }],
|
|
693
|
+
details: { accepted: true },
|
|
694
|
+
isError: false,
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
]);
|
|
698
|
+
return {
|
|
699
|
+
cwd,
|
|
700
|
+
getSessionId: () => "durable-child",
|
|
701
|
+
getSessionDir: () => privateSessionDir,
|
|
702
|
+
getSessionFile: () => persistedFile,
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
await runReadAgentInProcess(
|
|
707
|
+
"team",
|
|
708
|
+
member,
|
|
709
|
+
"investigate",
|
|
710
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
711
|
+
options,
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
expect(session.prompt).toHaveBeenCalledOnce();
|
|
715
|
+
expect(lifecycleOrder).toEqual(["dispose"]);
|
|
716
|
+
expect(fs.existsSync(privateSessionDir)).toBe(false);
|
|
717
|
+
const remembered = options.rememberCompletedAgentReport.mock.calls[0][1];
|
|
718
|
+
expect(remembered).toMatchObject({
|
|
719
|
+
status: "completed",
|
|
720
|
+
reportSource: "persisted-report_and_exit",
|
|
721
|
+
recoveryAttempted: false,
|
|
722
|
+
});
|
|
723
|
+
expect(remembered).not.toHaveProperty("recoverySessionId");
|
|
724
|
+
expect(remembered).not.toHaveProperty("recoverySessionFile");
|
|
725
|
+
const reports = await listTeamReportEvents("team");
|
|
726
|
+
expect(reports.at(-1)).toMatchObject({
|
|
727
|
+
status: "completed",
|
|
728
|
+
metadata: { reportSource: "persisted-report_and_exit", recoveryAttempted: false },
|
|
729
|
+
});
|
|
730
|
+
expect(reports.at(-1)?.metadata).not.toHaveProperty("recoverySessionId");
|
|
731
|
+
expect(reports.at(-1)?.metadata).not.toHaveProperty("recoverySessionFile");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("quarantines every recovery artifact when durable report persistence fails", async () => {
|
|
735
|
+
vi.spyOn(reportEvents, "appendTeamReportEvent").mockRejectedValue(new Error("report store unavailable"));
|
|
736
|
+
const session = makeSession();
|
|
737
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
738
|
+
let privateSessionDir = "";
|
|
739
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
740
|
+
privateSessionDir = sessionDir!;
|
|
741
|
+
const sessionFile = path.join(privateSessionDir, "child-session.jsonl");
|
|
742
|
+
fs.writeFileSync(sessionFile, "private transcript\n");
|
|
743
|
+
return {
|
|
744
|
+
cwd,
|
|
745
|
+
getSessionId: () => "child-session",
|
|
746
|
+
getSessionDir: () => privateSessionDir,
|
|
747
|
+
getSessionFile: () => sessionFile,
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
751
|
+
const options = makeRunOptions(runningReadAgents);
|
|
752
|
+
|
|
753
|
+
await runReadAgentInProcess(
|
|
754
|
+
"team",
|
|
755
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
756
|
+
"investigate",
|
|
757
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
758
|
+
options,
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const retainedMember = (await teams.readConfig("team")).members.find(member => member.name === "reader");
|
|
762
|
+
expect(retainedMember).toMatchObject({ isActive: false, lifecycleRunId: expect.any(String) });
|
|
763
|
+
const runId = retainedMember!.lifecycleRunId!;
|
|
764
|
+
expect(fs.existsSync(privateSessionDir)).toBe(true);
|
|
765
|
+
await expect(runtime.readRuntimeStatus("team", "reader")).resolves.toMatchObject({ lifecycleRunId: runId });
|
|
766
|
+
await expect(readLifecycleTombstone("team", "reader")).resolves.toMatchObject({
|
|
767
|
+
status: "occupied",
|
|
768
|
+
tombstone: {
|
|
769
|
+
runId,
|
|
770
|
+
phase: "cleanup_failed",
|
|
771
|
+
error: expect.stringContaining("report store unavailable"),
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
expect(session.dispose).toHaveBeenCalledOnce();
|
|
775
|
+
expect(options.emitAgentReport).toHaveBeenCalledWith(
|
|
776
|
+
"team",
|
|
777
|
+
"reader",
|
|
778
|
+
expect.any(Number),
|
|
779
|
+
42,
|
|
780
|
+
expect.stringContaining("Recovery pointer: pi-child-session/v1"),
|
|
781
|
+
false,
|
|
782
|
+
);
|
|
783
|
+
expect(runningReadAgents.size).toBe(1);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("retains a failed private session for bounded recovery", async () => {
|
|
787
|
+
const session = makeSession();
|
|
788
|
+
session.prompt.mockRejectedValue(new Error("provider failed"));
|
|
789
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
790
|
+
let privateSessionDir = "";
|
|
791
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
792
|
+
privateSessionDir = sessionDir!;
|
|
793
|
+
const sessionFile = path.join(privateSessionDir, "child-session.jsonl");
|
|
794
|
+
fs.writeFileSync(sessionFile, "recoverable private transcript\n");
|
|
795
|
+
return {
|
|
796
|
+
cwd,
|
|
797
|
+
getSessionId: () => "child-session",
|
|
798
|
+
getSessionDir: () => privateSessionDir,
|
|
799
|
+
getSessionFile: () => sessionFile,
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await runReadAgentInProcess(
|
|
804
|
+
"team",
|
|
805
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
806
|
+
"investigate",
|
|
807
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
808
|
+
makeRunOptions(),
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
expect(privateSessionDir).not.toBe("");
|
|
812
|
+
expect(fs.existsSync(privateSessionDir)).toBe(true);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("quarantines a failed run when its failure report cannot be persisted", async () => {
|
|
816
|
+
vi.spyOn(reportEvents, "appendTeamReportEvent").mockRejectedValue(new Error("failure report store unavailable"));
|
|
817
|
+
const session = makeSession();
|
|
818
|
+
session.prompt.mockRejectedValue(new Error("provider failed"));
|
|
819
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
820
|
+
let privateSessionDir = "";
|
|
821
|
+
piMocks.sessionManagerCreate.mockImplementation((cwd: string, sessionDir?: string) => {
|
|
822
|
+
privateSessionDir = sessionDir!;
|
|
823
|
+
const sessionFile = path.join(privateSessionDir, "child-session.jsonl");
|
|
824
|
+
fs.writeFileSync(sessionFile, "recoverable failed transcript\n");
|
|
825
|
+
return {
|
|
826
|
+
cwd,
|
|
827
|
+
getSessionId: () => "child-session",
|
|
828
|
+
getSessionDir: () => privateSessionDir,
|
|
829
|
+
getSessionFile: () => sessionFile,
|
|
830
|
+
};
|
|
831
|
+
});
|
|
832
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
833
|
+
const options = makeRunOptions(runningReadAgents);
|
|
834
|
+
|
|
835
|
+
await runReadAgentInProcess(
|
|
836
|
+
"team",
|
|
837
|
+
{ ...fixtureMember("reader"), prompt: "investigate" },
|
|
838
|
+
"investigate",
|
|
839
|
+
{ modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } },
|
|
840
|
+
options,
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const retainedMember = (await teams.readConfig("team")).members.find(member => member.name === "reader");
|
|
844
|
+
expect(retainedMember).toMatchObject({ isActive: false, lifecycleRunId: expect.any(String) });
|
|
845
|
+
const runId = retainedMember!.lifecycleRunId!;
|
|
846
|
+
expect(fs.existsSync(privateSessionDir)).toBe(true);
|
|
847
|
+
await expect(runtime.readRuntimeStatus("team", "reader")).resolves.toMatchObject({ lifecycleRunId: runId });
|
|
848
|
+
await expect(readLifecycleTombstone("team", "reader")).resolves.toMatchObject({
|
|
849
|
+
status: "occupied",
|
|
850
|
+
tombstone: {
|
|
851
|
+
runId,
|
|
852
|
+
phase: "cleanup_failed",
|
|
853
|
+
error: expect.stringContaining("failure report store unavailable"),
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
expect(options.emitAgentReport).toHaveBeenCalledWith(
|
|
857
|
+
"team",
|
|
858
|
+
"reader",
|
|
859
|
+
expect.any(Number),
|
|
860
|
+
expect.any(Number),
|
|
861
|
+
expect.stringContaining("Recovery pointer: pi-child-session/v1"),
|
|
862
|
+
false,
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it("requests one report-only recovery turn when the first turn has no usable output", async () => {
|
|
867
|
+
const session = makeSession();
|
|
868
|
+
session.messages = [];
|
|
869
|
+
session.prompt.mockImplementation(async () => {
|
|
870
|
+
if (session.prompt.mock.calls.length === 1) return;
|
|
871
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
872
|
+
const reportTool = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
873
|
+
await reportTool.execute("recovered-report", {
|
|
874
|
+
content: "Recovered on the report-only turn",
|
|
875
|
+
summary: "Recovered",
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
879
|
+
const member = { ...fixtureMember("reader"), prompt: "investigate" };
|
|
880
|
+
writeTeamConfig("team", member);
|
|
881
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
882
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
883
|
+
const emitAgentReport = vi.fn();
|
|
884
|
+
|
|
885
|
+
await runReadAgentInProcess("team", member, "investigate", {
|
|
886
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
887
|
+
}, {
|
|
888
|
+
isTeammate: false,
|
|
889
|
+
getTeamName: () => "team",
|
|
890
|
+
runningReadAgents,
|
|
891
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
892
|
+
isCurrentReadAgentRun: (key, state) => runningReadAgents.get(key) === state,
|
|
893
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
894
|
+
renderReadAgentStatus: vi.fn(),
|
|
895
|
+
rememberCompletedAgentReport,
|
|
896
|
+
emitAgentReport,
|
|
897
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
expect(session.prompt).toHaveBeenCalledTimes(2);
|
|
901
|
+
expect((session.prompt.mock.calls as any[][])[1][0]).toContain("previous turn ended without a usable final report");
|
|
902
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
903
|
+
status: "completed",
|
|
904
|
+
report: "Recovered on the report-only turn",
|
|
905
|
+
summary: "Recovered",
|
|
906
|
+
reportSource: "report_and_exit",
|
|
907
|
+
recoveryAttempted: true,
|
|
908
|
+
}));
|
|
909
|
+
expect(rememberCompletedAgentReport.mock.calls[0][1]).not.toHaveProperty("recoverySessionId");
|
|
910
|
+
expect(rememberCompletedAgentReport.mock.calls[0][1]).not.toHaveProperty("recoverySessionFile");
|
|
911
|
+
expect(emitAgentReport).toHaveBeenCalledWith(
|
|
912
|
+
"team", "reader", expect.any(Number), 42, "Recovered on the report-only turn", true,
|
|
913
|
+
);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("recovers an accepted report_and_exit payload from the durable child session", async () => {
|
|
917
|
+
const session = makeSession();
|
|
918
|
+
session.messages = [];
|
|
919
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
920
|
+
const sessionFile = path.join(root, "child-sessions", "durable-child.jsonl");
|
|
921
|
+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
922
|
+
fs.writeFileSync(sessionFile, "durable fixture\n");
|
|
923
|
+
piMocks.sessionManagerCreate.mockImplementationOnce(() => ({
|
|
924
|
+
getSessionId: () => "durable-child",
|
|
925
|
+
getSessionFile: () => sessionFile,
|
|
926
|
+
}));
|
|
927
|
+
piMocks.persistedSessionEntries.set(sessionFile, [
|
|
928
|
+
{
|
|
929
|
+
type: "message",
|
|
930
|
+
message: {
|
|
931
|
+
role: "assistant",
|
|
932
|
+
content: [{
|
|
933
|
+
type: "toolCall",
|
|
934
|
+
id: "accepted-report-call",
|
|
935
|
+
name: "report_and_exit",
|
|
936
|
+
arguments: { content: "Recovered durable report", summary: "Durable" },
|
|
937
|
+
}],
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
type: "message",
|
|
942
|
+
message: {
|
|
943
|
+
role: "toolResult",
|
|
944
|
+
toolCallId: "accepted-report-call",
|
|
945
|
+
toolName: "report_and_exit",
|
|
946
|
+
content: [{ type: "text", text: "Final report accepted." }],
|
|
947
|
+
details: { accepted: true },
|
|
948
|
+
isError: false,
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
]);
|
|
952
|
+
const member = { ...fixtureMember("reader"), prompt: "investigate" };
|
|
953
|
+
writeTeamConfig("team", member);
|
|
954
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
955
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
956
|
+
const emitAgentReport = vi.fn();
|
|
957
|
+
|
|
958
|
+
await runReadAgentInProcess("team", member, "investigate", {
|
|
959
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
960
|
+
}, {
|
|
961
|
+
isTeammate: false,
|
|
962
|
+
getTeamName: () => "team",
|
|
963
|
+
runningReadAgents,
|
|
964
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
965
|
+
isCurrentReadAgentRun: (key, state) => runningReadAgents.get(key) === state,
|
|
966
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
967
|
+
renderReadAgentStatus: vi.fn(),
|
|
968
|
+
rememberCompletedAgentReport,
|
|
969
|
+
emitAgentReport,
|
|
970
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
expect(session.prompt).toHaveBeenCalledOnce();
|
|
974
|
+
expect(piMocks.sessionManagerOpen).toHaveBeenCalledWith(sessionFile);
|
|
975
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
976
|
+
status: "completed",
|
|
977
|
+
report: "Recovered durable report",
|
|
978
|
+
summary: "Durable",
|
|
979
|
+
reportSource: "persisted-report_and_exit",
|
|
980
|
+
recoveryAttempted: false,
|
|
981
|
+
}));
|
|
982
|
+
expect(rememberCompletedAgentReport.mock.calls[0][1]).not.toHaveProperty("recoverySessionId");
|
|
983
|
+
expect(rememberCompletedAgentReport.mock.calls[0][1]).not.toHaveProperty("recoverySessionFile");
|
|
984
|
+
expect(emitAgentReport).toHaveBeenCalledWith(
|
|
985
|
+
"team", "reader", expect.any(Number), 42, "Recovered durable report", true,
|
|
986
|
+
);
|
|
987
|
+
const reports = await listTeamReportEvents("team");
|
|
988
|
+
expect(reports.at(-1)).toMatchObject({
|
|
989
|
+
status: "completed",
|
|
990
|
+
report: "Recovered durable report",
|
|
991
|
+
metadata: {
|
|
992
|
+
reportSource: "persisted-report_and_exit",
|
|
993
|
+
recoveryAttempted: false,
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
expect(reports.at(-1)?.metadata).not.toHaveProperty("recoverySessionId");
|
|
997
|
+
expect(reports.at(-1)?.metadata).not.toHaveProperty("recoverySessionFile");
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("classifies a terminal provider error without report text as a failed recoverable run", async () => {
|
|
1001
|
+
const session = makeSession();
|
|
1002
|
+
(session as any).messages = [{
|
|
1003
|
+
role: "assistant",
|
|
1004
|
+
content: [],
|
|
1005
|
+
stopReason: "error",
|
|
1006
|
+
errorMessage: "provider retries exhausted",
|
|
1007
|
+
}];
|
|
1008
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1009
|
+
const sessionFile = path.join(root, "child-sessions", "provider-error.jsonl");
|
|
1010
|
+
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
|
|
1011
|
+
fs.writeFileSync(sessionFile, "provider error fixture\n");
|
|
1012
|
+
piMocks.sessionManagerCreate.mockImplementationOnce(() => ({
|
|
1013
|
+
getSessionId: () => "provider-error",
|
|
1014
|
+
getSessionFile: () => sessionFile,
|
|
1015
|
+
}));
|
|
1016
|
+
const member = { ...fixtureMember("reader"), prompt: "investigate" };
|
|
1017
|
+
writeTeamConfig("team", member);
|
|
1018
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1019
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1020
|
+
const emitAgentReport = vi.fn();
|
|
1021
|
+
|
|
1022
|
+
await runReadAgentInProcess("team", member, "investigate", {
|
|
1023
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1024
|
+
}, {
|
|
1025
|
+
isTeammate: false,
|
|
1026
|
+
getTeamName: () => "team",
|
|
1027
|
+
runningReadAgents,
|
|
1028
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
1029
|
+
isCurrentReadAgentRun: (key, state) => runningReadAgents.get(key) === state,
|
|
1030
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1031
|
+
renderReadAgentStatus: vi.fn(),
|
|
1032
|
+
rememberCompletedAgentReport,
|
|
1033
|
+
emitAgentReport,
|
|
1034
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
expect(session.prompt).toHaveBeenCalledOnce();
|
|
1038
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1039
|
+
status: "failed",
|
|
1040
|
+
report: expect.stringContaining("stopReason=error: provider retries exhausted"),
|
|
1041
|
+
reportSource: "irrecoverable-empty",
|
|
1042
|
+
recoveryAttempted: false,
|
|
1043
|
+
recoverySessionId: "provider-error",
|
|
1044
|
+
recoverySessionFile: sessionFile,
|
|
1045
|
+
}));
|
|
1046
|
+
const failureText = rememberCompletedAgentReport.mock.calls[0][1].report;
|
|
1047
|
+
expect(failureText).toContain(`Recovery pointer: pi-child-session/v1`);
|
|
1048
|
+
expect(failureText).toContain(`sessionFile=${JSON.stringify(sessionFile)}`);
|
|
1049
|
+
expect(failureText).toContain("Retrieve the child transcript with the read tool");
|
|
1050
|
+
expect(failureText).not.toContain("produced no assistant text");
|
|
1051
|
+
expect(emitAgentReport).toHaveBeenCalledWith("team", "reader", expect.any(Number), 42, failureText, false);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("emits a failed diagnostic and stable pointer when the recovery turn is also empty", async () => {
|
|
1055
|
+
const session = makeSession();
|
|
1056
|
+
session.messages = [];
|
|
1057
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1058
|
+
const member = { ...fixtureMember("reader"), prompt: "investigate" };
|
|
1059
|
+
writeTeamConfig("team", member);
|
|
1060
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1061
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1062
|
+
const emitAgentReport = vi.fn();
|
|
1063
|
+
|
|
1064
|
+
await runReadAgentInProcess("team", member, "investigate", {
|
|
1065
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1066
|
+
}, {
|
|
1067
|
+
isTeammate: false,
|
|
1068
|
+
getTeamName: () => "team",
|
|
1069
|
+
runningReadAgents,
|
|
1070
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
1071
|
+
isCurrentReadAgentRun: (key, state) => runningReadAgents.get(key) === state,
|
|
1072
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1073
|
+
renderReadAgentStatus: vi.fn(),
|
|
1074
|
+
rememberCompletedAgentReport,
|
|
1075
|
+
emitAgentReport,
|
|
1076
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
expect(session.prompt).toHaveBeenCalledTimes(2);
|
|
1080
|
+
const failedReport = rememberCompletedAgentReport.mock.calls[0][1];
|
|
1081
|
+
expect(failedReport).toMatchObject({
|
|
1082
|
+
status: "failed",
|
|
1083
|
+
reportSource: "irrecoverable-empty",
|
|
1084
|
+
recoveryAttempted: true,
|
|
1085
|
+
recoverySessionId: "child-session-1",
|
|
1086
|
+
recoverySessionFile: expect.stringContaining("child-session-1.jsonl"),
|
|
1087
|
+
});
|
|
1088
|
+
expect(failedReport.report).toContain("report-only recovery turn without producing any usable final report");
|
|
1089
|
+
expect(failedReport.report).toContain("durableFile=false");
|
|
1090
|
+
expect(failedReport.report).toContain("lifecycleRunId=");
|
|
1091
|
+
expect(failedReport.report).not.toContain("produced no assistant text");
|
|
1092
|
+
expect(emitAgentReport).toHaveBeenCalledWith(
|
|
1093
|
+
"team", "reader", expect.any(Number), 42, failedReport.report, false,
|
|
1094
|
+
);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it("loads one immutable extension selection, activates its tools, and propagates parent trust", async () => {
|
|
1098
|
+
const session = makeSession();
|
|
1099
|
+
session.hasExtensionHandlers.mockReturnValue(true);
|
|
1100
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1101
|
+
piMocks.loaderExtensions.push({
|
|
1102
|
+
tools: new Map([
|
|
1103
|
+
["selected_extension_tool", { definition: { name: "selected_extension_tool" } }],
|
|
1104
|
+
["send_message", { definition: { name: "send_message", description: "untrusted override" } }],
|
|
1105
|
+
["spawn_agent", { definition: { name: "spawn_agent", description: "untrusted external spawn" } }],
|
|
1106
|
+
["spawn_swarm_agents", { definition: { name: "spawn_swarm_agents", description: "untrusted external swarm" } }],
|
|
1107
|
+
]),
|
|
1108
|
+
});
|
|
1109
|
+
const createResourcePlan = vi.fn(async (input: { cwd: string; projectTrusted: boolean }) => Object.freeze({
|
|
1110
|
+
selectionMode: "explicit" as const,
|
|
1111
|
+
extensionPaths: Object.freeze(["/extensions/selected.ts"]),
|
|
1112
|
+
extensions: Object.freeze([]),
|
|
1113
|
+
diagnostics: Object.freeze([]),
|
|
1114
|
+
skills: "all" as const,
|
|
1115
|
+
trust: Object.freeze({ cwd: input.cwd, projectTrusted: input.projectTrusted }),
|
|
1116
|
+
}));
|
|
1117
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1118
|
+
|
|
1119
|
+
await runReadAgentInProcess("team", {
|
|
1120
|
+
agentId: "reader@team",
|
|
1121
|
+
name: "reader",
|
|
1122
|
+
agentType: "teammate",
|
|
1123
|
+
role: "read",
|
|
1124
|
+
model: "provider/model",
|
|
1125
|
+
thinking: "high",
|
|
1126
|
+
modelSlot: "reading-default",
|
|
1127
|
+
joinedAt: Date.now(),
|
|
1128
|
+
tmuxPaneId: "",
|
|
1129
|
+
cwd: root,
|
|
1130
|
+
subscriptions: [],
|
|
1131
|
+
prompt: "investigate",
|
|
1132
|
+
}, "investigate", {
|
|
1133
|
+
cwd: root,
|
|
1134
|
+
isProjectTrusted: () => true,
|
|
1135
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1136
|
+
}, {
|
|
1137
|
+
isTeammate: false,
|
|
1138
|
+
getTeamName: () => "team",
|
|
1139
|
+
runningReadAgents,
|
|
1140
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1141
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1142
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1143
|
+
renderReadAgentStatus: vi.fn(),
|
|
1144
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
1145
|
+
emitAgentReport: vi.fn(),
|
|
1146
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1147
|
+
createResourcePlan,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
expect(createResourcePlan).toHaveBeenCalledOnce();
|
|
1151
|
+
expect(createResourcePlan).toHaveBeenCalledWith({ cwd: root, projectTrusted: true });
|
|
1152
|
+
expect(piMocks.loaderOptions[0]).toMatchObject({
|
|
1153
|
+
additionalExtensionPaths: ["/extensions/selected.ts"],
|
|
1154
|
+
noExtensions: true,
|
|
1155
|
+
noSkills: false,
|
|
1156
|
+
});
|
|
1157
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1158
|
+
expect(sessionOptions.tools).toContain("selected_extension_tool");
|
|
1159
|
+
expect(sessionOptions.tools.filter((name: string) => name === "send_message")).toHaveLength(1);
|
|
1160
|
+
expect(sessionOptions.tools).not.toContain("spawn_agent");
|
|
1161
|
+
expect(sessionOptions.tools).not.toContain("spawn_swarm_agents");
|
|
1162
|
+
expect(sessionOptions.customTools.find((tool: any) => tool.name === "send_message")?.description).not.toBe("untrusted override");
|
|
1163
|
+
expect(sessionOptions.customTools.some((tool: any) => tool.name === "spawn_agent" || tool.name === "spawn_swarm_agents")).toBe(false);
|
|
1164
|
+
expect(piMocks.settingsManagers.at(-1)?.isProjectTrusted()).toBe(true);
|
|
1165
|
+
expect(session.bindExtensions.mock.invocationCallOrder[0]).toBeLessThan(session.prompt.mock.invocationCallOrder[0]);
|
|
1166
|
+
expect(session.extensionRunner.emit).toHaveBeenCalledOnce();
|
|
1167
|
+
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
|
|
1168
|
+
expect(session.extensionRunner.emit.mock.invocationCallOrder[0]).toBeLessThan(session.clearQueue.mock.invocationCallOrder[0]);
|
|
1169
|
+
expect(session.clearQueue.mock.invocationCallOrder[0]).toBeLessThan(session.abort.mock.invocationCallOrder[0]);
|
|
1170
|
+
expect(session.abort.mock.invocationCallOrder[0]).toBeLessThan(session.dispose.mock.invocationCallOrder[0]);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it("replaces filtered external spawn collisions with restricted tools only for an opted-in write-feature parent", async () => {
|
|
1174
|
+
const session = makeSession();
|
|
1175
|
+
session.prompt.mockImplementation(async () => {
|
|
1176
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1177
|
+
const report = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
1178
|
+
await report.execute("report", { content: "tool wiring verified", summary: "Done" });
|
|
1179
|
+
});
|
|
1180
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1181
|
+
piMocks.loaderExtensions.push({
|
|
1182
|
+
tools: new Map([
|
|
1183
|
+
["spawn_agent", { definition: { name: "spawn_agent", description: "untrusted external spawn" } }],
|
|
1184
|
+
["spawn_swarm_agents", { definition: { name: "spawn_swarm_agents", description: "untrusted external swarm" } }],
|
|
1185
|
+
]),
|
|
1186
|
+
});
|
|
1187
|
+
const restrictedTools = [
|
|
1188
|
+
{ name: "spawn_agent", description: "restricted single", execute: vi.fn() },
|
|
1189
|
+
{ name: "spawn_swarm_agents", description: "restricted swarm", execute: vi.fn() },
|
|
1190
|
+
];
|
|
1191
|
+
const createNestedReadAgentTools = vi.fn(() => restrictedTools);
|
|
1192
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1193
|
+
const pendingChildController = createPendingChildController();
|
|
1194
|
+
const member: Member = {
|
|
1195
|
+
agentId: "feature-writer@team",
|
|
1196
|
+
name: "feature-writer",
|
|
1197
|
+
agentType: "teammate",
|
|
1198
|
+
role: "write",
|
|
1199
|
+
model: "provider/model",
|
|
1200
|
+
thinking: "medium",
|
|
1201
|
+
modelSlot: "write-feature",
|
|
1202
|
+
joinedAt: Date.now(),
|
|
1203
|
+
tmuxPaneId: "",
|
|
1204
|
+
cwd: root,
|
|
1205
|
+
subscriptions: [],
|
|
1206
|
+
prompt: "implement a bounded feature",
|
|
1207
|
+
delegationDepth: 0,
|
|
1208
|
+
allowNestedReadAgents: true,
|
|
1209
|
+
};
|
|
1210
|
+
writeTeamConfig("team", member);
|
|
1211
|
+
const outerCtx = {
|
|
1212
|
+
cwd: root,
|
|
1213
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
await runReadAgentInProcess("team", member, "implement a bounded feature", outerCtx, {
|
|
1217
|
+
isTeammate: false,
|
|
1218
|
+
getTeamName: () => "team",
|
|
1219
|
+
runningReadAgents,
|
|
1220
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1221
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1222
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1223
|
+
renderReadAgentStatus: vi.fn(),
|
|
1224
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
1225
|
+
emitAgentReport: vi.fn(),
|
|
1226
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1227
|
+
createNestedReadAgentTools,
|
|
1228
|
+
pendingChildController,
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
expect(createNestedReadAgentTools).toHaveBeenCalledOnce();
|
|
1232
|
+
expect(createNestedReadAgentTools).toHaveBeenCalledWith({
|
|
1233
|
+
teamName: "team",
|
|
1234
|
+
parent: member,
|
|
1235
|
+
parentRunId: member.lifecycleRunId,
|
|
1236
|
+
outerCtx,
|
|
1237
|
+
});
|
|
1238
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1239
|
+
expect(sessionOptions.tools.filter((name: string) => name === "spawn_agent")).toEqual(["spawn_agent"]);
|
|
1240
|
+
expect(sessionOptions.tools.filter((name: string) => name === "spawn_swarm_agents")).toEqual(["spawn_swarm_agents"]);
|
|
1241
|
+
expect(sessionOptions.customTools).toEqual(expect.arrayContaining(restrictedTools));
|
|
1242
|
+
expect(sessionOptions.customTools.find((tool: any) => tool.name === "spawn_agent")?.description).toBe("restricted single");
|
|
1243
|
+
expect(sessionOptions.customTools.find((tool: any) => tool.name === "spawn_swarm_agents")?.description).toBe("restricted swarm");
|
|
1244
|
+
const promptText = piMocks.loaderOptions[0].appendSystemPrompt.join("\n");
|
|
1245
|
+
expect(promptText).toContain("opted-in depth-0 write-feature/write-critical run");
|
|
1246
|
+
expect(promptText).toContain("restricted spawn_agent or spawn_swarm_agents");
|
|
1247
|
+
expect(promptText).toContain("any canonical read-* tier and any helper count");
|
|
1248
|
+
expect(promptText).toContain("global read capacity and queue");
|
|
1249
|
+
expect(promptText).toContain("Children report to you and cannot delegate");
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
it("keeps an opted-in writer live for an exact child report and lets the idle continuation submit the sole final report", async () => {
|
|
1253
|
+
const session = makeSession();
|
|
1254
|
+
session.messages = [{ role: "assistant", content: "initial turn ended without a final report" }];
|
|
1255
|
+
const pendingChildController = createPendingChildController();
|
|
1256
|
+
let exactChildRun: PendingChildRun | undefined;
|
|
1257
|
+
let sessionOptions: any;
|
|
1258
|
+
let continuationReportResult: any;
|
|
1259
|
+
const createNestedReadAgentTools = vi.fn((binding: any) => [{
|
|
1260
|
+
name: "spawn_agent",
|
|
1261
|
+
async execute() {
|
|
1262
|
+
const acceptance = pendingChildController.acceptChild({
|
|
1263
|
+
teamName: binding.teamName,
|
|
1264
|
+
parentName: binding.parent.name,
|
|
1265
|
+
parentRunId: binding.parentRunId,
|
|
1266
|
+
}, "child");
|
|
1267
|
+
exactChildRun = pendingChildController.bindAcceptedChild(acceptance, "child", "child-run");
|
|
1268
|
+
return { content: [{ type: "text", text: "child started" }] };
|
|
1269
|
+
},
|
|
1270
|
+
}]);
|
|
1271
|
+
session.prompt.mockImplementation(async () => {
|
|
1272
|
+
sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1273
|
+
const spawn = sessionOptions.customTools.find((tool: any) => tool.name === "spawn_agent");
|
|
1274
|
+
await spawn.execute("spawn-child", {});
|
|
1275
|
+
});
|
|
1276
|
+
session.sendUserMessage.mockImplementation(async (...args: any[]) => {
|
|
1277
|
+
expect(args[0]).toBe("Exact child report");
|
|
1278
|
+
const report = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
1279
|
+
continuationReportResult = await report.execute("parent-report", {
|
|
1280
|
+
content: "Authoritative parent report",
|
|
1281
|
+
summary: "Parent done",
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1285
|
+
|
|
1286
|
+
const writer: Member = {
|
|
1287
|
+
agentId: "feature-writer@team",
|
|
1288
|
+
name: "feature-writer",
|
|
1289
|
+
agentType: "teammate",
|
|
1290
|
+
role: "write",
|
|
1291
|
+
model: "provider/model",
|
|
1292
|
+
thinking: "medium",
|
|
1293
|
+
modelSlot: "write-feature",
|
|
1294
|
+
joinedAt: Date.now(),
|
|
1295
|
+
tmuxPaneId: "",
|
|
1296
|
+
cwd: root,
|
|
1297
|
+
subscriptions: [],
|
|
1298
|
+
prompt: "implement a bounded feature",
|
|
1299
|
+
delegationDepth: 0,
|
|
1300
|
+
allowNestedReadAgents: true,
|
|
1301
|
+
};
|
|
1302
|
+
writeTeamConfig("team", writer);
|
|
1303
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1304
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1305
|
+
const options = {
|
|
1306
|
+
isTeammate: false,
|
|
1307
|
+
getTeamName: () => "different-team",
|
|
1308
|
+
runningReadAgents,
|
|
1309
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1310
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1311
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1312
|
+
renderReadAgentStatus: vi.fn(),
|
|
1313
|
+
rememberCompletedAgentReport,
|
|
1314
|
+
emitAgentReport: vi.fn(),
|
|
1315
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1316
|
+
createNestedReadAgentTools,
|
|
1317
|
+
pendingChildController,
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
let runSettled = false;
|
|
1321
|
+
const run = runReadAgentInProcess("team", writer, writer.prompt!, {
|
|
1322
|
+
cwd: root,
|
|
1323
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1324
|
+
}, options).then(() => { runSettled = true; });
|
|
1325
|
+
|
|
1326
|
+
await vi.waitFor(() => {
|
|
1327
|
+
expect(exactChildRun).toBeDefined();
|
|
1328
|
+
expect(pendingChildController.pendingCount({
|
|
1329
|
+
teamName: "team",
|
|
1330
|
+
parentName: "feature-writer",
|
|
1331
|
+
parentRunId: writer.lifecycleRunId!,
|
|
1332
|
+
})).toBe(1);
|
|
1333
|
+
});
|
|
1334
|
+
const liveParent = runningReadAgents.get("team:feature-writer")!;
|
|
1335
|
+
expect(liveParent.acceptingMessages).toBe(true);
|
|
1336
|
+
expect(liveParent.persistedRecipientClosed).not.toBe(true);
|
|
1337
|
+
expect(runSettled).toBe(false);
|
|
1338
|
+
expect(rememberCompletedAgentReport).not.toHaveBeenCalled();
|
|
1339
|
+
expect((await teams.readConfig("team")).members.find(member => member.name === writer.name)?.isActive).not.toBe(false);
|
|
1340
|
+
|
|
1341
|
+
const delivery = sendMessageToRunningReadAgent(liveParent, "Exact child report").catch((error: Error) => error);
|
|
1342
|
+
await expect(delivery).resolves.toEqual(expect.objectContaining({ message: expect.stringContaining("was cancelled") }));
|
|
1343
|
+
expect(pendingChildController.settleChildRun(exactChildRun!)).toBe(false);
|
|
1344
|
+
await run;
|
|
1345
|
+
|
|
1346
|
+
expect(pendingChildController.trackedParentCount()).toBe(0);
|
|
1347
|
+
expect(session.sendUserMessage).toHaveBeenCalledOnce();
|
|
1348
|
+
expect(continuationReportResult).toMatchObject({ details: { accepted: true } });
|
|
1349
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledOnce();
|
|
1350
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1351
|
+
report: "Authoritative parent report",
|
|
1352
|
+
summary: "Parent done",
|
|
1353
|
+
}));
|
|
1354
|
+
expect(JSON.stringify(rememberCompletedAgentReport.mock.calls)).not.toContain("initial turn ended without a final report");
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
it("keeps an eligible zero-child parent idle until a direct message submits the first authoritative report", async () => {
|
|
1358
|
+
const session = makeSession();
|
|
1359
|
+
session.messages = [{ role: "assistant", content: "non-authoritative initial assistant text" }];
|
|
1360
|
+
const pendingChildController = createPendingChildController();
|
|
1361
|
+
let firstReportResult: any;
|
|
1362
|
+
let duplicateReportResult: any;
|
|
1363
|
+
session.sendUserMessage.mockImplementation(async (...args: any[]) => {
|
|
1364
|
+
expect(args[0]).toBe("submit final report");
|
|
1365
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1366
|
+
const report = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
1367
|
+
firstReportResult = await report.execute("first", {
|
|
1368
|
+
content: "sole authoritative report",
|
|
1369
|
+
summary: "Authoritative",
|
|
1370
|
+
});
|
|
1371
|
+
duplicateReportResult = await report.execute("duplicate", {
|
|
1372
|
+
content: "must be ignored",
|
|
1373
|
+
summary: "Duplicate",
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1377
|
+
|
|
1378
|
+
const writer = eligibleNestedParent("zero-idle-writer");
|
|
1379
|
+
writeTeamConfig("team", writer);
|
|
1380
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1381
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1382
|
+
const options = {
|
|
1383
|
+
isTeammate: false,
|
|
1384
|
+
getTeamName: () => "different-team",
|
|
1385
|
+
runningReadAgents,
|
|
1386
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1387
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1388
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1389
|
+
renderReadAgentStatus: vi.fn(),
|
|
1390
|
+
rememberCompletedAgentReport,
|
|
1391
|
+
emitAgentReport: vi.fn(),
|
|
1392
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1393
|
+
createNestedReadAgentTools: vi.fn(() => [{ name: "spawn_agent", execute: vi.fn() }]),
|
|
1394
|
+
pendingChildController,
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
let runSettled = false;
|
|
1398
|
+
const run = runReadAgentInProcess("team", writer, writer.prompt!, {
|
|
1399
|
+
cwd: root,
|
|
1400
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1401
|
+
}, options).then(() => { runSettled = true; });
|
|
1402
|
+
|
|
1403
|
+
await vi.waitFor(() => {
|
|
1404
|
+
expect(session.prompt).toHaveBeenCalledOnce();
|
|
1405
|
+
expect(runningReadAgents.get("team:zero-idle-writer")?.acceptingMessages).toBe(true);
|
|
1406
|
+
});
|
|
1407
|
+
expect(runSettled).toBe(false);
|
|
1408
|
+
expect(rememberCompletedAgentReport).not.toHaveBeenCalled();
|
|
1409
|
+
expect(pendingChildController.pendingCount({
|
|
1410
|
+
teamName: "team",
|
|
1411
|
+
parentName: writer.name,
|
|
1412
|
+
parentRunId: writer.lifecycleRunId!,
|
|
1413
|
+
})).toBe(0);
|
|
1414
|
+
|
|
1415
|
+
const delivery = sendMessageToRunningReadAgent(
|
|
1416
|
+
runningReadAgents.get("team:zero-idle-writer"),
|
|
1417
|
+
"submit final report"
|
|
1418
|
+
).catch((error: Error) => error);
|
|
1419
|
+
await expect(delivery).resolves.toEqual(expect.objectContaining({ message: expect.stringContaining("was cancelled") }));
|
|
1420
|
+
await run;
|
|
1421
|
+
|
|
1422
|
+
expect(firstReportResult).toMatchObject({ details: { accepted: true } });
|
|
1423
|
+
expect(duplicateReportResult).toMatchObject({ details: { accepted: false } });
|
|
1424
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledOnce();
|
|
1425
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1426
|
+
report: "sole authoritative report",
|
|
1427
|
+
summary: "Authoritative",
|
|
1428
|
+
}));
|
|
1429
|
+
expect(JSON.stringify(rememberCompletedAgentReport.mock.calls)).not.toContain("non-authoritative initial assistant text");
|
|
1430
|
+
expect(JSON.stringify(rememberCompletedAgentReport.mock.calls)).not.toContain("must be ignored");
|
|
1431
|
+
expect(pendingChildController.trackedParentCount()).toBe(0);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it("keeps a parent live when a child-report continuation returns without a report, then accepts a later direct report", async () => {
|
|
1435
|
+
const session = makeSession();
|
|
1436
|
+
const pendingChildController = createPendingChildController();
|
|
1437
|
+
let childRun: PendingChildRun | undefined;
|
|
1438
|
+
session.prompt.mockImplementation(async () => {
|
|
1439
|
+
const identity = {
|
|
1440
|
+
teamName: "team",
|
|
1441
|
+
parentName: "continuation-writer",
|
|
1442
|
+
parentRunId: writer.lifecycleRunId!,
|
|
1443
|
+
};
|
|
1444
|
+
const acceptance = pendingChildController.acceptChild(identity, "child");
|
|
1445
|
+
childRun = pendingChildController.bindAcceptedChild(acceptance, "child", "child-run");
|
|
1446
|
+
});
|
|
1447
|
+
session.sendUserMessage.mockImplementation(async (...args: any[]) => {
|
|
1448
|
+
const content = args[0];
|
|
1449
|
+
if (content === "child report without parent final") return;
|
|
1450
|
+
expect(content).toBe("later direct report");
|
|
1451
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1452
|
+
const report = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
1453
|
+
await report.execute("final", { content: "later authoritative report", summary: "Done later" });
|
|
1454
|
+
});
|
|
1455
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1456
|
+
|
|
1457
|
+
const writer = eligibleNestedParent("continuation-writer");
|
|
1458
|
+
writeTeamConfig("team", writer);
|
|
1459
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1460
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1461
|
+
const options = {
|
|
1462
|
+
isTeammate: false,
|
|
1463
|
+
getTeamName: () => "different-team",
|
|
1464
|
+
runningReadAgents,
|
|
1465
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1466
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1467
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1468
|
+
renderReadAgentStatus: vi.fn(),
|
|
1469
|
+
rememberCompletedAgentReport,
|
|
1470
|
+
emitAgentReport: vi.fn(),
|
|
1471
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1472
|
+
createNestedReadAgentTools: vi.fn(() => [{ name: "spawn_agent", execute: vi.fn() }]),
|
|
1473
|
+
pendingChildController,
|
|
1474
|
+
};
|
|
1475
|
+
let runSettled = false;
|
|
1476
|
+
const run = runReadAgentInProcess("team", writer, writer.prompt!, {
|
|
1477
|
+
cwd: root,
|
|
1478
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1479
|
+
}, options).then(() => { runSettled = true; });
|
|
1480
|
+
|
|
1481
|
+
await vi.waitFor(() => expect(childRun).toBeDefined());
|
|
1482
|
+
const liveParent = runningReadAgents.get("team:continuation-writer")!;
|
|
1483
|
+
expect(pendingChildController.settleChildRun(childRun!)).toBe(true);
|
|
1484
|
+
await expect(sendMessageToRunningReadAgent(liveParent, "child report without parent final")).resolves.toBe(true);
|
|
1485
|
+
expect(runSettled).toBe(false);
|
|
1486
|
+
expect(liveParent.acceptingMessages).toBe(true);
|
|
1487
|
+
expect(rememberCompletedAgentReport).not.toHaveBeenCalled();
|
|
1488
|
+
|
|
1489
|
+
const finalDelivery = sendMessageToRunningReadAgent(liveParent, "later direct report").catch((error: Error) => error);
|
|
1490
|
+
await expect(finalDelivery).resolves.toEqual(expect.objectContaining({ message: expect.stringContaining("was cancelled") }));
|
|
1491
|
+
await run;
|
|
1492
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1493
|
+
report: "later authoritative report",
|
|
1494
|
+
summary: "Done later",
|
|
1495
|
+
}));
|
|
1496
|
+
expect(pendingChildController.trackedParentCount()).toBe(0);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it("rearms across child waves when child B is accepted during child A's continuation", async () => {
|
|
1500
|
+
const session = makeSession();
|
|
1501
|
+
const pendingChildController = createPendingChildController();
|
|
1502
|
+
let childA: PendingChildRun | undefined;
|
|
1503
|
+
let childB: PendingChildRun | undefined;
|
|
1504
|
+
const writer = eligibleNestedParent("wave-writer");
|
|
1505
|
+
session.prompt.mockImplementation(async () => {
|
|
1506
|
+
const identity = { teamName: "team", parentName: writer.name, parentRunId: writer.lifecycleRunId! };
|
|
1507
|
+
const acceptance = pendingChildController.acceptChild(identity, "child-a");
|
|
1508
|
+
childA = pendingChildController.bindAcceptedChild(acceptance, "child-a", "child-a-run");
|
|
1509
|
+
});
|
|
1510
|
+
session.sendUserMessage.mockImplementation(async (...args: any[]) => {
|
|
1511
|
+
const content = args[0];
|
|
1512
|
+
const identity = { teamName: "team", parentName: writer.name, parentRunId: writer.lifecycleRunId! };
|
|
1513
|
+
if (content === "child A report") {
|
|
1514
|
+
const acceptance = pendingChildController.acceptChild(identity, "child-b");
|
|
1515
|
+
childB = pendingChildController.bindAcceptedChild(acceptance, "child-b", "child-b-run");
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
expect(content).toBe("child B report");
|
|
1519
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1520
|
+
const report = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
1521
|
+
await report.execute("waves-complete", { content: "all child waves integrated", summary: "Waves done" });
|
|
1522
|
+
});
|
|
1523
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1524
|
+
|
|
1525
|
+
writeTeamConfig("team", writer);
|
|
1526
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1527
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1528
|
+
const options = {
|
|
1529
|
+
isTeammate: false,
|
|
1530
|
+
getTeamName: () => "different-team",
|
|
1531
|
+
runningReadAgents,
|
|
1532
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1533
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1534
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1535
|
+
renderReadAgentStatus: vi.fn(),
|
|
1536
|
+
rememberCompletedAgentReport,
|
|
1537
|
+
emitAgentReport: vi.fn(),
|
|
1538
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1539
|
+
createNestedReadAgentTools: vi.fn(() => [{ name: "spawn_agent", execute: vi.fn() }]),
|
|
1540
|
+
pendingChildController,
|
|
1541
|
+
};
|
|
1542
|
+
let runSettled = false;
|
|
1543
|
+
const run = runReadAgentInProcess("team", writer, writer.prompt!, {
|
|
1544
|
+
cwd: root,
|
|
1545
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1546
|
+
}, options).then(() => { runSettled = true; });
|
|
1547
|
+
|
|
1548
|
+
await vi.waitFor(() => expect(childA).toBeDefined());
|
|
1549
|
+
const liveParent = runningReadAgents.get("team:wave-writer")!;
|
|
1550
|
+
expect(pendingChildController.settleChildRun(childA!)).toBe(true);
|
|
1551
|
+
await expect(sendMessageToRunningReadAgent(liveParent, "child A report")).resolves.toBe(true);
|
|
1552
|
+
expect(childB).toBeDefined();
|
|
1553
|
+
expect(pendingChildController.pendingCount({
|
|
1554
|
+
teamName: "team",
|
|
1555
|
+
parentName: writer.name,
|
|
1556
|
+
parentRunId: writer.lifecycleRunId!,
|
|
1557
|
+
})).toBe(1);
|
|
1558
|
+
expect(runSettled).toBe(false);
|
|
1559
|
+
|
|
1560
|
+
expect(pendingChildController.settleChildRun(childB!)).toBe(true);
|
|
1561
|
+
const finalDelivery = sendMessageToRunningReadAgent(liveParent, "child B report").catch((error: Error) => error);
|
|
1562
|
+
await expect(finalDelivery).resolves.toEqual(expect.objectContaining({ message: expect.stringContaining("was cancelled") }));
|
|
1563
|
+
await run;
|
|
1564
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1565
|
+
report: "all child waves integrated",
|
|
1566
|
+
summary: "Waves done",
|
|
1567
|
+
}));
|
|
1568
|
+
expect(pendingChildController.trackedParentCount()).toBe(0);
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it("unblocks an eligible zero-child idle parent immediately on explicit stop without fallback completion", async () => {
|
|
1572
|
+
const session = makeSession();
|
|
1573
|
+
session.messages = [{ role: "assistant", content: "must never become a completion report" }];
|
|
1574
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1575
|
+
const writer = eligibleNestedParent("stopped-idle-writer");
|
|
1576
|
+
writeTeamConfig("team", writer);
|
|
1577
|
+
const pendingChildController = createPendingChildController();
|
|
1578
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1579
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1580
|
+
const readAgentKey = (teamName: string, agentName: string) => `${teamName}:${agentName}`;
|
|
1581
|
+
const isCurrentReadAgentRun = (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state;
|
|
1582
|
+
const lifecycle = createLifecycleRuntime({
|
|
1583
|
+
isTeammate: false,
|
|
1584
|
+
terminal: null,
|
|
1585
|
+
runningReadAgents,
|
|
1586
|
+
readAgentKey,
|
|
1587
|
+
isCurrentReadAgentRun,
|
|
1588
|
+
renderReadAgentStatus: vi.fn(),
|
|
1589
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1590
|
+
drainWriteQueue: vi.fn(async () => {}),
|
|
1591
|
+
getSessionCwd: () => root,
|
|
1592
|
+
getTeamName: () => "team",
|
|
1593
|
+
onTeammateClosing: (teamName, member) => {
|
|
1594
|
+
pendingChildController.cancelParent({
|
|
1595
|
+
teamName,
|
|
1596
|
+
parentName: member.name,
|
|
1597
|
+
parentRunId: member.lifecycleRunId!,
|
|
1598
|
+
});
|
|
1599
|
+
},
|
|
1600
|
+
onTeammateSettled: (teamName, member) => {
|
|
1601
|
+
pendingChildController.forgetParent({
|
|
1602
|
+
teamName,
|
|
1603
|
+
parentName: member.name,
|
|
1604
|
+
parentRunId: member.lifecycleRunId!,
|
|
1605
|
+
});
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
const options = {
|
|
1609
|
+
isTeammate: false,
|
|
1610
|
+
getTeamName: () => "different-team",
|
|
1611
|
+
runningReadAgents,
|
|
1612
|
+
readAgentKey,
|
|
1613
|
+
isCurrentReadAgentRun,
|
|
1614
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1615
|
+
renderReadAgentStatus: vi.fn(),
|
|
1616
|
+
rememberCompletedAgentReport,
|
|
1617
|
+
emitAgentReport: vi.fn(),
|
|
1618
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1619
|
+
createNestedReadAgentTools: vi.fn(() => [{ name: "spawn_agent", execute: vi.fn() }]),
|
|
1620
|
+
pendingChildController,
|
|
1621
|
+
shutdownTeammate: lifecycle.shutdownTeammate,
|
|
1622
|
+
};
|
|
1623
|
+
const run = runReadAgentInProcess("team", writer, writer.prompt!, {
|
|
1624
|
+
cwd: root,
|
|
1625
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1626
|
+
}, options);
|
|
1627
|
+
|
|
1628
|
+
await vi.waitFor(() => {
|
|
1629
|
+
expect(session.prompt).toHaveBeenCalledOnce();
|
|
1630
|
+
expect(runningReadAgents.get("team:stopped-idle-writer")?.acceptingMessages).toBe(true);
|
|
1631
|
+
});
|
|
1632
|
+
await expect(lifecycle.shutdownTeammate("team", writer, { reason: "reload" })).resolves.toMatchObject({
|
|
1633
|
+
status: "settled",
|
|
1634
|
+
finalized: true,
|
|
1635
|
+
removedMember: true,
|
|
1636
|
+
});
|
|
1637
|
+
await run;
|
|
1638
|
+
|
|
1639
|
+
expect(rememberCompletedAgentReport).not.toHaveBeenCalled();
|
|
1640
|
+
expect(session.abort).toHaveBeenCalledOnce();
|
|
1641
|
+
expect(session.dispose).toHaveBeenCalledOnce();
|
|
1642
|
+
expect(runningReadAgents.size).toBe(0);
|
|
1643
|
+
expect(pendingChildController.trackedParentCount()).toBe(0);
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
it.each([
|
|
1647
|
+
{ label: "non-opted-in write-feature", role: "write" as const, modelSlot: "write-feature", thinking: "medium" as const, delegationDepth: 0, allowNestedReadAgents: false },
|
|
1648
|
+
{ label: "opted-in write-patch", role: "write" as const, modelSlot: "write-patch", thinking: "high" as const, delegationDepth: 0, allowNestedReadAgents: true },
|
|
1649
|
+
{ label: "opted-in write-system", role: "write" as const, modelSlot: "write-system", thinking: "xhigh" as const, delegationDepth: 0, allowNestedReadAgents: true },
|
|
1650
|
+
{ label: "depth-1 read child", role: "read" as const, modelSlot: "read-review", thinking: "high" as const, delegationDepth: 1, allowNestedReadAgents: true },
|
|
1651
|
+
])("does not assign delegation tools to $label sessions", async ({ label, role, modelSlot, thinking, delegationDepth, allowNestedReadAgents }) => {
|
|
1652
|
+
const session = makeSession();
|
|
1653
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1654
|
+
piMocks.loaderExtensions.push({
|
|
1655
|
+
tools: new Map([
|
|
1656
|
+
["spawn_agent", { definition: { name: "spawn_agent", description: "untrusted external spawn" } }],
|
|
1657
|
+
["spawn_swarm_agents", { definition: { name: "spawn_swarm_agents", description: "untrusted external swarm" } }],
|
|
1658
|
+
]),
|
|
1659
|
+
});
|
|
1660
|
+
const createNestedReadAgentTools = vi.fn(() => [
|
|
1661
|
+
{ name: "spawn_agent", execute: vi.fn() },
|
|
1662
|
+
{ name: "spawn_swarm_agents", execute: vi.fn() },
|
|
1663
|
+
]);
|
|
1664
|
+
const member: Member = {
|
|
1665
|
+
agentId: `${label}@team`,
|
|
1666
|
+
name: label.replaceAll(" ", "-"),
|
|
1667
|
+
agentType: "teammate",
|
|
1668
|
+
role,
|
|
1669
|
+
model: "provider/model",
|
|
1670
|
+
thinking,
|
|
1671
|
+
modelSlot,
|
|
1672
|
+
joinedAt: Date.now(),
|
|
1673
|
+
tmuxPaneId: "",
|
|
1674
|
+
cwd: root,
|
|
1675
|
+
subscriptions: [],
|
|
1676
|
+
prompt: "bounded assignment",
|
|
1677
|
+
delegationDepth,
|
|
1678
|
+
allowNestedReadAgents,
|
|
1679
|
+
parentAgentName: delegationDepth === 1 ? "writer" : undefined,
|
|
1680
|
+
parentLifecycleRunId: delegationDepth === 1 ? "writer-run" : undefined,
|
|
1681
|
+
requestedBy: delegationDepth === 1 ? "writer" : undefined,
|
|
1682
|
+
helperKind: delegationDepth === 1 ? "read_helper" : undefined,
|
|
1683
|
+
};
|
|
1684
|
+
writeTeamConfig("team", member);
|
|
1685
|
+
const rememberCompletedAgentReport = vi.fn();
|
|
1686
|
+
|
|
1687
|
+
await runReadAgentInProcess("team", member, "bounded assignment", {
|
|
1688
|
+
cwd: root,
|
|
1689
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1690
|
+
}, {
|
|
1691
|
+
isTeammate: false,
|
|
1692
|
+
getTeamName: () => "team",
|
|
1693
|
+
runningReadAgents: new Map<string, RunningReadAgent>(),
|
|
1694
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1695
|
+
isCurrentReadAgentRun: () => true,
|
|
1696
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1697
|
+
renderReadAgentStatus: vi.fn(),
|
|
1698
|
+
rememberCompletedAgentReport,
|
|
1699
|
+
emitAgentReport: vi.fn(),
|
|
1700
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1701
|
+
createNestedReadAgentTools,
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
expect(createNestedReadAgentTools).not.toHaveBeenCalled();
|
|
1705
|
+
expect(rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1706
|
+
report: "final report",
|
|
1707
|
+
}));
|
|
1708
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1709
|
+
expect(sessionOptions.tools).not.toContain("spawn_agent");
|
|
1710
|
+
expect(sessionOptions.tools).not.toContain("spawn_swarm_agents");
|
|
1711
|
+
expect(sessionOptions.customTools.some((tool: any) => tool.name === "spawn_agent" || tool.name === "spawn_swarm_agents")).toBe(false);
|
|
1712
|
+
const promptText = piMocks.loaderOptions[0].appendSystemPrompt.join("\n");
|
|
1713
|
+
expect(promptText).not.toContain("opted-in depth-0 write-feature/write-critical run");
|
|
1714
|
+
if (delegationDepth === 1) {
|
|
1715
|
+
expect(promptText).toContain(`depth-1 read helper requested by 'writer'`);
|
|
1716
|
+
expect(promptText).toContain("report_and_exit deliverable goes to that requesting writer");
|
|
1717
|
+
expect(promptText).toContain("lead receives only a classified completion notice");
|
|
1718
|
+
expect(promptText).toContain("You cannot delegate");
|
|
1719
|
+
expect(promptText).not.toContain("send your concise report to the lead");
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
it("injects the complete writer coordination surface into nested write-agent sessions", async () => {
|
|
1724
|
+
const session = makeSession();
|
|
1725
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1726
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1727
|
+
const options = {
|
|
1728
|
+
isTeammate: false,
|
|
1729
|
+
getTeamName: () => "team",
|
|
1730
|
+
runningReadAgents,
|
|
1731
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1732
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1733
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1734
|
+
renderReadAgentStatus: vi.fn(),
|
|
1735
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
1736
|
+
emitAgentReport: vi.fn(),
|
|
1737
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
await runReadAgentInProcess("team", {
|
|
1741
|
+
agentId: "writer@team",
|
|
1742
|
+
name: "writer",
|
|
1743
|
+
agentType: "teammate",
|
|
1744
|
+
role: "write",
|
|
1745
|
+
model: "provider/model",
|
|
1746
|
+
thinking: "xhigh",
|
|
1747
|
+
modelSlot: "writing-hard",
|
|
1748
|
+
joinedAt: Date.now(),
|
|
1749
|
+
tmuxPaneId: "",
|
|
1750
|
+
cwd: root,
|
|
1751
|
+
subscriptions: [],
|
|
1752
|
+
prompt: "edit an isolated file",
|
|
1753
|
+
}, "edit an isolated file", {
|
|
1754
|
+
modelRegistry: {
|
|
1755
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
1756
|
+
},
|
|
1757
|
+
}, options);
|
|
1758
|
+
|
|
1759
|
+
expect(piMocks.createAgentSession).toHaveBeenCalledTimes(1);
|
|
1760
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1761
|
+
const communicationToolNames = [
|
|
1762
|
+
"send_message",
|
|
1763
|
+
"report_progress",
|
|
1764
|
+
"read_inbox",
|
|
1765
|
+
"claim_file",
|
|
1766
|
+
"release_file",
|
|
1767
|
+
"list_file_claims",
|
|
1768
|
+
"report_and_exit",
|
|
1769
|
+
];
|
|
1770
|
+
expect(sessionOptions.tools).toEqual([
|
|
1771
|
+
"read",
|
|
1772
|
+
"bash",
|
|
1773
|
+
"edit",
|
|
1774
|
+
"write",
|
|
1775
|
+
"grep",
|
|
1776
|
+
"find",
|
|
1777
|
+
"ls",
|
|
1778
|
+
...communicationToolNames,
|
|
1779
|
+
]);
|
|
1780
|
+
expect(sessionOptions.customTools.map((tool: any) => tool.name).sort()).toEqual([...communicationToolNames].sort());
|
|
1781
|
+
expect(options.emitAgentReport).toHaveBeenCalledWith("team", "writer", expect.any(Number), 42, "final report", true);
|
|
1782
|
+
expect(options.releaseAllClaimsForAgent).toHaveBeenCalledTimes(1);
|
|
1783
|
+
expect(session.abort).toHaveBeenCalledTimes(1);
|
|
1784
|
+
expect(session.dispose).toHaveBeenCalledTimes(1);
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
it("accepts only the first writer report and finalizes only the nested run", async () => {
|
|
1788
|
+
const session = makeSession();
|
|
1789
|
+
session.messages = [{ role: "assistant", content: "trailing assistant text" }];
|
|
1790
|
+
let firstResult: any;
|
|
1791
|
+
let duplicateResult: any;
|
|
1792
|
+
let lateSendError: unknown;
|
|
1793
|
+
session.prompt.mockImplementation(async () => {
|
|
1794
|
+
expect(runningReadAgents.get("team:writer")).toMatchObject({ modelSlot: "write-system" });
|
|
1795
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
1796
|
+
const tools = new Map<string, any>(sessionOptions.customTools.map((tool: any) => [tool.name, tool]));
|
|
1797
|
+
await tools.get("claim_file").execute("claim", { paths: ["./fixtures/writer.txt"] });
|
|
1798
|
+
firstResult = await tools.get("report_and_exit").execute("first", {
|
|
1799
|
+
content: "authoritative report",
|
|
1800
|
+
summary: "Authoritative summary",
|
|
1801
|
+
});
|
|
1802
|
+
lateSendError = await sendPlainMessageIfRunning("team", "other-agent", "writer", "late message", "Late")
|
|
1803
|
+
.then(() => undefined, error => error);
|
|
1804
|
+
duplicateResult = await tools.get("report_and_exit").execute("duplicate", {
|
|
1805
|
+
content: "duplicate report",
|
|
1806
|
+
summary: "Duplicate summary",
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
1810
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1811
|
+
const releaseAllClaimsForAgent = vi.fn(async (teamName: string, agentName: string) => {
|
|
1812
|
+
return claims.releaseAllForAgent(teamName, agentName);
|
|
1813
|
+
});
|
|
1814
|
+
const options = {
|
|
1815
|
+
isTeammate: false,
|
|
1816
|
+
getTeamName: () => "different-team",
|
|
1817
|
+
runningReadAgents,
|
|
1818
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1819
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1820
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1821
|
+
renderReadAgentStatus: vi.fn(),
|
|
1822
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
1823
|
+
emitAgentReport: vi.fn(),
|
|
1824
|
+
releaseAllClaimsForAgent,
|
|
1825
|
+
};
|
|
1826
|
+
const writer: Member = {
|
|
1827
|
+
agentId: "writer@team",
|
|
1828
|
+
name: "writer",
|
|
1829
|
+
agentType: "teammate",
|
|
1830
|
+
role: "write",
|
|
1831
|
+
model: "provider/model",
|
|
1832
|
+
thinking: "xhigh",
|
|
1833
|
+
modelSlot: "writing-hard",
|
|
1834
|
+
joinedAt: Date.now(),
|
|
1835
|
+
tmuxPaneId: "",
|
|
1836
|
+
cwd: root,
|
|
1837
|
+
subscriptions: [],
|
|
1838
|
+
prompt: "edit an isolated file",
|
|
1839
|
+
};
|
|
1840
|
+
writeTeamConfig("team", writer);
|
|
1841
|
+
const leadShutdown = vi.fn();
|
|
1842
|
+
const terminalKill = vi.fn();
|
|
1843
|
+
|
|
1844
|
+
await runReadAgentInProcess("team", writer, "edit an isolated file", {
|
|
1845
|
+
modelRegistry: {
|
|
1846
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
1847
|
+
},
|
|
1848
|
+
shutdown: leadShutdown,
|
|
1849
|
+
terminal: { kill: terminalKill },
|
|
1850
|
+
}, options);
|
|
1851
|
+
|
|
1852
|
+
expect(firstResult.details).toEqual({
|
|
1853
|
+
session: "team",
|
|
1854
|
+
accepted: true,
|
|
1855
|
+
cancelledDeliveries: 0,
|
|
1856
|
+
deliveryOutcome: "none",
|
|
1857
|
+
});
|
|
1858
|
+
expect(lateSendError).toEqual(expect.objectContaining({ message: expect.stringContaining("lifecycle-quarantined") }));
|
|
1859
|
+
expect(await readInbox("team", "writer", false, false)).toEqual([]);
|
|
1860
|
+
expect(duplicateResult.details).toEqual({ session: "team", accepted: false });
|
|
1861
|
+
expect(options.rememberCompletedAgentReport).toHaveBeenCalledTimes(1);
|
|
1862
|
+
expect(options.rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
1863
|
+
status: "completed",
|
|
1864
|
+
report: "authoritative report",
|
|
1865
|
+
summary: "Authoritative summary",
|
|
1866
|
+
modelSlot: "write-system",
|
|
1867
|
+
}));
|
|
1868
|
+
expect(options.emitAgentReport).not.toHaveBeenCalled();
|
|
1869
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
1870
|
+
expect(leadInbox).toHaveLength(1);
|
|
1871
|
+
expect(leadInbox[0]).toMatchObject({ text: "authoritative report", summary: "Authoritative summary" });
|
|
1872
|
+
expect(leadInbox[0].metadata).toMatchObject({
|
|
1873
|
+
finalReport: true,
|
|
1874
|
+
model: "provider/model",
|
|
1875
|
+
thinking: "xhigh",
|
|
1876
|
+
modelSlot: "write-system",
|
|
1877
|
+
initialPrompt: "edit an isolated file",
|
|
1878
|
+
});
|
|
1879
|
+
expect(releaseAllClaimsForAgent).toHaveBeenCalledTimes(1);
|
|
1880
|
+
expect(await claims.listClaims("team")).toEqual([]);
|
|
1881
|
+
expect(session.abort).toHaveBeenCalledTimes(1);
|
|
1882
|
+
expect(session.dispose).toHaveBeenCalledTimes(1);
|
|
1883
|
+
expect(leadShutdown).not.toHaveBeenCalled();
|
|
1884
|
+
expect(terminalKill).not.toHaveBeenCalled();
|
|
1885
|
+
expect(runningReadAgents.size).toBe(0);
|
|
1886
|
+
expect(JSON.parse(fs.readFileSync(paths.configPath("team"), "utf-8")).members.map((item: Member) => item.name))
|
|
1887
|
+
.toEqual(["team-lead"]);
|
|
1888
|
+
|
|
1889
|
+
const reports = await listTeamReportEvents("team");
|
|
1890
|
+
expect(reports).toHaveLength(1);
|
|
1891
|
+
expect(reports[0]).toMatchObject({
|
|
1892
|
+
agentName: "writer",
|
|
1893
|
+
role: "write",
|
|
1894
|
+
status: "completed",
|
|
1895
|
+
report: "authoritative report",
|
|
1896
|
+
summary: "Authoritative summary",
|
|
1897
|
+
source: "read-agent",
|
|
1898
|
+
modelSlot: "write-system",
|
|
1899
|
+
metadata: { initialPrompt: "edit an isolated file", modelSlot: "write-system" },
|
|
1900
|
+
});
|
|
1901
|
+
expect(JSON.stringify({ state: options.rememberCompletedAgentReport.mock.calls, leadInbox, reports })).not.toContain("writing-hard");
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
it("keeps stop-before-create quarantined until the late started session finishes shutdown", async () => {
|
|
1905
|
+
vi.useFakeTimers();
|
|
1906
|
+
try {
|
|
1907
|
+
let resolveCreation!: (value: { session: any }) => void;
|
|
1908
|
+
const creation = new Promise<{ session: any }>((resolve) => { resolveCreation = resolve; });
|
|
1909
|
+
let markCreationStarted!: () => void;
|
|
1910
|
+
const creationStarted = new Promise<void>((resolve) => { markCreationStarted = resolve; });
|
|
1911
|
+
piMocks.createAgentSession.mockImplementation(() => {
|
|
1912
|
+
markCreationStarted();
|
|
1913
|
+
return creation;
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
const order: string[] = [];
|
|
1917
|
+
const session = makeSession();
|
|
1918
|
+
session.hasExtensionHandlers.mockReturnValue(true);
|
|
1919
|
+
session.bindExtensions.mockImplementation(async () => { order.push("session_start"); });
|
|
1920
|
+
session.extensionRunner.emit.mockImplementation(async () => { order.push("session_shutdown"); });
|
|
1921
|
+
let resolveAbort!: () => void;
|
|
1922
|
+
const abort = new Promise<void>((resolve) => { resolveAbort = resolve; });
|
|
1923
|
+
session.abort.mockImplementation(() => abort);
|
|
1924
|
+
|
|
1925
|
+
const reader: Member = {
|
|
1926
|
+
agentId: "reader@team",
|
|
1927
|
+
name: "reader",
|
|
1928
|
+
agentType: "teammate",
|
|
1929
|
+
role: "read",
|
|
1930
|
+
model: "provider/model",
|
|
1931
|
+
thinking: "high",
|
|
1932
|
+
modelSlot: "reading-default",
|
|
1933
|
+
joinedAt: Date.now(),
|
|
1934
|
+
tmuxPaneId: "",
|
|
1935
|
+
cwd: root,
|
|
1936
|
+
subscriptions: [],
|
|
1937
|
+
prompt: "investigate",
|
|
1938
|
+
};
|
|
1939
|
+
writeTeamConfig("team", reader);
|
|
1940
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
1941
|
+
const releaseAllClaimsForAgent = vi.fn(async () => []);
|
|
1942
|
+
const options = {
|
|
1943
|
+
isTeammate: false,
|
|
1944
|
+
getTeamName: () => "team",
|
|
1945
|
+
runningReadAgents,
|
|
1946
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1947
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
1948
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
1949
|
+
renderReadAgentStatus: vi.fn(),
|
|
1950
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
1951
|
+
emitAgentReport: vi.fn(),
|
|
1952
|
+
releaseAllClaimsForAgent,
|
|
1953
|
+
};
|
|
1954
|
+
const run = runReadAgentInProcess("team", reader, "investigate", {
|
|
1955
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
1956
|
+
}, options);
|
|
1957
|
+
await creationStarted;
|
|
1958
|
+
const state = runningReadAgents.get("team:reader")!;
|
|
1959
|
+
expect(state.startupState).toBe("pending");
|
|
1960
|
+
|
|
1961
|
+
const lifecycle = createLifecycleRuntime({
|
|
1962
|
+
isTeammate: false,
|
|
1963
|
+
terminal: null,
|
|
1964
|
+
runningReadAgents,
|
|
1965
|
+
readAgentKey: options.readAgentKey,
|
|
1966
|
+
isCurrentReadAgentRun: options.isCurrentReadAgentRun,
|
|
1967
|
+
renderReadAgentStatus: options.renderReadAgentStatus,
|
|
1968
|
+
releaseAllClaimsForAgent,
|
|
1969
|
+
drainWriteQueue: vi.fn(async () => {}),
|
|
1970
|
+
getSessionCwd: () => root,
|
|
1971
|
+
getTeamName: () => "team",
|
|
1972
|
+
});
|
|
1973
|
+
const stopping = lifecycle.shutdownTeammate("team", reader);
|
|
1974
|
+
await vi.waitFor(() => {
|
|
1975
|
+
const persisted = JSON.parse(fs.readFileSync(paths.configPath("team"), "utf-8"));
|
|
1976
|
+
expect(persisted.members.find((item: Member) => item.name === "reader")?.isActive).toBe(false);
|
|
1977
|
+
});
|
|
1978
|
+
expect(state.stopRequested).toBe(true);
|
|
1979
|
+
expect(state.acceptingMessages).toBe(false);
|
|
1980
|
+
expect(session.prompt).not.toHaveBeenCalled();
|
|
1981
|
+
expect(releaseAllClaimsForAgent).not.toHaveBeenCalled();
|
|
1982
|
+
|
|
1983
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
1984
|
+
resolveCreation({ session });
|
|
1985
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1986
|
+
expect(order).toEqual(["session_start", "session_shutdown"]);
|
|
1987
|
+
expect(session.prompt).not.toHaveBeenCalled();
|
|
1988
|
+
expect(session.abort).toHaveBeenCalledOnce();
|
|
1989
|
+
expect(session.dispose).not.toHaveBeenCalled();
|
|
1990
|
+
expect(releaseAllClaimsForAgent).not.toHaveBeenCalled();
|
|
1991
|
+
|
|
1992
|
+
await vi.advanceTimersByTimeAsync(NESTED_SESSION_TEARDOWN_TIMEOUT_MS - 1000);
|
|
1993
|
+
await expect(stopping).resolves.toMatchObject({
|
|
1994
|
+
status: "timed_out",
|
|
1995
|
+
abort: "timed_out",
|
|
1996
|
+
dispose: "deferred",
|
|
1997
|
+
});
|
|
1998
|
+
expect(state.teardownState).toBe("quarantined");
|
|
1999
|
+
expect(runningReadAgents.get("team:reader")).toBe(state);
|
|
2000
|
+
expect(session.dispose).not.toHaveBeenCalled();
|
|
2001
|
+
expect(releaseAllClaimsForAgent).not.toHaveBeenCalled();
|
|
2002
|
+
|
|
2003
|
+
resolveAbort();
|
|
2004
|
+
await state.teardownFinalizationPromise;
|
|
2005
|
+
await run;
|
|
2006
|
+
expect(session.dispose).toHaveBeenCalledOnce();
|
|
2007
|
+
expect(releaseAllClaimsForAgent).toHaveBeenCalledOnce();
|
|
2008
|
+
expect(runningReadAgents.has("team:reader")).toBe(false);
|
|
2009
|
+
} finally {
|
|
2010
|
+
vi.useRealTimers();
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
it("accepts report_and_exit without aborting or waiting for a stuck direct delivery", async () => {
|
|
2015
|
+
vi.useFakeTimers();
|
|
2016
|
+
try {
|
|
2017
|
+
const session = makeSession();
|
|
2018
|
+
let settleRawDelivery!: () => void;
|
|
2019
|
+
const rawDelivery = new Promise<void>((resolve) => { settleRawDelivery = resolve; });
|
|
2020
|
+
let markDeliveryStarted!: () => void;
|
|
2021
|
+
const deliveryStarted = new Promise<void>((resolve) => { markDeliveryStarted = resolve; });
|
|
2022
|
+
session.sendUserMessage.mockImplementation(() => {
|
|
2023
|
+
markDeliveryStarted();
|
|
2024
|
+
return rawDelivery;
|
|
2025
|
+
});
|
|
2026
|
+
let releasePrompt!: () => void;
|
|
2027
|
+
const promptRelease = new Promise<void>((resolve) => { releasePrompt = resolve; });
|
|
2028
|
+
let reportSubmitted!: () => void;
|
|
2029
|
+
const submitted = new Promise<void>((resolve) => { reportSubmitted = resolve; });
|
|
2030
|
+
let abortStarted!: () => void;
|
|
2031
|
+
const aborting = new Promise<void>((resolve) => { abortStarted = resolve; });
|
|
2032
|
+
session.abort.mockImplementation(async () => { abortStarted(); });
|
|
2033
|
+
|
|
2034
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2035
|
+
let reportResult: any;
|
|
2036
|
+
let deliveryOutcome: Promise<unknown> | undefined;
|
|
2037
|
+
session.prompt.mockImplementation(async () => {
|
|
2038
|
+
const state = runningReadAgents.get("team:writer")!;
|
|
2039
|
+
deliveryOutcome = sendMessageToRunningReadAgent(state, "Queued scope update")
|
|
2040
|
+
.catch((error: Error) => error);
|
|
2041
|
+
await deliveryStarted;
|
|
2042
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
2043
|
+
const reportTool = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
2044
|
+
reportResult = await reportTool.execute("report", {
|
|
2045
|
+
content: "authoritative report",
|
|
2046
|
+
summary: "Done",
|
|
2047
|
+
});
|
|
2048
|
+
reportSubmitted();
|
|
2049
|
+
await promptRelease;
|
|
2050
|
+
});
|
|
2051
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2052
|
+
const writer: Member = {
|
|
2053
|
+
agentId: "writer@team",
|
|
2054
|
+
name: "writer",
|
|
2055
|
+
agentType: "teammate",
|
|
2056
|
+
role: "write",
|
|
2057
|
+
model: "provider/model",
|
|
2058
|
+
thinking: "xhigh",
|
|
2059
|
+
modelSlot: "writing-hard",
|
|
2060
|
+
joinedAt: Date.now(),
|
|
2061
|
+
tmuxPaneId: "",
|
|
2062
|
+
cwd: root,
|
|
2063
|
+
subscriptions: [],
|
|
2064
|
+
prompt: "edit an isolated file",
|
|
2065
|
+
};
|
|
2066
|
+
writeTeamConfig("team", writer);
|
|
2067
|
+
const options = {
|
|
2068
|
+
isTeammate: false,
|
|
2069
|
+
getTeamName: () => "team",
|
|
2070
|
+
runningReadAgents,
|
|
2071
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2072
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2073
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2074
|
+
renderReadAgentStatus: vi.fn(),
|
|
2075
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2076
|
+
emitAgentReport: vi.fn(),
|
|
2077
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2078
|
+
};
|
|
2079
|
+
|
|
2080
|
+
const run = runReadAgentInProcess("team", writer, "edit an isolated file", {
|
|
2081
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
2082
|
+
}, options);
|
|
2083
|
+
await submitted;
|
|
2084
|
+
|
|
2085
|
+
expect(reportResult.details).toMatchObject({
|
|
2086
|
+
accepted: true,
|
|
2087
|
+
cancelledDeliveries: 1,
|
|
2088
|
+
deliveryOutcome: "cancelled",
|
|
2089
|
+
});
|
|
2090
|
+
expect(session.abort).not.toHaveBeenCalled();
|
|
2091
|
+
await expect(deliveryOutcome).resolves.toEqual(expect.objectContaining({
|
|
2092
|
+
message: expect.stringContaining("was cancelled"),
|
|
2093
|
+
}));
|
|
2094
|
+
|
|
2095
|
+
releasePrompt();
|
|
2096
|
+
await aborting;
|
|
2097
|
+
await vi.advanceTimersByTimeAsync(2500);
|
|
2098
|
+
await run;
|
|
2099
|
+
const quarantined = runningReadAgents.get("team:writer")!;
|
|
2100
|
+
expect(quarantined.teardownState).toBe("quarantined");
|
|
2101
|
+
expect(session.dispose).not.toHaveBeenCalled();
|
|
2102
|
+
|
|
2103
|
+
settleRawDelivery();
|
|
2104
|
+
await quarantined.teardownFinalizationPromise;
|
|
2105
|
+
expect(session.dispose).toHaveBeenCalledOnce();
|
|
2106
|
+
expect(runningReadAgents.size).toBe(0);
|
|
2107
|
+
} finally {
|
|
2108
|
+
vi.useRealTimers();
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("does not accept or dispose a nested run when persisted report admission cannot close", async () => {
|
|
2113
|
+
const session = makeSession();
|
|
2114
|
+
let reportError: unknown;
|
|
2115
|
+
session.prompt.mockImplementation(async () => {
|
|
2116
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
2117
|
+
const reportTool = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
2118
|
+
reportError = await reportTool.execute("report", {
|
|
2119
|
+
content: "must not be accepted",
|
|
2120
|
+
summary: "Rejected report",
|
|
2121
|
+
}).then(() => undefined, (error: unknown) => error);
|
|
2122
|
+
});
|
|
2123
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2124
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2125
|
+
const options = {
|
|
2126
|
+
isTeammate: false,
|
|
2127
|
+
getTeamName: () => "team",
|
|
2128
|
+
runningReadAgents,
|
|
2129
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2130
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2131
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2132
|
+
renderReadAgentStatus: vi.fn(),
|
|
2133
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2134
|
+
emitAgentReport: vi.fn(),
|
|
2135
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2136
|
+
};
|
|
2137
|
+
const writer: Member = {
|
|
2138
|
+
agentId: "writer@team",
|
|
2139
|
+
name: "writer",
|
|
2140
|
+
agentType: "teammate",
|
|
2141
|
+
role: "write",
|
|
2142
|
+
model: "provider/model",
|
|
2143
|
+
thinking: "xhigh",
|
|
2144
|
+
modelSlot: "writing-hard",
|
|
2145
|
+
joinedAt: Date.now(),
|
|
2146
|
+
tmuxPaneId: "",
|
|
2147
|
+
cwd: root,
|
|
2148
|
+
subscriptions: [],
|
|
2149
|
+
prompt: "edit an isolated file",
|
|
2150
|
+
isActive: true,
|
|
2151
|
+
};
|
|
2152
|
+
writeTeamConfig("team", writer);
|
|
2153
|
+
vi.spyOn(teams, "updateMember").mockRejectedValue(new Error("config lock unavailable"));
|
|
2154
|
+
vi.spyOn(teams, "removeMemberMatchingRun").mockRejectedValue(new Error("config removal unavailable"));
|
|
2155
|
+
|
|
2156
|
+
await expect(runReadAgentInProcess("team", writer, "edit an isolated file", {
|
|
2157
|
+
modelRegistry: {
|
|
2158
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2159
|
+
},
|
|
2160
|
+
}, options)).rejects.toThrow("Could not close message admission for writer in team");
|
|
2161
|
+
|
|
2162
|
+
expect(reportError).toEqual(expect.objectContaining({
|
|
2163
|
+
message: expect.stringContaining("Could not close message admission for writer in team"),
|
|
2164
|
+
}));
|
|
2165
|
+
expect((await teams.readConfig("team")).members.find(member => member.name === "writer")?.isActive).toBe(true);
|
|
2166
|
+
expect(options.rememberCompletedAgentReport).not.toHaveBeenCalled();
|
|
2167
|
+
expect(options.emitAgentReport).not.toHaveBeenCalled();
|
|
2168
|
+
expect(session.abort).not.toHaveBeenCalled();
|
|
2169
|
+
expect(session.dispose).not.toHaveBeenCalled();
|
|
2170
|
+
expect(runningReadAgents.has("team:writer")).toBe(true);
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
it("reports unexpected writer errors as failures even after report submission", async () => {
|
|
2174
|
+
const session = makeSession();
|
|
2175
|
+
session.hasExtensionHandlers.mockReturnValue(true);
|
|
2176
|
+
let reportResult: any;
|
|
2177
|
+
session.prompt.mockImplementation(async () => {
|
|
2178
|
+
expect(runningReadAgents.get("team:writer")).toMatchObject({ modelSlot: "write-system" });
|
|
2179
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
2180
|
+
const reportTool = sessionOptions.customTools.find((tool: any) => tool.name === "report_and_exit");
|
|
2181
|
+
reportResult = await reportTool.execute("report", {
|
|
2182
|
+
content: "submitted before failure",
|
|
2183
|
+
summary: "Submitted summary",
|
|
2184
|
+
});
|
|
2185
|
+
throw new Error("unexpected provider failure");
|
|
2186
|
+
});
|
|
2187
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2188
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2189
|
+
const releaseAllClaimsForAgent = vi.fn(async (teamName: string, agentName: string) => {
|
|
2190
|
+
return claims.releaseAllForAgent(teamName, agentName);
|
|
2191
|
+
});
|
|
2192
|
+
const options = {
|
|
2193
|
+
isTeammate: false,
|
|
2194
|
+
getTeamName: () => "different-team",
|
|
2195
|
+
runningReadAgents,
|
|
2196
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2197
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2198
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2199
|
+
renderReadAgentStatus: vi.fn(),
|
|
2200
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2201
|
+
emitAgentReport: vi.fn(),
|
|
2202
|
+
releaseAllClaimsForAgent,
|
|
2203
|
+
};
|
|
2204
|
+
const writer: Member = {
|
|
2205
|
+
agentId: "writer@team",
|
|
2206
|
+
name: "writer",
|
|
2207
|
+
agentType: "teammate",
|
|
2208
|
+
role: "write",
|
|
2209
|
+
model: "provider/model",
|
|
2210
|
+
thinking: "xhigh",
|
|
2211
|
+
modelSlot: "writing-hard",
|
|
2212
|
+
joinedAt: Date.now(),
|
|
2213
|
+
tmuxPaneId: "",
|
|
2214
|
+
cwd: root,
|
|
2215
|
+
subscriptions: [],
|
|
2216
|
+
prompt: "edit an isolated file",
|
|
2217
|
+
};
|
|
2218
|
+
writeTeamConfig("team", writer);
|
|
2219
|
+
const leadShutdown = vi.fn();
|
|
2220
|
+
const terminalKill = vi.fn();
|
|
2221
|
+
|
|
2222
|
+
await runReadAgentInProcess("team", writer, "edit an isolated file", {
|
|
2223
|
+
modelRegistry: {
|
|
2224
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2225
|
+
},
|
|
2226
|
+
shutdown: leadShutdown,
|
|
2227
|
+
terminal: { kill: terminalKill },
|
|
2228
|
+
}, options);
|
|
2229
|
+
|
|
2230
|
+
expect(reportResult.details).toEqual({
|
|
2231
|
+
session: "team",
|
|
2232
|
+
accepted: true,
|
|
2233
|
+
cancelledDeliveries: 0,
|
|
2234
|
+
deliveryOutcome: "none",
|
|
2235
|
+
});
|
|
2236
|
+
expect(options.rememberCompletedAgentReport).toHaveBeenCalledTimes(1);
|
|
2237
|
+
expect(options.rememberCompletedAgentReport).toHaveBeenCalledWith("team", expect.objectContaining({
|
|
2238
|
+
status: "failed",
|
|
2239
|
+
report: "Edit agent writer failed: unexpected provider failure",
|
|
2240
|
+
modelSlot: "write-system",
|
|
2241
|
+
}));
|
|
2242
|
+
expect(options.emitAgentReport).not.toHaveBeenCalled();
|
|
2243
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
2244
|
+
expect(leadInbox).toHaveLength(1);
|
|
2245
|
+
expect(leadInbox[0].metadata).toMatchObject({
|
|
2246
|
+
finalReport: true,
|
|
2247
|
+
model: "provider/model",
|
|
2248
|
+
thinking: "xhigh",
|
|
2249
|
+
modelSlot: "write-system",
|
|
2250
|
+
initialPrompt: "edit an isolated file",
|
|
2251
|
+
});
|
|
2252
|
+
expect(releaseAllClaimsForAgent).toHaveBeenCalledTimes(1);
|
|
2253
|
+
expect(session.extensionRunner.emit).toHaveBeenCalledOnce();
|
|
2254
|
+
expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
|
|
2255
|
+
expect(session.abort).toHaveBeenCalledTimes(1);
|
|
2256
|
+
expect(session.dispose).toHaveBeenCalledTimes(1);
|
|
2257
|
+
expect(leadShutdown).not.toHaveBeenCalled();
|
|
2258
|
+
expect(terminalKill).not.toHaveBeenCalled();
|
|
2259
|
+
expect(runningReadAgents.size).toBe(0);
|
|
2260
|
+
|
|
2261
|
+
const reports = await listTeamReportEvents("team");
|
|
2262
|
+
expect(reports).toHaveLength(1);
|
|
2263
|
+
expect(reports[0]).toMatchObject({
|
|
2264
|
+
agentName: "writer",
|
|
2265
|
+
role: "write",
|
|
2266
|
+
status: "failed",
|
|
2267
|
+
report: "Edit agent writer failed: unexpected provider failure",
|
|
2268
|
+
source: "read-agent",
|
|
2269
|
+
modelSlot: "write-system",
|
|
2270
|
+
metadata: { initialPrompt: "edit an isolated file", modelSlot: "write-system" },
|
|
2271
|
+
});
|
|
2272
|
+
expect(JSON.stringify({ state: options.rememberCompletedAgentReport.mock.calls, leadInbox, reports })).not.toContain("writing-hard");
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
it("preserves the full per-update assistant snippet while processing text deltas incrementally", () => {
|
|
2276
|
+
const state = {
|
|
2277
|
+
runId: "run-reader",
|
|
2278
|
+
name: "reader",
|
|
2279
|
+
teamName: "team",
|
|
2280
|
+
startedAt: 0,
|
|
2281
|
+
tokensUsed: 0,
|
|
2282
|
+
status: "thinking",
|
|
2283
|
+
recentEvents: [],
|
|
2284
|
+
lastActivityAt: 0,
|
|
2285
|
+
} as RunningReadAgent;
|
|
2286
|
+
const session = { getSessionStats: () => ({ tokens: { total: 42 } }) } as any;
|
|
2287
|
+
const renderReadAgentStatus = vi.fn();
|
|
2288
|
+
const expectedSnippet = (text: string) => {
|
|
2289
|
+
const sanitized = sanitizeTuiLine(text).trim();
|
|
2290
|
+
return sanitized.length > 180 ? `…${sanitized.slice(-179)}` : sanitized;
|
|
2291
|
+
};
|
|
2292
|
+
|
|
2293
|
+
let firstPart = "";
|
|
2294
|
+
handleReadAgentSessionEvent(state, session, {
|
|
2295
|
+
type: "message_start",
|
|
2296
|
+
message: { role: "assistant", content: [{ type: "text", text: firstPart }] },
|
|
2297
|
+
}, renderReadAgentStatus);
|
|
2298
|
+
|
|
2299
|
+
for (const delta of ["Alpha\t", "beta\n", "gamma ", "x".repeat(2_500)]) {
|
|
2300
|
+
firstPart += delta;
|
|
2301
|
+
const message = { role: "assistant", content: [{ type: "text", text: firstPart }] };
|
|
2302
|
+
handleReadAgentSessionEvent(state, session, {
|
|
2303
|
+
type: "message_update",
|
|
2304
|
+
message,
|
|
2305
|
+
assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta, partial: message },
|
|
2306
|
+
}, renderReadAgentStatus);
|
|
2307
|
+
expect(state.latestAssistantSnippet).toBe(expectedSnippet(firstPart));
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
let secondPart = "";
|
|
2311
|
+
let message = {
|
|
2312
|
+
role: "assistant",
|
|
2313
|
+
content: [{ type: "text", text: firstPart }, { type: "text", text: secondPart }],
|
|
2314
|
+
};
|
|
2315
|
+
handleReadAgentSessionEvent(state, session, {
|
|
2316
|
+
type: "message_update",
|
|
2317
|
+
message,
|
|
2318
|
+
assistantMessageEvent: { type: "text_start", contentIndex: 1, partial: message },
|
|
2319
|
+
}, renderReadAgentStatus);
|
|
2320
|
+
secondPart = "second part";
|
|
2321
|
+
message = {
|
|
2322
|
+
role: "assistant",
|
|
2323
|
+
content: [{ type: "text", text: firstPart }, { type: "text", text: secondPart }],
|
|
2324
|
+
};
|
|
2325
|
+
handleReadAgentSessionEvent(state, session, {
|
|
2326
|
+
type: "message_update",
|
|
2327
|
+
message,
|
|
2328
|
+
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: secondPart, partial: message },
|
|
2329
|
+
}, renderReadAgentStatus);
|
|
2330
|
+
expect(state.latestAssistantSnippet).toBe(expectedSnippet(`${firstPart}\n${secondPart}`));
|
|
2331
|
+
|
|
2332
|
+
secondPart += "\x1b[2K\runsafe tail";
|
|
2333
|
+
message = {
|
|
2334
|
+
role: "assistant",
|
|
2335
|
+
content: [{ type: "text", text: firstPart }, { type: "text", text: secondPart }],
|
|
2336
|
+
};
|
|
2337
|
+
handleReadAgentSessionEvent(state, session, {
|
|
2338
|
+
type: "message_update",
|
|
2339
|
+
message,
|
|
2340
|
+
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "\x1b[2K\runsafe tail", partial: message },
|
|
2341
|
+
}, renderReadAgentStatus);
|
|
2342
|
+
expect(state.latestAssistantSnippet).toBe(expectedSnippet(`${firstPart}\n${secondPart}`));
|
|
2343
|
+
expect(state.tokensUsed).toBe(42);
|
|
2344
|
+
expect(renderReadAgentStatus).toHaveBeenCalledTimes(7);
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
it("ignores pre-response estimates and adopts provider context usage on the first measured update", () => {
|
|
2348
|
+
const state = {
|
|
2349
|
+
runId: "run-reader",
|
|
2350
|
+
name: "reader",
|
|
2351
|
+
teamName: "team",
|
|
2352
|
+
startedAt: 0,
|
|
2353
|
+
tokensUsed: 0,
|
|
2354
|
+
status: "thinking",
|
|
2355
|
+
recentEvents: [],
|
|
2356
|
+
lastActivityAt: 0,
|
|
2357
|
+
} as RunningReadAgent;
|
|
2358
|
+
let tokensUsed = 0;
|
|
2359
|
+
let contextTokens = 597;
|
|
2360
|
+
let contextPercent = 0.3;
|
|
2361
|
+
const session = {
|
|
2362
|
+
messages: [{ role: "user", content: "prompt" }],
|
|
2363
|
+
getSessionStats: vi.fn(() => ({
|
|
2364
|
+
tokens: { total: tokensUsed },
|
|
2365
|
+
contextUsage: { tokens: contextTokens, contextWindow: 200_000, percent: contextPercent },
|
|
2366
|
+
})),
|
|
2367
|
+
} as any;
|
|
2368
|
+
const renderReadAgentStatus = vi.fn();
|
|
2369
|
+
const update = { type: "message_update", message: { role: "toolResult", content: "partial" } };
|
|
2370
|
+
|
|
2371
|
+
handleReadAgentSessionEvent(state, session, update, renderReadAgentStatus);
|
|
2372
|
+
expect(state.tokensUsed).toBe(0);
|
|
2373
|
+
expect(state.contextUsage).toEqual({ tokens: 0, contextWindow: 200_000, percent: 0 });
|
|
2374
|
+
tokensUsed = 2_300_000;
|
|
2375
|
+
contextTokens = 80_000;
|
|
2376
|
+
contextPercent = 40;
|
|
2377
|
+
handleReadAgentSessionEvent(state, session, update, renderReadAgentStatus);
|
|
2378
|
+
|
|
2379
|
+
expect(session.getSessionStats).toHaveBeenCalledTimes(2);
|
|
2380
|
+
expect(state.tokensUsed).toBe(2_300_000);
|
|
2381
|
+
expect(state.contextUsage).toEqual({ tokens: 80_000, contextWindow: 200_000, percent: 40 });
|
|
2382
|
+
expect(renderReadAgentStatus).toHaveBeenCalledTimes(2);
|
|
2383
|
+
});
|
|
2384
|
+
|
|
2385
|
+
it("emits normalized progress without resetting tool-working status for non-assistant message updates", async () => {
|
|
2386
|
+
let subscriber: ((event: any) => void) | undefined;
|
|
2387
|
+
const session = makeSession();
|
|
2388
|
+
session.subscribe.mockImplementation((callback: (event: any) => void) => {
|
|
2389
|
+
subscriber = callback;
|
|
2390
|
+
});
|
|
2391
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2392
|
+
session.prompt.mockImplementation(async () => {
|
|
2393
|
+
const sessionOptions = piMocks.createAgentSession.mock.calls[0][0];
|
|
2394
|
+
const progressTool = sessionOptions.customTools.find((tool: any) => tool.name === "report_progress");
|
|
2395
|
+
await progressTool.execute("progress", { status: " Inspecting\n event handling " });
|
|
2396
|
+
expect(runningReadAgents.get("team:reader")).toMatchObject({
|
|
2397
|
+
status: "thinking",
|
|
2398
|
+
latestProgress: "Inspecting event handling",
|
|
2399
|
+
progressUpdatedAt: expect.any(Number),
|
|
2400
|
+
});
|
|
2401
|
+
|
|
2402
|
+
subscriber?.({ type: "tool_execution_start", toolName: "bash" });
|
|
2403
|
+
subscriber?.({ type: "message_update", message: { role: "toolResult", content: "not assistant text" } });
|
|
2404
|
+
expect(runningReadAgents.get("team:reader")).toMatchObject({
|
|
2405
|
+
status: "working",
|
|
2406
|
+
activeToolName: "bash",
|
|
2407
|
+
latestProgress: "Inspecting event handling",
|
|
2408
|
+
});
|
|
2409
|
+
subscriber?.({ type: "tool_execution_end", toolName: "bash" });
|
|
2410
|
+
expect(runningReadAgents.get("team:reader")).toMatchObject({
|
|
2411
|
+
status: "thinking",
|
|
2412
|
+
latestProgress: "Inspecting event handling",
|
|
2413
|
+
});
|
|
2414
|
+
});
|
|
2415
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2416
|
+
const options = {
|
|
2417
|
+
isTeammate: false,
|
|
2418
|
+
getTeamName: () => "team",
|
|
2419
|
+
runningReadAgents,
|
|
2420
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2421
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2422
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2423
|
+
renderReadAgentStatus: vi.fn(),
|
|
2424
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2425
|
+
emitAgentReport: vi.fn(),
|
|
2426
|
+
emitAgentProgress: vi.fn(),
|
|
2427
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2428
|
+
};
|
|
2429
|
+
|
|
2430
|
+
await runReadAgentInProcess("team", {
|
|
2431
|
+
agentId: "reader@team",
|
|
2432
|
+
name: "reader",
|
|
2433
|
+
agentType: "teammate",
|
|
2434
|
+
role: "read",
|
|
2435
|
+
model: "provider/model",
|
|
2436
|
+
thinking: "high",
|
|
2437
|
+
modelSlot: "reading-default",
|
|
2438
|
+
joinedAt: Date.now(),
|
|
2439
|
+
tmuxPaneId: "",
|
|
2440
|
+
cwd: root,
|
|
2441
|
+
subscriptions: [],
|
|
2442
|
+
prompt: "investigate",
|
|
2443
|
+
}, "investigate", {
|
|
2444
|
+
modelRegistry: {
|
|
2445
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2446
|
+
},
|
|
2447
|
+
}, options);
|
|
2448
|
+
|
|
2449
|
+
expect(options.emitAgentProgress).toHaveBeenCalledWith("team", "reader", "Inspecting event handling", expect.any(Number));
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
it("emits prompt-build reports even when prompt-build is not the adopted lead team", async () => {
|
|
2453
|
+
const session = makeSession();
|
|
2454
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2455
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2456
|
+
const options = {
|
|
2457
|
+
isTeammate: false,
|
|
2458
|
+
getTeamName: () => "active-user-team",
|
|
2459
|
+
runningReadAgents,
|
|
2460
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2461
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2462
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2463
|
+
renderReadAgentStatus: vi.fn(),
|
|
2464
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2465
|
+
emitAgentReport: vi.fn(),
|
|
2466
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2467
|
+
};
|
|
2468
|
+
|
|
2469
|
+
await runReadAgentInProcess("prompt-build-123", {
|
|
2470
|
+
agentId: "prompt-branch-1@prompt-build-123",
|
|
2471
|
+
name: "prompt-branch-1",
|
|
2472
|
+
agentType: "teammate",
|
|
2473
|
+
role: "read",
|
|
2474
|
+
model: "provider/model",
|
|
2475
|
+
thinking: "high",
|
|
2476
|
+
modelSlot: "reading-default",
|
|
2477
|
+
joinedAt: Date.now(),
|
|
2478
|
+
tmuxPaneId: "",
|
|
2479
|
+
cwd: root,
|
|
2480
|
+
subscriptions: [],
|
|
2481
|
+
prompt: "build prompt options",
|
|
2482
|
+
}, "build prompt options", {
|
|
2483
|
+
modelRegistry: {
|
|
2484
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2485
|
+
},
|
|
2486
|
+
}, options);
|
|
2487
|
+
|
|
2488
|
+
expect(options.emitAgentReport).toHaveBeenCalledWith("prompt-build-123", "prompt-branch-1", expect.any(Number), 42, "final report", true);
|
|
2489
|
+
const leadInbox = await readInbox("prompt-build-123", "team-lead", false, false);
|
|
2490
|
+
expect(leadInbox).toEqual([]);
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
it("emits a private pi-prompt writer report without requesting lead injection", async () => {
|
|
2494
|
+
const session = makeSession();
|
|
2495
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2496
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2497
|
+
const options = {
|
|
2498
|
+
isTeammate: false,
|
|
2499
|
+
getTeamName: () => "session-main",
|
|
2500
|
+
runningReadAgents,
|
|
2501
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2502
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2503
|
+
ensureReadAgentStatusTicker: vi.fn(), renderReadAgentStatus: vi.fn(), rememberCompletedAgentReport: vi.fn(),
|
|
2504
|
+
emitAgentReport: vi.fn(), releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2505
|
+
};
|
|
2506
|
+
|
|
2507
|
+
await runReadAgentInProcess("session-main", {
|
|
2508
|
+
agentId: "planner@session-main", name: "planner", agentType: "teammate", role: "write",
|
|
2509
|
+
model: "provider/model", thinking: "high", modelSlot: "writing-basic", joinedAt: Date.now(),
|
|
2510
|
+
tmuxPaneId: "", cwd: root, subscriptions: [], prompt: "write plan",
|
|
2511
|
+
metadata: { piPromptPlanning: { version: 1, correlation: "private" } },
|
|
2512
|
+
}, "write plan", { modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) } }, options);
|
|
2513
|
+
|
|
2514
|
+
expect(options.emitAgentReport).toHaveBeenCalledWith("session-main", "planner", expect.any(Number), 42, "final report", true, true);
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
it("suppresses lead report injection for workflow-spawned read agents while persisting report events", async () => {
|
|
2518
|
+
const session = makeSession();
|
|
2519
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2520
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2521
|
+
const options = {
|
|
2522
|
+
isTeammate: false,
|
|
2523
|
+
getTeamName: () => "team",
|
|
2524
|
+
runningReadAgents,
|
|
2525
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2526
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2527
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2528
|
+
renderReadAgentStatus: vi.fn(),
|
|
2529
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2530
|
+
emitAgentReport: vi.fn(),
|
|
2531
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2532
|
+
};
|
|
2533
|
+
|
|
2534
|
+
await runReadAgentInProcess("team", {
|
|
2535
|
+
agentId: "workflow-reader@team",
|
|
2536
|
+
name: "workflow-reader",
|
|
2537
|
+
agentType: "teammate",
|
|
2538
|
+
role: "read",
|
|
2539
|
+
model: "provider/model",
|
|
2540
|
+
thinking: "high",
|
|
2541
|
+
modelSlot: "reading-default",
|
|
2542
|
+
joinedAt: Date.now(),
|
|
2543
|
+
tmuxPaneId: "",
|
|
2544
|
+
cwd: root,
|
|
2545
|
+
subscriptions: [],
|
|
2546
|
+
prompt: "workflow branch",
|
|
2547
|
+
metadata: { operationId: "op-1", workflowRunId: "run-1" },
|
|
2548
|
+
}, "workflow branch", {
|
|
2549
|
+
modelRegistry: {
|
|
2550
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2551
|
+
},
|
|
2552
|
+
}, options);
|
|
2553
|
+
|
|
2554
|
+
expect(options.emitAgentReport).not.toHaveBeenCalled();
|
|
2555
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
2556
|
+
expect(leadInbox).toEqual([]);
|
|
2557
|
+
|
|
2558
|
+
const reports = await listTeamReportEvents("team");
|
|
2559
|
+
expect(reports).toHaveLength(1);
|
|
2560
|
+
expect(reports[0]).toMatchObject({
|
|
2561
|
+
agentName: "workflow-reader",
|
|
2562
|
+
status: "completed",
|
|
2563
|
+
report: "final report",
|
|
2564
|
+
operationId: "op-1",
|
|
2565
|
+
workflowRunId: "run-1",
|
|
2566
|
+
source: "read-agent",
|
|
2567
|
+
});
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
it("suppresses failed workflow read-agent injection while persisting failure events", async () => {
|
|
2571
|
+
const session = makeSession();
|
|
2572
|
+
session.prompt.mockRejectedValue(new Error("branch failed"));
|
|
2573
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2574
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2575
|
+
const options = {
|
|
2576
|
+
isTeammate: false,
|
|
2577
|
+
getTeamName: () => "team",
|
|
2578
|
+
runningReadAgents,
|
|
2579
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2580
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2581
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2582
|
+
renderReadAgentStatus: vi.fn(),
|
|
2583
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2584
|
+
emitAgentReport: vi.fn(),
|
|
2585
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2586
|
+
};
|
|
2587
|
+
|
|
2588
|
+
await runReadAgentInProcess("team", {
|
|
2589
|
+
agentId: "workflow-reader@team",
|
|
2590
|
+
name: "workflow-reader",
|
|
2591
|
+
agentType: "teammate",
|
|
2592
|
+
role: "read",
|
|
2593
|
+
model: "provider/model",
|
|
2594
|
+
thinking: "high",
|
|
2595
|
+
modelSlot: "reading-default",
|
|
2596
|
+
joinedAt: Date.now(),
|
|
2597
|
+
tmuxPaneId: "",
|
|
2598
|
+
cwd: root,
|
|
2599
|
+
subscriptions: [],
|
|
2600
|
+
prompt: "workflow branch",
|
|
2601
|
+
metadata: { orchestration: { operationId: "op-1", workflowRunId: "run-1" } },
|
|
2602
|
+
}, "workflow branch", {
|
|
2603
|
+
modelRegistry: {
|
|
2604
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2605
|
+
},
|
|
2606
|
+
}, options);
|
|
2607
|
+
|
|
2608
|
+
expect(options.emitAgentReport).not.toHaveBeenCalled();
|
|
2609
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
2610
|
+
expect(leadInbox).toEqual([]);
|
|
2611
|
+
|
|
2612
|
+
const reports = await listTeamReportEvents("team");
|
|
2613
|
+
expect(reports).toHaveLength(1);
|
|
2614
|
+
expect(reports[0]).toMatchObject({
|
|
2615
|
+
agentName: "workflow-reader",
|
|
2616
|
+
status: "failed",
|
|
2617
|
+
report: "Read agent workflow-reader failed: branch failed",
|
|
2618
|
+
operationId: "op-1",
|
|
2619
|
+
workflowRunId: "run-1",
|
|
2620
|
+
source: "read-agent",
|
|
2621
|
+
});
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
it("directly wakes an active helper requester even when the helper already persisted its report", async () => {
|
|
2625
|
+
const session = makeSession();
|
|
2626
|
+
session.prompt.mockImplementation(async () => {
|
|
2627
|
+
await sendPlainMessage("team", "writer-reader", "writer", "final report", "Read helper writer-reader report", "cyan");
|
|
2628
|
+
await sendPlainMessage("team", "writer-reader", "team-lead", "Read helper writer-reader completed for writer. Report sent to writer.", "Read helper writer-reader done", "cyan");
|
|
2629
|
+
});
|
|
2630
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2631
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2632
|
+
const options = {
|
|
2633
|
+
isTeammate: false,
|
|
2634
|
+
agentName: "team-lead",
|
|
2635
|
+
getTeamName: () => "team",
|
|
2636
|
+
runningReadAgents,
|
|
2637
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2638
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2639
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2640
|
+
renderReadAgentStatus: vi.fn(),
|
|
2641
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2642
|
+
emitAgentReport: vi.fn(),
|
|
2643
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2644
|
+
quietTrigger: vi.fn(),
|
|
2645
|
+
renderLeadInboxStatus: vi.fn(async () => {}),
|
|
2646
|
+
notifyLeadOfInboxReports: vi.fn(async () => {}),
|
|
2647
|
+
deliverMessageToActiveAgent: vi.fn(async () => true),
|
|
2648
|
+
};
|
|
2649
|
+
|
|
2650
|
+
await runReadAgentInProcess("team", {
|
|
2651
|
+
agentId: "writer-reader@team",
|
|
2652
|
+
name: "writer-reader",
|
|
2653
|
+
agentType: "teammate",
|
|
2654
|
+
role: "read",
|
|
2655
|
+
model: "provider/model",
|
|
2656
|
+
thinking: "high",
|
|
2657
|
+
modelSlot: "reading-default",
|
|
2658
|
+
joinedAt: Date.now(),
|
|
2659
|
+
tmuxPaneId: "",
|
|
2660
|
+
cwd: root,
|
|
2661
|
+
subscriptions: [],
|
|
2662
|
+
prompt: "investigate",
|
|
2663
|
+
color: "cyan",
|
|
2664
|
+
requestedBy: "writer",
|
|
2665
|
+
helperKind: "read_helper",
|
|
2666
|
+
}, "investigate", {
|
|
2667
|
+
modelRegistry: {
|
|
2668
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2669
|
+
},
|
|
2670
|
+
}, options);
|
|
2671
|
+
|
|
2672
|
+
const requesterInbox = await readInbox("team", "writer", false, false);
|
|
2673
|
+
expect(requesterInbox).toHaveLength(1);
|
|
2674
|
+
expect(requesterInbox[0]).toMatchObject({
|
|
2675
|
+
from: "writer-reader",
|
|
2676
|
+
text: "final report",
|
|
2677
|
+
summary: "Read helper writer-reader report",
|
|
2678
|
+
color: "cyan",
|
|
2679
|
+
read: false,
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
expect(options.deliverMessageToActiveAgent).toHaveBeenCalledWith("team", "writer", "final report");
|
|
2683
|
+
|
|
2684
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
2685
|
+
expect(leadInbox).toHaveLength(2);
|
|
2686
|
+
const classifiedNotice = leadInbox.find(message => message.metadata?.helperCompletion === true);
|
|
2687
|
+
expect(classifiedNotice).toMatchObject({
|
|
2688
|
+
from: "writer-reader",
|
|
2689
|
+
summary: "Read helper writer-reader done",
|
|
2690
|
+
color: "cyan",
|
|
2691
|
+
read: false,
|
|
2692
|
+
metadata: {
|
|
2693
|
+
finalReport: true,
|
|
2694
|
+
helperCompletion: true,
|
|
2695
|
+
outcome: "completed",
|
|
2696
|
+
requestedBy: "writer",
|
|
2697
|
+
},
|
|
2698
|
+
});
|
|
2699
|
+
expect(classifiedNotice?.text).toContain("Report sent to writer");
|
|
2700
|
+
expect(classifiedNotice?.text).not.toBe("final report");
|
|
2701
|
+
expect(options.emitAgentReport).not.toHaveBeenCalled();
|
|
2702
|
+
expect(options.quietTrigger).not.toHaveBeenCalled();
|
|
2703
|
+
expect(options.renderLeadInboxStatus).toHaveBeenCalled();
|
|
2704
|
+
expect(options.notifyLeadOfInboxReports).toHaveBeenCalledWith("team");
|
|
2705
|
+
|
|
2706
|
+
const promptText = piMocks.loaderOptions[0].appendSystemPrompt.join("\n");
|
|
2707
|
+
expect(promptText).toContain("depth-1 read helper requested by 'writer'");
|
|
2708
|
+
expect(promptText).toContain("report_and_exit deliverable goes to that requesting writer");
|
|
2709
|
+
expect(promptText).toContain("lead receives only a classified completion notice");
|
|
2710
|
+
expect(promptText).not.toContain("send your concise report to the lead and stop");
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
it("retains an old child report instead of delivering it to a replacement parent run", async () => {
|
|
2714
|
+
const helper: Member = {
|
|
2715
|
+
agentId: "stale-child@team",
|
|
2716
|
+
name: "stale-child",
|
|
2717
|
+
agentType: "teammate",
|
|
2718
|
+
role: "read",
|
|
2719
|
+
model: "provider/model",
|
|
2720
|
+
thinking: "high",
|
|
2721
|
+
modelSlot: "read-review",
|
|
2722
|
+
joinedAt: Date.now(),
|
|
2723
|
+
tmuxPaneId: "",
|
|
2724
|
+
cwd: root,
|
|
2725
|
+
subscriptions: [],
|
|
2726
|
+
prompt: "inspect the old parent assignment",
|
|
2727
|
+
requestedBy: "writer",
|
|
2728
|
+
helperKind: "read_helper",
|
|
2729
|
+
delegationDepth: 1,
|
|
2730
|
+
parentAgentName: "writer",
|
|
2731
|
+
parentLifecycleRunId: "writer-run-A",
|
|
2732
|
+
};
|
|
2733
|
+
const replacementParent: Member = {
|
|
2734
|
+
agentId: "writer@team",
|
|
2735
|
+
name: "writer",
|
|
2736
|
+
agentType: "teammate",
|
|
2737
|
+
role: "write",
|
|
2738
|
+
model: "provider/model",
|
|
2739
|
+
thinking: "medium",
|
|
2740
|
+
modelSlot: "write-feature",
|
|
2741
|
+
joinedAt: Date.now(),
|
|
2742
|
+
tmuxPaneId: "",
|
|
2743
|
+
cwd: root,
|
|
2744
|
+
subscriptions: [],
|
|
2745
|
+
lifecycleRunId: "writer-run-B",
|
|
2746
|
+
delegationDepth: 0,
|
|
2747
|
+
allowNestedReadAgents: true,
|
|
2748
|
+
isActive: true,
|
|
2749
|
+
};
|
|
2750
|
+
writeTeamConfig("team", helper, [replacementParent]);
|
|
2751
|
+
const session = makeSession();
|
|
2752
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2753
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2754
|
+
const options = {
|
|
2755
|
+
isTeammate: false,
|
|
2756
|
+
agentName: "team-lead",
|
|
2757
|
+
getTeamName: () => "team",
|
|
2758
|
+
runningReadAgents,
|
|
2759
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2760
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2761
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2762
|
+
renderReadAgentStatus: vi.fn(),
|
|
2763
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2764
|
+
emitAgentReport: vi.fn(),
|
|
2765
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2766
|
+
renderLeadInboxStatus: vi.fn(async () => {}),
|
|
2767
|
+
notifyLeadOfInboxReports: vi.fn(async () => {}),
|
|
2768
|
+
deliverMessageToActiveAgent: vi.fn(async () => false),
|
|
2769
|
+
};
|
|
2770
|
+
|
|
2771
|
+
await runReadAgentInProcess("team", helper, helper.prompt!, {
|
|
2772
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
2773
|
+
}, options);
|
|
2774
|
+
|
|
2775
|
+
expect(options.deliverMessageToActiveAgent).toHaveBeenCalledWith(
|
|
2776
|
+
"team",
|
|
2777
|
+
"writer",
|
|
2778
|
+
"final report",
|
|
2779
|
+
"writer-run-A"
|
|
2780
|
+
);
|
|
2781
|
+
expect(await readInbox("team", "writer", false, false)).toEqual([]);
|
|
2782
|
+
expect((await listTeamReportEvents("team")).filter(event => event.agentName === "stale-child"))
|
|
2783
|
+
.toEqual([expect.objectContaining({ status: "completed", report: "final report" })]);
|
|
2784
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
2785
|
+
expect(leadInbox).toHaveLength(1);
|
|
2786
|
+
expect(leadInbox[0].metadata).toMatchObject({
|
|
2787
|
+
finalReport: true,
|
|
2788
|
+
helperCompletion: true,
|
|
2789
|
+
requestedBy: "writer",
|
|
2790
|
+
});
|
|
2791
|
+
expect(leadInbox[0].text).toContain("writer is no longer running; the report is retained here");
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
it("uses a fallback delivery if a read helper exits without sending its required report", async () => {
|
|
2795
|
+
writeTeamConfig("team", {
|
|
2796
|
+
agentId: "writer-reader@team",
|
|
2797
|
+
name: "writer-reader",
|
|
2798
|
+
agentType: "teammate",
|
|
2799
|
+
role: "read",
|
|
2800
|
+
model: "provider/model",
|
|
2801
|
+
thinking: "high",
|
|
2802
|
+
modelSlot: "reading-default",
|
|
2803
|
+
joinedAt: Date.now(),
|
|
2804
|
+
tmuxPaneId: "",
|
|
2805
|
+
cwd: root,
|
|
2806
|
+
subscriptions: [],
|
|
2807
|
+
prompt: "investigate",
|
|
2808
|
+
color: "cyan",
|
|
2809
|
+
requestedBy: "writer",
|
|
2810
|
+
helperKind: "read_helper",
|
|
2811
|
+
}, [{
|
|
2812
|
+
agentId: "writer@team",
|
|
2813
|
+
name: "writer",
|
|
2814
|
+
agentType: "teammate",
|
|
2815
|
+
role: "write",
|
|
2816
|
+
model: "provider/model",
|
|
2817
|
+
joinedAt: Date.now(),
|
|
2818
|
+
tmuxPaneId: "%writer",
|
|
2819
|
+
cwd: root,
|
|
2820
|
+
subscriptions: [],
|
|
2821
|
+
isActive: true,
|
|
2822
|
+
}]);
|
|
2823
|
+
const session = makeSession();
|
|
2824
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
2825
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2826
|
+
const options = {
|
|
2827
|
+
isTeammate: false,
|
|
2828
|
+
agentName: "team-lead",
|
|
2829
|
+
getTeamName: () => "team",
|
|
2830
|
+
runningReadAgents,
|
|
2831
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2832
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2833
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2834
|
+
renderReadAgentStatus: vi.fn(),
|
|
2835
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2836
|
+
emitAgentReport: vi.fn(),
|
|
2837
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2838
|
+
quietTrigger: vi.fn(),
|
|
2839
|
+
renderLeadInboxStatus: vi.fn(async () => {}),
|
|
2840
|
+
notifyLeadOfInboxReports: vi.fn(async () => {}),
|
|
2841
|
+
};
|
|
2842
|
+
|
|
2843
|
+
await runReadAgentInProcess("team", {
|
|
2844
|
+
agentId: "writer-reader@team",
|
|
2845
|
+
name: "writer-reader",
|
|
2846
|
+
agentType: "teammate",
|
|
2847
|
+
role: "read",
|
|
2848
|
+
model: "provider/model",
|
|
2849
|
+
thinking: "high",
|
|
2850
|
+
modelSlot: "reading-default",
|
|
2851
|
+
joinedAt: Date.now(),
|
|
2852
|
+
tmuxPaneId: "",
|
|
2853
|
+
cwd: root,
|
|
2854
|
+
subscriptions: [],
|
|
2855
|
+
prompt: "investigate",
|
|
2856
|
+
color: "cyan",
|
|
2857
|
+
requestedBy: "writer",
|
|
2858
|
+
helperKind: "read_helper",
|
|
2859
|
+
}, "investigate", {
|
|
2860
|
+
modelRegistry: {
|
|
2861
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
2862
|
+
},
|
|
2863
|
+
}, options);
|
|
2864
|
+
|
|
2865
|
+
const requesterInbox = await readInbox("team", "writer", false, false);
|
|
2866
|
+
expect(requesterInbox).toHaveLength(1);
|
|
2867
|
+
expect(requesterInbox[0]).toMatchObject({
|
|
2868
|
+
from: "writer-reader",
|
|
2869
|
+
text: "final report",
|
|
2870
|
+
summary: "Read helper writer-reader report",
|
|
2871
|
+
color: "cyan",
|
|
2872
|
+
read: false,
|
|
2873
|
+
});
|
|
2874
|
+
|
|
2875
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
2876
|
+
expect(leadInbox).toHaveLength(1);
|
|
2877
|
+
expect(leadInbox[0]).toMatchObject({
|
|
2878
|
+
metadata: {
|
|
2879
|
+
finalReport: true,
|
|
2880
|
+
helperCompletion: true,
|
|
2881
|
+
outcome: "completed",
|
|
2882
|
+
requestedBy: "writer",
|
|
2883
|
+
},
|
|
2884
|
+
});
|
|
2885
|
+
expect(leadInbox[0].text).toContain("Report sent to writer");
|
|
2886
|
+
expect(options.notifyLeadOfInboxReports).toHaveBeenCalledWith("team");
|
|
2887
|
+
});
|
|
2888
|
+
|
|
2889
|
+
it("keeps rapid same-name helper notices and tmux reports isolated by run id", async () => {
|
|
2890
|
+
const helper: Member = {
|
|
2891
|
+
agentId: "reused-helper@team",
|
|
2892
|
+
name: "reused-helper",
|
|
2893
|
+
agentType: "teammate",
|
|
2894
|
+
role: "read",
|
|
2895
|
+
model: "provider/model",
|
|
2896
|
+
thinking: "high",
|
|
2897
|
+
modelSlot: "reading-default",
|
|
2898
|
+
joinedAt: Date.now(),
|
|
2899
|
+
tmuxPaneId: "",
|
|
2900
|
+
cwd: root,
|
|
2901
|
+
subscriptions: [],
|
|
2902
|
+
prompt: "investigate",
|
|
2903
|
+
color: "cyan",
|
|
2904
|
+
requestedBy: "writer",
|
|
2905
|
+
helperKind: "read_helper",
|
|
2906
|
+
isActive: true,
|
|
2907
|
+
};
|
|
2908
|
+
const writer: Member = {
|
|
2909
|
+
agentId: "writer@team",
|
|
2910
|
+
name: "writer",
|
|
2911
|
+
agentType: "teammate",
|
|
2912
|
+
role: "write",
|
|
2913
|
+
model: "provider/model",
|
|
2914
|
+
joinedAt: Date.now(),
|
|
2915
|
+
tmuxPaneId: "%writer",
|
|
2916
|
+
cwd: root,
|
|
2917
|
+
subscriptions: [],
|
|
2918
|
+
isActive: true,
|
|
2919
|
+
};
|
|
2920
|
+
writeTeamConfig("team", helper, [writer]);
|
|
2921
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
2922
|
+
const notifyLeadOfInboxReports = vi.fn(async () => {});
|
|
2923
|
+
const options = {
|
|
2924
|
+
isTeammate: false,
|
|
2925
|
+
agentName: "team-lead",
|
|
2926
|
+
getTeamName: () => "team",
|
|
2927
|
+
runningReadAgents,
|
|
2928
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
2929
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
2930
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
2931
|
+
renderReadAgentStatus: vi.fn(),
|
|
2932
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
2933
|
+
emitAgentReport: vi.fn(),
|
|
2934
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
2935
|
+
renderLeadInboxStatus: vi.fn(async () => {}),
|
|
2936
|
+
notifyLeadOfInboxReports,
|
|
2937
|
+
deliverMessageToActiveAgent: vi.fn(async () => false),
|
|
2938
|
+
};
|
|
2939
|
+
|
|
2940
|
+
const firstSession = makeSession();
|
|
2941
|
+
piMocks.createAgentSession.mockResolvedValueOnce({ session: firstSession });
|
|
2942
|
+
await runReadAgentInProcess("team", helper, "investigate", {
|
|
2943
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
2944
|
+
}, options);
|
|
2945
|
+
const firstNotices = await readInbox("team", "team-lead", false, false);
|
|
2946
|
+
expect(firstNotices).toHaveLength(1);
|
|
2947
|
+
const firstRunId = firstNotices[0].metadata?.runId;
|
|
2948
|
+
expect(firstRunId).toEqual(expect.any(String));
|
|
2949
|
+
await readInbox("team", "team-lead", true, true);
|
|
2950
|
+
|
|
2951
|
+
await teams.addMember("team", { ...helper, joinedAt: Date.now(), isActive: true });
|
|
2952
|
+
const secondSession = makeSession();
|
|
2953
|
+
secondSession.prompt.mockRejectedValue(new Error("second run failed"));
|
|
2954
|
+
piMocks.createAgentSession.mockResolvedValueOnce({ session: secondSession });
|
|
2955
|
+
await runReadAgentInProcess("team", { ...helper, joinedAt: Date.now(), isActive: true }, "investigate", {
|
|
2956
|
+
modelRegistry: { find: vi.fn(() => ({ provider: "provider", id: "model" })) },
|
|
2957
|
+
}, options);
|
|
2958
|
+
|
|
2959
|
+
const classifiedNotices = (await readInbox("team", "team-lead", false, false))
|
|
2960
|
+
.filter(message => message.metadata?.helperCompletion === true);
|
|
2961
|
+
expect(classifiedNotices).toHaveLength(2);
|
|
2962
|
+
expect(classifiedNotices.map(message => message.metadata?.outcome)).toEqual(["completed", "failed"]);
|
|
2963
|
+
expect(classifiedNotices[1].metadata?.runId).toEqual(expect.any(String));
|
|
2964
|
+
expect(classifiedNotices[1].metadata?.runId).not.toBe(firstRunId);
|
|
2965
|
+
const unreadLead = await readInbox("team", "team-lead", true, false);
|
|
2966
|
+
expect(unreadLead).toHaveLength(1);
|
|
2967
|
+
expect(unreadLead[0].metadata?.runId).toBe(classifiedNotices[1].metadata?.runId);
|
|
2968
|
+
expect(notifyLeadOfInboxReports).toHaveBeenCalledTimes(2);
|
|
2969
|
+
|
|
2970
|
+
const requesterReports = await readInbox("team", "writer", false, false);
|
|
2971
|
+
expect(requesterReports).toHaveLength(2);
|
|
2972
|
+
expect(requesterReports.map(message => message.metadata?.outcome)).toEqual(["completed", "failed"]);
|
|
2973
|
+
expect(new Set(requesterReports.map(message => message.metadata?.runId)).size).toBe(2);
|
|
2974
|
+
});
|
|
2975
|
+
|
|
2976
|
+
it("publishes a rejected prompt failure while the helper remains live during deferred delivery", async () => {
|
|
2977
|
+
const helper: Member = {
|
|
2978
|
+
agentId: "failing-helper@team",
|
|
2979
|
+
name: "failing-helper",
|
|
2980
|
+
agentType: "teammate",
|
|
2981
|
+
role: "read",
|
|
2982
|
+
model: "provider/model",
|
|
2983
|
+
thinking: "high",
|
|
2984
|
+
modelSlot: "reading-default",
|
|
2985
|
+
joinedAt: Date.now(),
|
|
2986
|
+
tmuxPaneId: "",
|
|
2987
|
+
cwd: root,
|
|
2988
|
+
subscriptions: [],
|
|
2989
|
+
prompt: "investigate",
|
|
2990
|
+
color: "cyan",
|
|
2991
|
+
requestedBy: "writer",
|
|
2992
|
+
helperKind: "read_helper",
|
|
2993
|
+
};
|
|
2994
|
+
writeTeamConfig("team", helper, [{
|
|
2995
|
+
agentId: "writer@team",
|
|
2996
|
+
name: "writer",
|
|
2997
|
+
agentType: "teammate",
|
|
2998
|
+
role: "write",
|
|
2999
|
+
model: "provider/model",
|
|
3000
|
+
joinedAt: Date.now(),
|
|
3001
|
+
tmuxPaneId: "%writer",
|
|
3002
|
+
cwd: root,
|
|
3003
|
+
subscriptions: [],
|
|
3004
|
+
isActive: true,
|
|
3005
|
+
}]);
|
|
3006
|
+
const session = makeSession();
|
|
3007
|
+
session.prompt.mockRejectedValue(new Error("source unavailable"));
|
|
3008
|
+
piMocks.createAgentSession.mockResolvedValue({ session });
|
|
3009
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
3010
|
+
const liveFailureRenders: RunningReadAgent[] = [];
|
|
3011
|
+
let markFailureDeliveryStarted!: () => void;
|
|
3012
|
+
const failureDeliveryStarted = new Promise<void>((resolve) => { markFailureDeliveryStarted = resolve; });
|
|
3013
|
+
let releaseFailureDelivery!: () => void;
|
|
3014
|
+
const failureDeliveryGate = new Promise<void>((resolve) => { releaseFailureDelivery = resolve; });
|
|
3015
|
+
const options = {
|
|
3016
|
+
isTeammate: false,
|
|
3017
|
+
agentName: "team-lead",
|
|
3018
|
+
getTeamName: () => "team",
|
|
3019
|
+
runningReadAgents,
|
|
3020
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
3021
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
3022
|
+
ensureReadAgentStatusTicker: vi.fn(),
|
|
3023
|
+
renderReadAgentStatus: vi.fn(() => {
|
|
3024
|
+
const state = runningReadAgents.get("team:failing-helper");
|
|
3025
|
+
if (state?.lastError) liveFailureRenders.push(state);
|
|
3026
|
+
}),
|
|
3027
|
+
rememberCompletedAgentReport: vi.fn(),
|
|
3028
|
+
emitAgentReport: vi.fn(),
|
|
3029
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
3030
|
+
quietTrigger: vi.fn(),
|
|
3031
|
+
renderLeadInboxStatus: vi.fn(async () => {}),
|
|
3032
|
+
notifyLeadOfInboxReports: vi.fn(async () => {}),
|
|
3033
|
+
deliverMessageToActiveAgent: vi.fn(async () => {
|
|
3034
|
+
markFailureDeliveryStarted();
|
|
3035
|
+
await failureDeliveryGate;
|
|
3036
|
+
return true;
|
|
3037
|
+
}),
|
|
3038
|
+
};
|
|
3039
|
+
|
|
3040
|
+
const run = runReadAgentInProcess("team", helper, "investigate", {
|
|
3041
|
+
modelRegistry: {
|
|
3042
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
3043
|
+
},
|
|
3044
|
+
}, options);
|
|
3045
|
+
await failureDeliveryStarted;
|
|
3046
|
+
|
|
3047
|
+
const liveFailureState = runningReadAgents.get("team:failing-helper");
|
|
3048
|
+
expect(session.prompt).toHaveBeenCalledWith("investigate", { source: "extension" });
|
|
3049
|
+
expect(liveFailureState?.lastError).toMatchObject({ message: "source unavailable" });
|
|
3050
|
+
expect(liveFailureState?.lastError?.timestamp).toEqual(expect.any(Number));
|
|
3051
|
+
expect(liveFailureRenders).toContain(liveFailureState);
|
|
3052
|
+
expect(runningReadAgents.get("team:failing-helper")).toBe(liveFailureState);
|
|
3053
|
+
|
|
3054
|
+
releaseFailureDelivery();
|
|
3055
|
+
await run;
|
|
3056
|
+
expect(runningReadAgents.has("team:failing-helper")).toBe(false);
|
|
3057
|
+
expect(options.deliverMessageToActiveAgent).toHaveBeenCalledWith(
|
|
3058
|
+
"team",
|
|
3059
|
+
"writer",
|
|
3060
|
+
"Read agent failing-helper failed: source unavailable"
|
|
3061
|
+
);
|
|
3062
|
+
expect(await readInbox("team", "writer", false, false)).toEqual([]);
|
|
3063
|
+
const leadInbox = await readInbox("team", "team-lead", false, false);
|
|
3064
|
+
expect(leadInbox).toHaveLength(1);
|
|
3065
|
+
expect(leadInbox[0]).toMatchObject({
|
|
3066
|
+
metadata: {
|
|
3067
|
+
finalReport: true,
|
|
3068
|
+
helperCompletion: true,
|
|
3069
|
+
outcome: "failed",
|
|
3070
|
+
requestedBy: "writer",
|
|
3071
|
+
},
|
|
3072
|
+
});
|
|
3073
|
+
expect(options.notifyLeadOfInboxReports).toHaveBeenCalledTimes(1);
|
|
3074
|
+
expect(options.notifyLeadOfInboxReports).toHaveBeenCalledWith("team");
|
|
3075
|
+
expect(options.quietTrigger).not.toHaveBeenCalled();
|
|
3076
|
+
});
|
|
3077
|
+
});
|