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,1924 @@
|
|
|
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 { CHILD_AGENT_LIFECYCLE_PROBE, registerTeamTools } from "./team-tools.js";
|
|
6
|
+
import { createLifecycleRuntime } from "../team/lifecycle.js";
|
|
7
|
+
import { NESTED_SESSION_TEARDOWN_TIMEOUT_MS } from "../agents/read-agent-session-lifecycle.js";
|
|
8
|
+
import * as paths from "../../src/utils/paths.js";
|
|
9
|
+
import * as teams from "../../src/utils/teams.js";
|
|
10
|
+
import * as writeQueue from "../../src/utils/write-queue.js";
|
|
11
|
+
import type { Member } from "../../src/utils/models.js";
|
|
12
|
+
import type { RunningReadAgent } from "../runtime/types.js";
|
|
13
|
+
import { ACCEPTED_FAVORITE_MODEL_SLOTS, FAVORITE_MODEL_SLOTS } from "../../src/utils/settings.js";
|
|
14
|
+
import { readLifecycleTombstone } from "../../src/utils/lifecycle-tombstone.js";
|
|
15
|
+
import { NESTED_READ_MODEL_SLOTS } from "../runtime/nested-read-agents.js";
|
|
16
|
+
import { closePersistedRecipient } from "../team/recipient-closure.js";
|
|
17
|
+
import { createPendingChildController } from "../runtime/pending-child-controller.js";
|
|
18
|
+
|
|
19
|
+
type RegisteredTool = {
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
parameters?: any;
|
|
23
|
+
execute: (toolCallId: string, params: any, signal: AbortSignal, onUpdate: any, ctx: any) => Promise<any>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let root: string;
|
|
27
|
+
let teamsRoot: string;
|
|
28
|
+
let tasksRoot: string;
|
|
29
|
+
|
|
30
|
+
function installPathSpies() {
|
|
31
|
+
vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName))));
|
|
32
|
+
vi.spyOn(paths, "taskDir").mockImplementation((teamName: unknown) => path.join(tasksRoot, paths.sanitizeName(String(teamName))));
|
|
33
|
+
vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "config.json"));
|
|
34
|
+
vi.spyOn(paths, "writeQueuePath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "write-queue.json"));
|
|
35
|
+
vi.spyOn(paths, "leadSessionPath").mockImplementation((teamName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lead-session.json"));
|
|
36
|
+
vi.spyOn(paths, "sessionContextReferencePath").mockImplementation((teamName: unknown, agentName: unknown, lifecycleRunId: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "session-context", `${paths.sanitizeName(String(agentName))}--${paths.sanitizeName(String(lifecycleRunId))}.md`));
|
|
37
|
+
vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName: unknown, agentName: unknown) => path.join(teamsRoot, paths.sanitizeName(String(teamName)), "lifecycle", "quarantine", `${paths.sanitizeName(String(agentName))}.json`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeProjectSettings(settings: unknown) {
|
|
41
|
+
fs.mkdirSync(path.join(root, ".pi"), { recursive: true });
|
|
42
|
+
fs.writeFileSync(path.join(root, ".pi", "pi-extended-teams.json"), JSON.stringify(settings));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeGlobalSettings(settings: unknown) {
|
|
46
|
+
const settingsPath = path.join(root, ".pi", "agent", "pi-extended-teams", "settings.json");
|
|
47
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
48
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeFavoriteLevels() {
|
|
52
|
+
writeGlobalSettings({
|
|
53
|
+
favoriteModels: {
|
|
54
|
+
"read-collect": { model: "provider/model", thinking: "high" },
|
|
55
|
+
"read-review": { model: "provider/model", thinking: "xhigh" },
|
|
56
|
+
"read-analyze": { model: "provider/model", thinking: "medium" },
|
|
57
|
+
"read-critical": { model: "provider/model", thinking: "xhigh" },
|
|
58
|
+
"write-patch": { model: "provider/model", thinking: "max" },
|
|
59
|
+
"write-feature": { model: "provider/model", thinking: "medium" },
|
|
60
|
+
"write-system": { model: "provider/model", thinking: "high" },
|
|
61
|
+
"write-critical": { model: "provider/model", thinking: "max" },
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeCtx() {
|
|
67
|
+
return {
|
|
68
|
+
cwd: root,
|
|
69
|
+
sessionManager: {
|
|
70
|
+
getSessionId: vi.fn(() => "test-session"),
|
|
71
|
+
buildContextEntries: vi.fn((): any[] => []),
|
|
72
|
+
},
|
|
73
|
+
model: { provider: "provider", id: "model" },
|
|
74
|
+
modelRegistry: {
|
|
75
|
+
getAvailable: vi.fn(async () => [{ provider: "provider", id: "model" }]),
|
|
76
|
+
find: vi.fn(() => ({ provider: "provider", id: "model" })),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function makeRunningAgent(teamName: string, member: Member): RunningReadAgent {
|
|
82
|
+
return {
|
|
83
|
+
runId: member.lifecycleRunId || `${member.name}-run`,
|
|
84
|
+
name: member.name,
|
|
85
|
+
teamName,
|
|
86
|
+
role: member.role,
|
|
87
|
+
startedAt: Date.now(),
|
|
88
|
+
tokensUsed: 0,
|
|
89
|
+
status: "thinking",
|
|
90
|
+
recentEvents: [],
|
|
91
|
+
lastActivityAt: Date.now(),
|
|
92
|
+
model: member.model,
|
|
93
|
+
thinking: member.thinking,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function registerTools() {
|
|
98
|
+
const tools = new Map<string, RegisteredTool>();
|
|
99
|
+
const eventHandlers = new Map<string, Array<(payload: any) => void>>();
|
|
100
|
+
const sessionHandlers = new Map<string, Array<(event: any) => void | Promise<void>>>();
|
|
101
|
+
const eventUnsubscribes = new Map<string, ReturnType<typeof vi.fn>>();
|
|
102
|
+
const runningReadAgents = new Map<string, RunningReadAgent>();
|
|
103
|
+
const completions = new Map<string, () => void>();
|
|
104
|
+
const readAgentKey = (teamName: string, agentName: string) => `${teamName}:${agentName}`;
|
|
105
|
+
const runReadAgentInProcess = vi.fn((teamName: string, member: Member, _prompt: string, _ctx: any, _options: any) => {
|
|
106
|
+
const key = readAgentKey(teamName, member.name);
|
|
107
|
+
runningReadAgents.set(key, makeRunningAgent(teamName, member));
|
|
108
|
+
return new Promise<void>((resolve) => {
|
|
109
|
+
completions.set(member.name, () => {
|
|
110
|
+
runningReadAgents.delete(key);
|
|
111
|
+
void teams.removeMember(teamName, member.name).catch(() => {}).finally(resolve);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
const adoptTeamAsLead = vi.fn();
|
|
116
|
+
const piEventEmit = vi.fn();
|
|
117
|
+
const shutdownTeammate = vi.fn(async (teamName: string, member: Member) => {
|
|
118
|
+
runningReadAgents.delete(readAgentKey(teamName, member.name));
|
|
119
|
+
await teams.removeMember(teamName, member.name).catch(() => {});
|
|
120
|
+
return {
|
|
121
|
+
status: "settled" as const,
|
|
122
|
+
reason: "quit" as const,
|
|
123
|
+
extensionShutdown: "no_handlers" as const,
|
|
124
|
+
abort: "unavailable" as const,
|
|
125
|
+
delivery: "settled" as const,
|
|
126
|
+
dispose: "settled" as const,
|
|
127
|
+
cancelledDeliveries: 0,
|
|
128
|
+
persistenceClosed: true,
|
|
129
|
+
finalized: true,
|
|
130
|
+
removedMember: true,
|
|
131
|
+
releasedClaims: [],
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const sessionCtx = makeCtx();
|
|
136
|
+
const pendingChildController = createPendingChildController();
|
|
137
|
+
const teamToolsRuntime = registerTeamTools({
|
|
138
|
+
registerTool: (tool: RegisteredTool) => tools.set(tool.name, tool),
|
|
139
|
+
on: vi.fn((name: string, handler: (event: any) => void | Promise<void>) => {
|
|
140
|
+
sessionHandlers.set(name, [...(sessionHandlers.get(name) ?? []), handler]);
|
|
141
|
+
}),
|
|
142
|
+
events: {
|
|
143
|
+
on: vi.fn((name: string, handler: (payload: any) => void) => {
|
|
144
|
+
eventHandlers.set(name, [...(eventHandlers.get(name) ?? []), handler]);
|
|
145
|
+
const unsubscribe = vi.fn(() => {
|
|
146
|
+
eventHandlers.set(name, (eventHandlers.get(name) ?? []).filter((candidate) => candidate !== handler));
|
|
147
|
+
});
|
|
148
|
+
eventUnsubscribes.set(name, unsubscribe);
|
|
149
|
+
return unsubscribe;
|
|
150
|
+
}),
|
|
151
|
+
emit: piEventEmit,
|
|
152
|
+
},
|
|
153
|
+
}, {
|
|
154
|
+
terminal: null,
|
|
155
|
+
runningReadAgents,
|
|
156
|
+
readAgentKey,
|
|
157
|
+
isCurrentReadAgentRun: (key: string, state: RunningReadAgent) => runningReadAgents.get(key) === state,
|
|
158
|
+
renderReadAgentStatus: vi.fn(),
|
|
159
|
+
readAgentOptions: vi.fn(() => ({})),
|
|
160
|
+
runReadAgentInProcess,
|
|
161
|
+
startWriteAgent: vi.fn(async () => "%1"),
|
|
162
|
+
shutdownTeammate,
|
|
163
|
+
adoptTeamAsLead,
|
|
164
|
+
buildRoster: vi.fn(async () => ({})),
|
|
165
|
+
isTeammate: false,
|
|
166
|
+
agentName: "team-lead",
|
|
167
|
+
getTeamName: () => "session-test-session",
|
|
168
|
+
getSessionCtx: () => sessionCtx,
|
|
169
|
+
pendingChildController,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const emit = (name: string, payload: any) => {
|
|
173
|
+
for (const handler of eventHandlers.get(name) ?? []) handler(payload);
|
|
174
|
+
};
|
|
175
|
+
const emitAsync = async (name: string, payload: any) => {
|
|
176
|
+
await Promise.all((eventHandlers.get(name) ?? []).map((handler) => handler(payload)));
|
|
177
|
+
};
|
|
178
|
+
const shutdown = async (reason = "reload") => {
|
|
179
|
+
for (const handler of sessionHandlers.get("session_shutdown") ?? []) await handler({ reason });
|
|
180
|
+
};
|
|
181
|
+
return { tools, teamToolsRuntime, pendingChildController, runningReadAgents, completions, runReadAgentInProcess, adoptTeamAsLead, shutdownTeammate, emit, emitAsync, piEventEmit, shutdown, eventUnsubscribes, sessionCtx };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function admitNestedReadParent(
|
|
185
|
+
harness: ReturnType<typeof registerTools>,
|
|
186
|
+
overrides: Partial<Member> = {}
|
|
187
|
+
): Promise<Member> {
|
|
188
|
+
const teamName = "session-test-session";
|
|
189
|
+
if (!teams.teamExists(teamName)) {
|
|
190
|
+
teams.createTeam(teamName, "test-session", "lead-agent", "Pi session agents", "provider/model");
|
|
191
|
+
}
|
|
192
|
+
const parent: Member = {
|
|
193
|
+
agentId: `writer@${teamName}`,
|
|
194
|
+
name: "writer",
|
|
195
|
+
agentType: "teammate",
|
|
196
|
+
role: "write",
|
|
197
|
+
model: "provider/model",
|
|
198
|
+
thinking: "medium",
|
|
199
|
+
modelSlot: "write-feature",
|
|
200
|
+
joinedAt: Date.now(),
|
|
201
|
+
tmuxPaneId: "",
|
|
202
|
+
cwd: root,
|
|
203
|
+
subscriptions: [],
|
|
204
|
+
delegationDepth: 0,
|
|
205
|
+
allowNestedReadAgents: true,
|
|
206
|
+
...overrides,
|
|
207
|
+
};
|
|
208
|
+
await teams.addMember(teamName, parent);
|
|
209
|
+
harness.runningReadAgents.set(`${teamName}:${parent.name}`, makeRunningAgent(teamName, parent));
|
|
210
|
+
return parent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
describe("public agent spawn tools", () => {
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-public-agents-"));
|
|
216
|
+
teamsRoot = path.join(root, "teams");
|
|
217
|
+
tasksRoot = path.join(root, "tasks");
|
|
218
|
+
fs.mkdirSync(teamsRoot, { recursive: true });
|
|
219
|
+
fs.mkdirSync(tasksRoot, { recursive: true });
|
|
220
|
+
installPathSpies();
|
|
221
|
+
vi.spyOn(os, "homedir").mockReturnValue(root);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
vi.restoreAllMocks();
|
|
226
|
+
if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("registers only the public spawn tools from team-tools", () => {
|
|
230
|
+
const { tools } = registerTools();
|
|
231
|
+
|
|
232
|
+
expect(Array.from(tools.keys()).sort()).toEqual(["spawn_agent", "spawn_swarm_agents"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("publishes all canonical intent tiers, compatibility aliases, and agent-friendly defaults", () => {
|
|
236
|
+
const { tools } = registerTools();
|
|
237
|
+
const spawn = tools.get("spawn_agent")!;
|
|
238
|
+
const swarm = tools.get("spawn_swarm_agents")!;
|
|
239
|
+
const spawnSlot = spawn.parameters.properties.model_slot;
|
|
240
|
+
const swarmDefaultSlot = swarm.parameters.properties.defaults.properties.model_slot;
|
|
241
|
+
const swarmAgentSlot = swarm.parameters.properties.agents.items.properties.model_slot;
|
|
242
|
+
const spawnNestedOptIn = spawn.parameters.properties.allow_nested_read_agents;
|
|
243
|
+
const swarmDefaultNestedOptIn = swarm.parameters.properties.defaults.properties.allow_nested_read_agents;
|
|
244
|
+
const swarmAgentNestedOptIn = swarm.parameters.properties.agents.items.properties.allow_nested_read_agents;
|
|
245
|
+
const spawnSessionContext = spawn.parameters.properties.session_context;
|
|
246
|
+
const swarmDefaultSessionContext = swarm.parameters.properties.defaults.properties.session_context;
|
|
247
|
+
const swarmAgentSessionContext = swarm.parameters.properties.agents.items.properties.session_context;
|
|
248
|
+
|
|
249
|
+
expect(FAVORITE_MODEL_SLOTS.filter((slot) => slot.startsWith("read-"))).toHaveLength(4);
|
|
250
|
+
expect(FAVORITE_MODEL_SLOTS.filter((slot) => slot.startsWith("write-"))).toHaveLength(4);
|
|
251
|
+
expect(spawnSlot.enum).toEqual(ACCEPTED_FAVORITE_MODEL_SLOTS);
|
|
252
|
+
expect(spawnSlot.default).toBe("read-review");
|
|
253
|
+
expect(swarmDefaultSlot.enum).toEqual(ACCEPTED_FAVORITE_MODEL_SLOTS);
|
|
254
|
+
expect(swarmAgentSlot.enum).toEqual(ACCEPTED_FAVORITE_MODEL_SLOTS);
|
|
255
|
+
expect(spawnNestedOptIn.default).toBe(false);
|
|
256
|
+
expect(swarmDefaultNestedOptIn.default).toBe(false);
|
|
257
|
+
expect(swarmAgentNestedOptIn.default).toBe(false);
|
|
258
|
+
expect(spawnSessionContext.enum).toEqual(["none", "lazy"]);
|
|
259
|
+
expect(spawnSessionContext.default).toBe("none");
|
|
260
|
+
expect(swarmDefaultSessionContext.enum).toEqual(["none", "lazy"]);
|
|
261
|
+
expect(swarmAgentSessionContext.enum).toEqual(["none", "lazy"]);
|
|
262
|
+
expect(spawnSessionContext.description).toContain("on demand");
|
|
263
|
+
expect(spawnNestedOptIn.description).toContain("write-feature/write-critical");
|
|
264
|
+
expect(spawnSlot.description).toContain("read-review is the normal default");
|
|
265
|
+
expect(spawnSlot.description).toContain("write-system owns a cross-cutting integration");
|
|
266
|
+
expect(spawn.description).toContain("read-analyze for connected explanation/root cause");
|
|
267
|
+
expect(spawn.description).toContain("relevant goal, decisions, prior attempts");
|
|
268
|
+
expect(spawn.description).toContain("session_context=lazy");
|
|
269
|
+
expect(spawn.description).toContain("wait literally idle");
|
|
270
|
+
expect(spawn.description).toContain("never sleep, poll");
|
|
271
|
+
expect(swarm.description).toContain("delegation-locked");
|
|
272
|
+
expect(swarm.description).toContain("automatic reports");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("spawn_agent creates an implicit current-session group and starts an in-process read agent by level", async () => {
|
|
276
|
+
writeFavoriteLevels();
|
|
277
|
+
const { tools, runReadAgentInProcess, adoptTeamAsLead, sessionCtx } = registerTools();
|
|
278
|
+
const ctx = makeCtx();
|
|
279
|
+
const abort = new AbortController().signal;
|
|
280
|
+
|
|
281
|
+
const result = await tools.get("spawn_agent")!.execute("spawn", {
|
|
282
|
+
name: "reader",
|
|
283
|
+
prompt: "inspect one",
|
|
284
|
+
cwd: root,
|
|
285
|
+
model_slot: "read-review",
|
|
286
|
+
}, abort, undefined, ctx);
|
|
287
|
+
|
|
288
|
+
expect(result.details).toMatchObject({ name: "reader", role: "read", mode: "in-process", terminalId: null, session: "session-test-session", modelSlot: "read-review", sessionContext: "none", sessionContextAvailable: false });
|
|
289
|
+
expect(sessionCtx.sessionManager.buildContextEntries).not.toHaveBeenCalled();
|
|
290
|
+
expect(adoptTeamAsLead).toHaveBeenCalledWith("session-test-session", ctx);
|
|
291
|
+
expect(runReadAgentInProcess).toHaveBeenCalledWith(
|
|
292
|
+
"session-test-session",
|
|
293
|
+
expect.objectContaining({ name: "reader", role: "read", model: "provider/model", thinking: "xhigh", modelSlot: "read-review" }),
|
|
294
|
+
"inspect one",
|
|
295
|
+
ctx,
|
|
296
|
+
expect.any(Object),
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("adds a lazy filtered session reference only when explicitly requested and removes it after settlement", async () => {
|
|
301
|
+
writeFavoriteLevels();
|
|
302
|
+
const harness = registerTools();
|
|
303
|
+
harness.sessionCtx.sessionManager.buildContextEntries.mockImplementation(() => [
|
|
304
|
+
{ type: "message", id: "user0001", message: { role: "user", content: "The prior decision was to keep the public API small." } },
|
|
305
|
+
{ type: "message", id: "assistant1", message: { role: "assistant", content: [{ type: "text", text: "I inspected the spawn boundary." }] } },
|
|
306
|
+
]);
|
|
307
|
+
const ctx = makeCtx();
|
|
308
|
+
ctx.sessionManager = harness.sessionCtx.sessionManager;
|
|
309
|
+
const abort = new AbortController().signal;
|
|
310
|
+
|
|
311
|
+
const result = await harness.tools.get("spawn_agent")!.execute("spawn-context", {
|
|
312
|
+
name: "context-reader",
|
|
313
|
+
prompt: "Explain the remaining handoff gap.",
|
|
314
|
+
cwd: root,
|
|
315
|
+
model_slot: "read-analyze",
|
|
316
|
+
session_context: "lazy",
|
|
317
|
+
}, abort, undefined, ctx);
|
|
318
|
+
|
|
319
|
+
expect(result.details).toMatchObject({ name: "context-reader", sessionContext: "lazy", sessionContextAvailable: true });
|
|
320
|
+
const [teamName, member, launchPrompt] = harness.runReadAgentInProcess.mock.calls[0]!;
|
|
321
|
+
expect(teamName).toBe("session-test-session");
|
|
322
|
+
expect(member).toMatchObject({ name: "context-reader", sessionContext: "lazy" });
|
|
323
|
+
expect(launchPrompt).toContain("Explain the remaining handoff gap.");
|
|
324
|
+
expect(launchPrompt).toContain("Session fallback (lazy, historical evidence only)");
|
|
325
|
+
expect(launchPrompt).toContain("Do not read it by default");
|
|
326
|
+
const referencePath = launchPrompt.match(/available at: (.+)/)?.[1]?.trim();
|
|
327
|
+
expect(referencePath).toBeTruthy();
|
|
328
|
+
expect(fs.existsSync(referencePath!)).toBe(true);
|
|
329
|
+
expect(fs.readFileSync(referencePath!, "utf8")).toContain("prior decision was to keep the public API small");
|
|
330
|
+
expect((await teams.readConfig("session-test-session")).members.find(item => item.name === "context-reader")?.prompt)
|
|
331
|
+
.toBe("Explain the remaining handoff gap.");
|
|
332
|
+
|
|
333
|
+
harness.completions.get("context-reader")!();
|
|
334
|
+
await vi.waitFor(() => expect(fs.existsSync(referencePath!)).toBe(false));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("fails open to the original mission when a lazy session snapshot cannot be built", async () => {
|
|
338
|
+
writeFavoriteLevels();
|
|
339
|
+
const harness = registerTools();
|
|
340
|
+
harness.sessionCtx.sessionManager.buildContextEntries.mockImplementation(() => { throw new Error("session unavailable"); });
|
|
341
|
+
const ctx = makeCtx();
|
|
342
|
+
ctx.sessionManager = harness.sessionCtx.sessionManager;
|
|
343
|
+
|
|
344
|
+
await expect(harness.tools.get("spawn_agent")!.execute("spawn-context-fallback", {
|
|
345
|
+
name: "fallback-reader",
|
|
346
|
+
prompt: "Inspect without history.",
|
|
347
|
+
cwd: root,
|
|
348
|
+
model_slot: "read-review",
|
|
349
|
+
session_context: "lazy",
|
|
350
|
+
}, new AbortController().signal, undefined, ctx)).resolves.toMatchObject({
|
|
351
|
+
details: { name: "fallback-reader", sessionContext: "lazy", sessionContextAvailable: false },
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledWith(
|
|
355
|
+
"session-test-session",
|
|
356
|
+
expect.objectContaining({ name: "fallback-reader", sessionContext: "lazy" }),
|
|
357
|
+
"Inspect without history.",
|
|
358
|
+
ctx,
|
|
359
|
+
expect.any(Object),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("applies swarm lazy defaults with per-agent opt-out and supports write tiers", async () => {
|
|
364
|
+
writeFavoriteLevels();
|
|
365
|
+
const harness = registerTools();
|
|
366
|
+
harness.sessionCtx.sessionManager.buildContextEntries.mockImplementation(() => [
|
|
367
|
+
{ type: "message", id: "prior", message: { role: "user", content: "Prior decision" } },
|
|
368
|
+
]);
|
|
369
|
+
const ctx = makeCtx();
|
|
370
|
+
ctx.sessionManager = harness.sessionCtx.sessionManager;
|
|
371
|
+
const swarm = await harness.tools.get("spawn_swarm_agents")!.execute("swarm-context", {
|
|
372
|
+
defaults: { model_slot: "read-review", cwd: root, session_context: "lazy" },
|
|
373
|
+
agents: [
|
|
374
|
+
{ name: "swarm-lazy", prompt: "Use fallback only if blocked." },
|
|
375
|
+
{ name: "swarm-none", prompt: "Work independently.", session_context: "none" },
|
|
376
|
+
],
|
|
377
|
+
}, new AbortController().signal, undefined, ctx);
|
|
378
|
+
|
|
379
|
+
expect(swarm.details.spawned).toEqual(expect.arrayContaining([
|
|
380
|
+
expect.objectContaining({ name: "swarm-lazy", sessionContext: "lazy", sessionContextAvailable: true }),
|
|
381
|
+
expect.objectContaining({ name: "swarm-none", sessionContext: "none", sessionContextAvailable: false }),
|
|
382
|
+
]));
|
|
383
|
+
expect(harness.runReadAgentInProcess.mock.calls.find(call => call[1].name === "swarm-lazy")?.[2])
|
|
384
|
+
.toContain("Session fallback (lazy, historical evidence only)");
|
|
385
|
+
expect(harness.runReadAgentInProcess.mock.calls.find(call => call[1].name === "swarm-none")?.[2])
|
|
386
|
+
.toBe("Work independently.");
|
|
387
|
+
|
|
388
|
+
const write = await harness.tools.get("spawn_agent")!.execute("write-context", {
|
|
389
|
+
name: "writer-context",
|
|
390
|
+
prompt: "Edit only the claimed file.",
|
|
391
|
+
cwd: root,
|
|
392
|
+
model_slot: "write-patch",
|
|
393
|
+
session_context: "lazy",
|
|
394
|
+
}, new AbortController().signal, undefined, ctx);
|
|
395
|
+
expect(write.details).toMatchObject({ role: "write", sessionContext: "lazy", sessionContextAvailable: true });
|
|
396
|
+
expect(harness.runReadAgentInProcess.mock.calls.find(call => call[1].name === "writer-context")?.[2])
|
|
397
|
+
.toContain("Session fallback (lazy, historical evidence only)");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("removes lazy references after synchronous and asynchronous launch failures", async () => {
|
|
401
|
+
writeFavoriteLevels();
|
|
402
|
+
const harness = registerTools();
|
|
403
|
+
harness.sessionCtx.sessionManager.buildContextEntries.mockImplementation(() => [
|
|
404
|
+
{ type: "message", id: "prior", message: { role: "user", content: "Prior decision" } },
|
|
405
|
+
]);
|
|
406
|
+
const ctx = makeCtx();
|
|
407
|
+
ctx.sessionManager = harness.sessionCtx.sessionManager;
|
|
408
|
+
let synchronousPath = "";
|
|
409
|
+
harness.runReadAgentInProcess.mockImplementationOnce((_team, _member, prompt) => {
|
|
410
|
+
synchronousPath = prompt.match(/available at: (.+)/)?.[1]?.trim() || "";
|
|
411
|
+
throw new Error("synchronous launch failure");
|
|
412
|
+
});
|
|
413
|
+
await expect(harness.tools.get("spawn_agent")!.execute("sync-failure", {
|
|
414
|
+
name: "sync-failure",
|
|
415
|
+
prompt: "Inspect.",
|
|
416
|
+
cwd: root,
|
|
417
|
+
model_slot: "read-review",
|
|
418
|
+
session_context: "lazy",
|
|
419
|
+
}, new AbortController().signal, undefined, ctx)).rejects.toThrow("synchronous launch failure");
|
|
420
|
+
expect(synchronousPath).not.toBe("");
|
|
421
|
+
expect(fs.existsSync(synchronousPath)).toBe(false);
|
|
422
|
+
|
|
423
|
+
let asynchronousPath = "";
|
|
424
|
+
harness.runReadAgentInProcess.mockImplementationOnce((_team, _member, prompt) => {
|
|
425
|
+
asynchronousPath = prompt.match(/available at: (.+)/)?.[1]?.trim() || "";
|
|
426
|
+
return Promise.reject(new Error("asynchronous launch failure"));
|
|
427
|
+
});
|
|
428
|
+
await expect(harness.tools.get("spawn_agent")!.execute("async-failure", {
|
|
429
|
+
name: "async-failure",
|
|
430
|
+
prompt: "Inspect.",
|
|
431
|
+
cwd: root,
|
|
432
|
+
model_slot: "read-review",
|
|
433
|
+
session_context: "lazy",
|
|
434
|
+
}, new AbortController().signal, undefined, ctx)).resolves.toMatchObject({
|
|
435
|
+
details: { sessionContextAvailable: true },
|
|
436
|
+
});
|
|
437
|
+
await vi.waitFor(() => expect(fs.existsSync(asynchronousPath)).toBe(false));
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("canonicalizes persisted legacy model slots when reusing an existing member", async () => {
|
|
441
|
+
const { runReadAgentInProcess, emitAsync, piEventEmit } = registerTools();
|
|
442
|
+
const ctx = makeCtx();
|
|
443
|
+
teams.createTeam("session-test-session", "test-session", "lead-agent", "Pi session agents", "provider/model");
|
|
444
|
+
await teams.addMember("session-test-session", {
|
|
445
|
+
agentId: "writer@session-test-session",
|
|
446
|
+
name: "writer",
|
|
447
|
+
agentType: "teammate",
|
|
448
|
+
role: "write",
|
|
449
|
+
model: "provider/model",
|
|
450
|
+
thinking: "high",
|
|
451
|
+
modelSlot: "writing-hard",
|
|
452
|
+
joinedAt: Date.now(),
|
|
453
|
+
tmuxPaneId: "%1",
|
|
454
|
+
cwd: root,
|
|
455
|
+
subscriptions: [],
|
|
456
|
+
isActive: true,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
await emitAsync("pi-extended-teams:orchestration-request", {
|
|
460
|
+
requestId: "reuse-writer",
|
|
461
|
+
type: "spawn_teammate_once",
|
|
462
|
+
params: {
|
|
463
|
+
team_name: "session-test-session",
|
|
464
|
+
name: "writer",
|
|
465
|
+
prompt: "continue existing work",
|
|
466
|
+
cwd: root,
|
|
467
|
+
model_slot: "writing-hard",
|
|
468
|
+
},
|
|
469
|
+
ctx,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
expect(piEventEmit).toHaveBeenCalledWith("pi-extended-teams:orchestration-response", expect.objectContaining({
|
|
473
|
+
requestId: "reuse-writer",
|
|
474
|
+
ok: true,
|
|
475
|
+
details: expect.objectContaining({
|
|
476
|
+
existing: true,
|
|
477
|
+
idempotent: true,
|
|
478
|
+
requestedModelSlot: "write-system",
|
|
479
|
+
modelSlot: "write-system",
|
|
480
|
+
role: "write",
|
|
481
|
+
}),
|
|
482
|
+
}));
|
|
483
|
+
expect(runReadAgentInProcess).not.toHaveBeenCalled();
|
|
484
|
+
expect((await teams.readConfig("session-test-session")).members.find((member) => member.name === "writer")?.modelSlot).toBe("writing-hard");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("canonicalizes legacy requested slots when reusing a queued write", async () => {
|
|
488
|
+
writeFavoriteLevels();
|
|
489
|
+
const { runReadAgentInProcess, emitAsync, piEventEmit } = registerTools();
|
|
490
|
+
const ctx = makeCtx();
|
|
491
|
+
teams.createTeam("session-test-session", "test-session", "lead-agent", "Pi session agents", "provider/model");
|
|
492
|
+
await writeQueue.enqueueWriteSpawn("session-test-session", {
|
|
493
|
+
name: "writer",
|
|
494
|
+
prompt: "continue queued work",
|
|
495
|
+
cwd: root,
|
|
496
|
+
modelSlot: "writing-hard",
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await emitAsync("pi-extended-teams:orchestration-request", {
|
|
500
|
+
requestId: "reuse-queued-writer",
|
|
501
|
+
type: "spawn_teammate_once",
|
|
502
|
+
params: {
|
|
503
|
+
team_name: "session-test-session",
|
|
504
|
+
name: "writer",
|
|
505
|
+
prompt: "continue queued work",
|
|
506
|
+
cwd: root,
|
|
507
|
+
model_slot: "writing-hard",
|
|
508
|
+
},
|
|
509
|
+
ctx,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(piEventEmit).toHaveBeenCalledWith("pi-extended-teams:orchestration-response", expect.objectContaining({
|
|
513
|
+
requestId: "reuse-queued-writer",
|
|
514
|
+
ok: true,
|
|
515
|
+
details: expect.objectContaining({
|
|
516
|
+
queued: true,
|
|
517
|
+
existing: true,
|
|
518
|
+
idempotent: true,
|
|
519
|
+
requestedModelSlot: "write-system",
|
|
520
|
+
modelSlot: "write-system",
|
|
521
|
+
modelSource: "queued",
|
|
522
|
+
role: "write",
|
|
523
|
+
}),
|
|
524
|
+
}));
|
|
525
|
+
expect(runReadAgentInProcess).not.toHaveBeenCalled();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("rejects a public same-name spawn during quarantine and succeeds after old settlement", async () => {
|
|
529
|
+
vi.useFakeTimers();
|
|
530
|
+
try {
|
|
531
|
+
writeFavoriteLevels();
|
|
532
|
+
const { tools, runningReadAgents, runReadAgentInProcess, shutdownTeammate } = registerTools();
|
|
533
|
+
const ctx = makeCtx();
|
|
534
|
+
const abort = new AbortController().signal;
|
|
535
|
+
const params = {
|
|
536
|
+
name: "reader",
|
|
537
|
+
prompt: "inspect one",
|
|
538
|
+
cwd: root,
|
|
539
|
+
model_slot: "read-review",
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
await tools.get("spawn_agent")!.execute("spawn-1", params, abort, undefined, ctx);
|
|
543
|
+
const oldState = runningReadAgents.get("session-test-session:reader")!;
|
|
544
|
+
let settleDelivery!: () => void;
|
|
545
|
+
oldState.messageDeliveryTail = new Promise<void>((resolve) => { settleDelivery = resolve; });
|
|
546
|
+
oldState.session = {
|
|
547
|
+
hasExtensionHandlers: vi.fn(() => false),
|
|
548
|
+
clearQueue: vi.fn(() => ({ steering: [], followUp: [] })),
|
|
549
|
+
abort: vi.fn(async () => {}),
|
|
550
|
+
dispose: vi.fn(),
|
|
551
|
+
} as any;
|
|
552
|
+
const lifecycle = createLifecycleRuntime({
|
|
553
|
+
isTeammate: false,
|
|
554
|
+
terminal: null,
|
|
555
|
+
runningReadAgents,
|
|
556
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
557
|
+
isCurrentReadAgentRun: (key, state) => runningReadAgents.get(key) === state,
|
|
558
|
+
renderReadAgentStatus: vi.fn(),
|
|
559
|
+
drainWriteQueue: vi.fn(async () => {}),
|
|
560
|
+
getSessionCwd: () => root,
|
|
561
|
+
getTeamName: () => "session-test-session",
|
|
562
|
+
});
|
|
563
|
+
shutdownTeammate.mockImplementation(lifecycle.shutdownTeammate as any);
|
|
564
|
+
// Simulate an inactive-update implementation that reports success without
|
|
565
|
+
// writing. Matching removal closes admission, but the run fence must stay.
|
|
566
|
+
vi.spyOn(teams, "updateMember").mockResolvedValueOnce();
|
|
567
|
+
|
|
568
|
+
const retry = tools.get("spawn_agent")!.execute("spawn-2", params, abort, undefined, ctx);
|
|
569
|
+
const retryRejection = expect(retry).rejects.toThrow("Retry after lifecycle cleanup settles");
|
|
570
|
+
await vi.advanceTimersByTimeAsync(NESTED_SESSION_TEARDOWN_TIMEOUT_MS);
|
|
571
|
+
await retryRejection;
|
|
572
|
+
expect(oldState.teardownState).toBe("quarantined");
|
|
573
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
574
|
+
expect(runningReadAgents.get("session-test-session:reader")).toBe(oldState);
|
|
575
|
+
expect((await teams.readConfig("session-test-session")).members.some(member => member.name === "reader")).toBe(false);
|
|
576
|
+
await expect(readLifecycleTombstone("session-test-session", "reader")).resolves.toMatchObject({
|
|
577
|
+
status: "occupied",
|
|
578
|
+
tombstone: { runId: oldState.runId },
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
await expect(tools.get("spawn_agent")!.execute("spawn-fenced", params, abort, undefined, ctx))
|
|
582
|
+
.rejects.toThrow("lifecycle-quarantined");
|
|
583
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
584
|
+
expect(runningReadAgents.get("session-test-session:reader")).toBe(oldState);
|
|
585
|
+
|
|
586
|
+
settleDelivery();
|
|
587
|
+
await oldState.teardownFinalizationPromise;
|
|
588
|
+
expect(runningReadAgents.has("session-test-session:reader")).toBe(false);
|
|
589
|
+
expect((await teams.readConfig("session-test-session")).members.some(member => member.name === "reader")).toBe(false);
|
|
590
|
+
await expect(readLifecycleTombstone("session-test-session", "reader")).resolves.toEqual({ status: "absent" });
|
|
591
|
+
|
|
592
|
+
await expect(tools.get("spawn_agent")!.execute("spawn-3", params, abort, undefined, ctx)).resolves.toMatchObject({
|
|
593
|
+
details: { name: "reader", role: "read" },
|
|
594
|
+
});
|
|
595
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(2);
|
|
596
|
+
expect(runningReadAgents.get("session-test-session:reader")?.runId).not.toBe(oldState.runId);
|
|
597
|
+
} finally {
|
|
598
|
+
vi.useRealTimers();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("accepts legacy public/settings aliases and normalizes spawned members", async () => {
|
|
603
|
+
writeGlobalSettings({
|
|
604
|
+
favoriteModels: {
|
|
605
|
+
"reading-fast": { model: "provider/model", thinking: "low" },
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
609
|
+
const ctx = makeCtx();
|
|
610
|
+
const abort = new AbortController().signal;
|
|
611
|
+
|
|
612
|
+
const result = await tools.get("spawn_agent")!.execute("spawn", {
|
|
613
|
+
name: "fast-reader",
|
|
614
|
+
prompt: "inspect quickly",
|
|
615
|
+
cwd: root,
|
|
616
|
+
model_slot: "reading-fast",
|
|
617
|
+
}, abort, undefined, ctx);
|
|
618
|
+
|
|
619
|
+
expect(result.details).toMatchObject({ modelSource: "favorite-slot", modelSlot: "read-collect" });
|
|
620
|
+
expect(runReadAgentInProcess).toHaveBeenCalledWith(
|
|
621
|
+
"session-test-session",
|
|
622
|
+
expect.objectContaining({ name: "fast-reader", model: "provider/model", thinking: "low", modelSlot: "read-collect" }),
|
|
623
|
+
"inspect quickly",
|
|
624
|
+
ctx,
|
|
625
|
+
expect.any(Object),
|
|
626
|
+
);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("requires a configured model_slot level", async () => {
|
|
630
|
+
const { tools } = registerTools();
|
|
631
|
+
const ctx = makeCtx();
|
|
632
|
+
const abort = new AbortController().signal;
|
|
633
|
+
|
|
634
|
+
await expect(tools.get("spawn_agent")!.execute("spawn", {
|
|
635
|
+
name: "missing-level",
|
|
636
|
+
prompt: "inspect quickly",
|
|
637
|
+
cwd: root,
|
|
638
|
+
}, abort, undefined, ctx)).rejects.toThrow(/requires a configured model_slot intent tier/);
|
|
639
|
+
|
|
640
|
+
await expect(tools.get("spawn_agent")!.execute("spawn", {
|
|
641
|
+
name: "unset-level",
|
|
642
|
+
prompt: "inspect quickly",
|
|
643
|
+
cwd: root,
|
|
644
|
+
model_slot: "reading-fast",
|
|
645
|
+
}, abort, undefined, ctx)).rejects.toThrow(/Favorite model slot "read-collect" is not configured/);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("rejects direct model, thinking, or role selection", async () => {
|
|
649
|
+
writeFavoriteLevels();
|
|
650
|
+
const { tools } = registerTools();
|
|
651
|
+
const ctx = makeCtx();
|
|
652
|
+
const abort = new AbortController().signal;
|
|
653
|
+
|
|
654
|
+
await expect(tools.get("spawn_agent")!.execute("spawn", {
|
|
655
|
+
name: "conflict-reader",
|
|
656
|
+
prompt: "inspect quickly",
|
|
657
|
+
cwd: root,
|
|
658
|
+
model_slot: "reading-fast",
|
|
659
|
+
thinking: "high",
|
|
660
|
+
}, abort, undefined, ctx)).rejects.toThrow(/must use model_slot only.*thinking/);
|
|
661
|
+
|
|
662
|
+
await expect(tools.get("spawn_agent")!.execute("spawn", {
|
|
663
|
+
name: "role-reader",
|
|
664
|
+
role: "read",
|
|
665
|
+
prompt: "inspect quickly",
|
|
666
|
+
cwd: root,
|
|
667
|
+
model_slot: "reading-fast",
|
|
668
|
+
}, abort, undefined, ctx)).rejects.toThrow(/must use model_slot only.*role/);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("queues read agents at the configured cap behind spawn_agent", async () => {
|
|
672
|
+
writeFavoriteLevels();
|
|
673
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
674
|
+
const { tools, completions, runReadAgentInProcess } = registerTools();
|
|
675
|
+
const ctx = makeCtx();
|
|
676
|
+
ctx.sessionManager.buildContextEntries.mockImplementation(() => [
|
|
677
|
+
{ type: "message", id: "prior", message: { role: "user", content: "Queue-time context" } },
|
|
678
|
+
]);
|
|
679
|
+
const abort = new AbortController().signal;
|
|
680
|
+
|
|
681
|
+
const first = await tools.get("spawn_agent")!.execute("spawn-1", {
|
|
682
|
+
name: "reader-1",
|
|
683
|
+
prompt: "inspect one",
|
|
684
|
+
cwd: root,
|
|
685
|
+
model_slot: "reading-fast",
|
|
686
|
+
}, abort, undefined, ctx);
|
|
687
|
+
const second = await tools.get("spawn_agent")!.execute("spawn-2", {
|
|
688
|
+
name: "reader-2",
|
|
689
|
+
prompt: "inspect two",
|
|
690
|
+
cwd: root,
|
|
691
|
+
model_slot: "reading-fast",
|
|
692
|
+
session_context: "lazy",
|
|
693
|
+
}, abort, undefined, ctx);
|
|
694
|
+
|
|
695
|
+
expect(first.details).toMatchObject({ queued: false, role: "read", mode: "in-process" });
|
|
696
|
+
expect(second.details).toMatchObject({ queued: true, role: "read", queuePosition: 1, sessionContext: "lazy", sessionContextAvailable: null });
|
|
697
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
698
|
+
expect(ctx.sessionManager.buildContextEntries).not.toHaveBeenCalled();
|
|
699
|
+
|
|
700
|
+
completions.get("reader-1")!();
|
|
701
|
+
await vi.waitFor(() => expect(runReadAgentInProcess).toHaveBeenCalledTimes(2));
|
|
702
|
+
expect(runReadAgentInProcess.mock.calls[1][1]).toMatchObject({ name: "reader-2", role: "read", sessionContext: "lazy" });
|
|
703
|
+
expect(runReadAgentInProcess.mock.calls[1][2]).toContain("Session fallback (lazy, historical evidence only)");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("drains one queued read only after the completed run's finalization releases capacity", async () => {
|
|
707
|
+
writeFavoriteLevels();
|
|
708
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
709
|
+
const { tools, runningReadAgents, runReadAgentInProcess } = registerTools();
|
|
710
|
+
const ctx = makeCtx();
|
|
711
|
+
const abort = new AbortController().signal;
|
|
712
|
+
let resolveRun!: () => void;
|
|
713
|
+
let resolveFinalization!: () => void;
|
|
714
|
+
|
|
715
|
+
runReadAgentInProcess.mockImplementationOnce((teamName: string, member: Member) => {
|
|
716
|
+
const key = `${teamName}:${member.name}`;
|
|
717
|
+
const state = {
|
|
718
|
+
...makeRunningAgent(teamName, member),
|
|
719
|
+
teardownFinalizationPromise: new Promise<void>((resolve) => { resolveFinalization = resolve; }),
|
|
720
|
+
} as any;
|
|
721
|
+
runningReadAgents.set(key, state);
|
|
722
|
+
return new Promise<void>((resolve) => { resolveRun = resolve; });
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
await tools.get("spawn_agent")!.execute("spawn-1", {
|
|
726
|
+
name: "reader-1",
|
|
727
|
+
prompt: "inspect one",
|
|
728
|
+
cwd: root,
|
|
729
|
+
model_slot: "read-review",
|
|
730
|
+
}, abort, undefined, ctx);
|
|
731
|
+
await tools.get("spawn_agent")!.execute("spawn-2", {
|
|
732
|
+
name: "reader-2",
|
|
733
|
+
prompt: "inspect two",
|
|
734
|
+
cwd: root,
|
|
735
|
+
model_slot: "read-review",
|
|
736
|
+
}, abort, undefined, ctx);
|
|
737
|
+
|
|
738
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
739
|
+
resolveRun();
|
|
740
|
+
await Promise.resolve();
|
|
741
|
+
await Promise.resolve();
|
|
742
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
743
|
+
|
|
744
|
+
runningReadAgents.delete("session-test-session:reader-1");
|
|
745
|
+
await teams.removeMember("session-test-session", "reader-1");
|
|
746
|
+
resolveFinalization();
|
|
747
|
+
|
|
748
|
+
await vi.waitFor(() => expect(runReadAgentInProcess).toHaveBeenCalledTimes(2));
|
|
749
|
+
expect(runReadAgentInProcess.mock.calls[1][1]).toMatchObject({ name: "reader-2", role: "read" });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("reserves queued read capacity before an async runner registers its running state", async () => {
|
|
753
|
+
writeFavoriteLevels();
|
|
754
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
755
|
+
const { tools, runningReadAgents, runReadAgentInProcess, emit } = registerTools();
|
|
756
|
+
const ctx = makeCtx();
|
|
757
|
+
const abort = new AbortController().signal;
|
|
758
|
+
let resolveARun!: () => void;
|
|
759
|
+
let resolveAFinalization!: () => void;
|
|
760
|
+
let allowBRegistration!: () => void;
|
|
761
|
+
let resolveBRun!: () => void;
|
|
762
|
+
let resolveBFinalization!: () => void;
|
|
763
|
+
const aRun = new Promise<void>((resolve) => { resolveARun = resolve; });
|
|
764
|
+
const aFinalization = new Promise<void>((resolve) => { resolveAFinalization = resolve; });
|
|
765
|
+
const bRegistrationGate = new Promise<void>((resolve) => { allowBRegistration = resolve; });
|
|
766
|
+
const bRun = new Promise<void>((resolve) => { resolveBRun = resolve; });
|
|
767
|
+
const bFinalization = new Promise<void>((resolve) => { resolveBFinalization = resolve; });
|
|
768
|
+
|
|
769
|
+
runReadAgentInProcess.mockImplementation((teamName: string, member: Member) => {
|
|
770
|
+
const key = `${teamName}:${member.name}`;
|
|
771
|
+
if (member.name === "reader-a") {
|
|
772
|
+
runningReadAgents.set(key, {
|
|
773
|
+
...makeRunningAgent(teamName, member),
|
|
774
|
+
teardownFinalizationPromise: aFinalization,
|
|
775
|
+
} as any);
|
|
776
|
+
return aRun;
|
|
777
|
+
}
|
|
778
|
+
if (member.name === "reader-b") {
|
|
779
|
+
return (async () => {
|
|
780
|
+
await bRegistrationGate;
|
|
781
|
+
runningReadAgents.set(key, {
|
|
782
|
+
...makeRunningAgent(teamName, member),
|
|
783
|
+
teardownFinalizationPromise: bFinalization,
|
|
784
|
+
} as any);
|
|
785
|
+
await bRun;
|
|
786
|
+
})();
|
|
787
|
+
}
|
|
788
|
+
runningReadAgents.set(key, makeRunningAgent(teamName, member));
|
|
789
|
+
return new Promise<void>(() => {});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
for (const name of ["reader-a", "reader-b", "reader-c"]) {
|
|
793
|
+
await tools.get("spawn_agent")!.execute(`spawn-${name}`, {
|
|
794
|
+
name,
|
|
795
|
+
prompt: `inspect ${name}`,
|
|
796
|
+
cwd: root,
|
|
797
|
+
model_slot: "read-review",
|
|
798
|
+
}, abort, undefined, ctx);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
802
|
+
resolveARun();
|
|
803
|
+
await Promise.resolve();
|
|
804
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
805
|
+
|
|
806
|
+
runningReadAgents.delete("session-test-session:reader-a");
|
|
807
|
+
await teams.removeMember("session-test-session", "reader-a");
|
|
808
|
+
resolveAFinalization();
|
|
809
|
+
|
|
810
|
+
await vi.waitFor(() => expect(runReadAgentInProcess).toHaveBeenCalledTimes(2));
|
|
811
|
+
expect(runReadAgentInProcess.mock.calls.map(call => call[1].name)).toEqual(["reader-a", "reader-b"]);
|
|
812
|
+
expect(runningReadAgents.has("session-test-session:reader-b")).toBe(false);
|
|
813
|
+
const respond = vi.fn();
|
|
814
|
+
emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
815
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 0, queued: 1 });
|
|
816
|
+
|
|
817
|
+
allowBRegistration();
|
|
818
|
+
await vi.waitFor(() => expect(runningReadAgents.has("session-test-session:reader-b")).toBe(true));
|
|
819
|
+
resolveBRun();
|
|
820
|
+
await Promise.resolve();
|
|
821
|
+
await Promise.resolve();
|
|
822
|
+
expect(runReadAgentInProcess).toHaveBeenCalledTimes(2);
|
|
823
|
+
|
|
824
|
+
runningReadAgents.delete("session-test-session:reader-b");
|
|
825
|
+
await teams.removeMember("session-test-session", "reader-b");
|
|
826
|
+
resolveBFinalization();
|
|
827
|
+
|
|
828
|
+
await vi.waitFor(() => expect(runReadAgentInProcess).toHaveBeenCalledTimes(3));
|
|
829
|
+
expect(runReadAgentInProcess.mock.calls[2][1]).toMatchObject({ name: "reader-c", role: "read" });
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it("reports running and queued child agents only for the matching Pi session", async () => {
|
|
833
|
+
writeFavoriteLevels();
|
|
834
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
835
|
+
const { tools, emit } = registerTools();
|
|
836
|
+
const ctx = makeCtx();
|
|
837
|
+
const abort = new AbortController().signal;
|
|
838
|
+
|
|
839
|
+
await tools.get("spawn_agent")!.execute("spawn-1", {
|
|
840
|
+
name: "reader-1",
|
|
841
|
+
prompt: "inspect one",
|
|
842
|
+
cwd: root,
|
|
843
|
+
model_slot: "reading-fast",
|
|
844
|
+
}, abort, undefined, ctx);
|
|
845
|
+
await tools.get("spawn_agent")!.execute("spawn-2", {
|
|
846
|
+
name: "reader-2",
|
|
847
|
+
prompt: "inspect two",
|
|
848
|
+
cwd: root,
|
|
849
|
+
model_slot: "reading-fast",
|
|
850
|
+
}, abort, undefined, ctx);
|
|
851
|
+
|
|
852
|
+
const matching: any[] = [];
|
|
853
|
+
emit("pi-extended-teams:child-agent-lifecycle-probe", {
|
|
854
|
+
sessionId: "test-session",
|
|
855
|
+
respond: (snapshot: any) => matching.push(snapshot),
|
|
856
|
+
});
|
|
857
|
+
expect(matching).toEqual([{ sessionId: "test-session", running: 1, queued: 1 }]);
|
|
858
|
+
|
|
859
|
+
const mismatched: any[] = [];
|
|
860
|
+
emit("pi-extended-teams:child-agent-lifecycle-probe", {
|
|
861
|
+
sessionId: "other-session",
|
|
862
|
+
respond: (snapshot: any) => mismatched.push(snapshot),
|
|
863
|
+
});
|
|
864
|
+
expect(mismatched).toEqual([]);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("unsubscribes the lifecycle probe listener idempotently during reload shutdown", async () => {
|
|
868
|
+
const { emit, shutdown, eventUnsubscribes } = registerTools();
|
|
869
|
+
const respond = vi.fn();
|
|
870
|
+
const payload = { sessionId: "test-session", respond };
|
|
871
|
+
|
|
872
|
+
emit("pi-extended-teams:child-agent-lifecycle-probe", payload);
|
|
873
|
+
expect(respond).toHaveBeenCalledTimes(1);
|
|
874
|
+
|
|
875
|
+
await shutdown();
|
|
876
|
+
await shutdown();
|
|
877
|
+
emit("pi-extended-teams:child-agent-lifecycle-probe", payload);
|
|
878
|
+
|
|
879
|
+
expect(eventUnsubscribes.get("pi-extended-teams:child-agent-lifecycle-probe")).toHaveBeenCalledTimes(1);
|
|
880
|
+
expect(respond).toHaveBeenCalledTimes(1);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("spawn_swarm_agents requires every agent to have a configured level", async () => {
|
|
884
|
+
writeFavoriteLevels();
|
|
885
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
886
|
+
const ctx = makeCtx();
|
|
887
|
+
const abort = new AbortController().signal;
|
|
888
|
+
|
|
889
|
+
await expect(tools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
890
|
+
defaults: { cwd: root },
|
|
891
|
+
agents: [
|
|
892
|
+
{ name: "a", prompt: "inspect a" },
|
|
893
|
+
],
|
|
894
|
+
}, abort, undefined, ctx)).rejects.toThrow(/requires a configured model_slot intent tier/);
|
|
895
|
+
|
|
896
|
+
expect(runReadAgentInProcess).not.toHaveBeenCalled();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("spawn_swarm_agents applies default and per-agent levels", async () => {
|
|
900
|
+
writeFavoriteLevels();
|
|
901
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
902
|
+
const ctx = makeCtx();
|
|
903
|
+
const abort = new AbortController().signal;
|
|
904
|
+
|
|
905
|
+
const result = await tools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
906
|
+
defaults: { cwd: root, model_slot: "read-review" },
|
|
907
|
+
agents: [
|
|
908
|
+
{ name: "a", prompt: "inspect a" },
|
|
909
|
+
{ name: "b", prompt: "inspect b", model_slot: "read-critical" },
|
|
910
|
+
],
|
|
911
|
+
}, abort, undefined, ctx);
|
|
912
|
+
|
|
913
|
+
expect(result.details.spawned).toHaveLength(2);
|
|
914
|
+
expect(runReadAgentInProcess.mock.calls[0][1]).toMatchObject({ name: "a", thinking: "xhigh", modelSlot: "read-review" });
|
|
915
|
+
expect(runReadAgentInProcess.mock.calls[1][1]).toMatchObject({ name: "b", thinking: "xhigh", modelSlot: "read-critical" });
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("spawn_swarm_agents applies default and per-agent favorite model slots", async () => {
|
|
919
|
+
writeGlobalSettings({
|
|
920
|
+
favoriteModels: {
|
|
921
|
+
"reading-fast": { model: "provider/model", thinking: "low" },
|
|
922
|
+
"reading-hard": { model: "provider/model", thinking: "xhigh" },
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
926
|
+
const ctx = makeCtx();
|
|
927
|
+
const abort = new AbortController().signal;
|
|
928
|
+
|
|
929
|
+
const result = await tools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
930
|
+
defaults: { cwd: root, model_slot: "reading-fast" },
|
|
931
|
+
agents: [
|
|
932
|
+
{ name: "a", prompt: "inspect a" },
|
|
933
|
+
{ name: "b", prompt: "inspect b", model_slot: "reading-hard" },
|
|
934
|
+
],
|
|
935
|
+
}, abort, undefined, ctx);
|
|
936
|
+
|
|
937
|
+
expect(result.details.spawned).toHaveLength(2);
|
|
938
|
+
expect(runReadAgentInProcess.mock.calls[0][1]).toMatchObject({ name: "a", thinking: "low", modelSlot: "read-collect" });
|
|
939
|
+
expect(runReadAgentInProcess.mock.calls[1][1]).toMatchObject({ name: "b", thinking: "xhigh", modelSlot: "read-critical" });
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("spawn_swarm_agents rejects per-agent direct model/thinking even with a default level", async () => {
|
|
943
|
+
writeFavoriteLevels();
|
|
944
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
945
|
+
const ctx = makeCtx();
|
|
946
|
+
const abort = new AbortController().signal;
|
|
947
|
+
|
|
948
|
+
await expect(tools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
949
|
+
defaults: { cwd: root, model_slot: "reading-fast" },
|
|
950
|
+
agents: [
|
|
951
|
+
{ name: "a", prompt: "inspect a" },
|
|
952
|
+
{ name: "b", prompt: "inspect b", model: "provider/model", thinking: "xhigh" },
|
|
953
|
+
],
|
|
954
|
+
}, abort, undefined, ctx)).rejects.toThrow(/must use model_slot only.*model.*thinking/);
|
|
955
|
+
|
|
956
|
+
expect(runReadAgentInProcess).not.toHaveBeenCalled();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("spawn_swarm_agents rejects direct defaults even when an agent supplies a level", async () => {
|
|
960
|
+
writeFavoriteLevels();
|
|
961
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
962
|
+
const ctx = makeCtx();
|
|
963
|
+
const abort = new AbortController().signal;
|
|
964
|
+
|
|
965
|
+
await expect(tools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
966
|
+
defaults: { cwd: root, model: "provider/model", thinking: "low" },
|
|
967
|
+
agents: [
|
|
968
|
+
{ name: "a", prompt: "inspect a", model_slot: "reading-hard" },
|
|
969
|
+
],
|
|
970
|
+
}, abort, undefined, ctx)).rejects.toThrow(/must use model_slot only.*model.*thinking/);
|
|
971
|
+
|
|
972
|
+
expect(runReadAgentInProcess).not.toHaveBeenCalled();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("spawn_swarm_agents gives unnamed agents unique names across swarms", async () => {
|
|
976
|
+
writeFavoriteLevels();
|
|
977
|
+
const { tools, shutdownTeammate } = registerTools();
|
|
978
|
+
const ctx = makeCtx();
|
|
979
|
+
const abort = new AbortController().signal;
|
|
980
|
+
|
|
981
|
+
const first = await tools.get("spawn_swarm_agents")!.execute("swarm-1", {
|
|
982
|
+
defaults: { cwd: root, model_slot: "read-review" },
|
|
983
|
+
agents: [{ prompt: "inspect one" }, { prompt: "inspect two" }],
|
|
984
|
+
}, abort, undefined, ctx);
|
|
985
|
+
const second = await tools.get("spawn_swarm_agents")!.execute("swarm-2", {
|
|
986
|
+
defaults: { cwd: root, model_slot: "read-review" },
|
|
987
|
+
agents: [{ prompt: "inspect three" }, { prompt: "inspect four" }],
|
|
988
|
+
}, abort, undefined, ctx);
|
|
989
|
+
|
|
990
|
+
const names = [...first.details.spawned, ...second.details.spawned].map((item: any) => item.name);
|
|
991
|
+
expect(new Set(names).size).toBe(4);
|
|
992
|
+
expect(names.every((name: string) => name.startsWith("agent-"))).toBe(true);
|
|
993
|
+
expect(shutdownTeammate).not.toHaveBeenCalled();
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it("stamps public agents at depth zero and honors only the dedicated single/default/per-agent opt-in", async () => {
|
|
997
|
+
writeFavoriteLevels();
|
|
998
|
+
const { tools, runReadAgentInProcess } = registerTools();
|
|
999
|
+
const ctx = makeCtx();
|
|
1000
|
+
const abort = new AbortController().signal;
|
|
1001
|
+
|
|
1002
|
+
await tools.get("spawn_agent")!.execute("single-default", {
|
|
1003
|
+
name: "single-default",
|
|
1004
|
+
prompt: "implement without delegation",
|
|
1005
|
+
cwd: root,
|
|
1006
|
+
model_slot: "write-feature",
|
|
1007
|
+
metadata: { allowNestedReadAgents: true, delegationDepth: 99 },
|
|
1008
|
+
}, abort, undefined, ctx);
|
|
1009
|
+
await tools.get("spawn_agent")!.execute("single-opt-in", {
|
|
1010
|
+
name: "single-opt-in",
|
|
1011
|
+
prompt: "implement with read helpers",
|
|
1012
|
+
cwd: root,
|
|
1013
|
+
model_slot: "write-critical",
|
|
1014
|
+
allow_nested_read_agents: true,
|
|
1015
|
+
}, abort, undefined, ctx);
|
|
1016
|
+
await tools.get("spawn_swarm_agents")!.execute("swarm-opt-in", {
|
|
1017
|
+
defaults: {
|
|
1018
|
+
cwd: root,
|
|
1019
|
+
model_slot: "write-feature",
|
|
1020
|
+
allow_nested_read_agents: true,
|
|
1021
|
+
},
|
|
1022
|
+
agents: [
|
|
1023
|
+
{ name: "swarm-inherited", prompt: "inherit delegation" },
|
|
1024
|
+
{ name: "swarm-disabled", prompt: "disable delegation", allow_nested_read_agents: false },
|
|
1025
|
+
],
|
|
1026
|
+
}, abort, undefined, ctx);
|
|
1027
|
+
|
|
1028
|
+
const spawnedMembers = runReadAgentInProcess.mock.calls.map((call) => call[1]);
|
|
1029
|
+
expect(spawnedMembers).toHaveLength(4);
|
|
1030
|
+
expect(spawnedMembers[0]).toMatchObject({
|
|
1031
|
+
name: "single-default",
|
|
1032
|
+
delegationDepth: 0,
|
|
1033
|
+
allowNestedReadAgents: false,
|
|
1034
|
+
metadata: { allowNestedReadAgents: true, delegationDepth: 99 },
|
|
1035
|
+
});
|
|
1036
|
+
expect(spawnedMembers[1]).toMatchObject({
|
|
1037
|
+
name: "single-opt-in",
|
|
1038
|
+
delegationDepth: 0,
|
|
1039
|
+
allowNestedReadAgents: true,
|
|
1040
|
+
});
|
|
1041
|
+
expect(spawnedMembers[2]).toMatchObject({
|
|
1042
|
+
name: "swarm-inherited",
|
|
1043
|
+
delegationDepth: 0,
|
|
1044
|
+
allowNestedReadAgents: true,
|
|
1045
|
+
});
|
|
1046
|
+
expect(spawnedMembers[3]).toMatchObject({
|
|
1047
|
+
name: "swarm-disabled",
|
|
1048
|
+
delegationDepth: 0,
|
|
1049
|
+
allowNestedReadAgents: false,
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("creates restricted child tools only for a live canonical opted-in parent and rejects non-read or extra fields", async () => {
|
|
1054
|
+
writeFavoriteLevels();
|
|
1055
|
+
const harness = registerTools();
|
|
1056
|
+
const parent = await admitNestedReadParent(harness);
|
|
1057
|
+
const outerCtx = makeCtx();
|
|
1058
|
+
const binding = {
|
|
1059
|
+
teamName: "session-test-session",
|
|
1060
|
+
parent,
|
|
1061
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1062
|
+
outerCtx,
|
|
1063
|
+
};
|
|
1064
|
+
const nestedTools = new Map(harness.teamToolsRuntime.createNestedReadAgentTools(binding).map((tool: any) => [tool.name, tool]));
|
|
1065
|
+
|
|
1066
|
+
expect(Array.from(nestedTools.keys()).sort()).toEqual(["spawn_agent", "spawn_swarm_agents"]);
|
|
1067
|
+
expect(nestedTools.get("spawn_agent")!.parameters.properties.model_slot.enum).toEqual(NESTED_READ_MODEL_SLOTS);
|
|
1068
|
+
expect(nestedTools.get("spawn_swarm_agents")!.parameters.properties.defaults.properties.model_slot.enum).toEqual(NESTED_READ_MODEL_SLOTS);
|
|
1069
|
+
expect(nestedTools.get("spawn_swarm_agents")!.parameters.properties.agents.items.properties.model_slot.enum).toEqual(NESTED_READ_MODEL_SLOTS);
|
|
1070
|
+
|
|
1071
|
+
for (const ineligibleParent of [
|
|
1072
|
+
{ ...parent, allowNestedReadAgents: false, metadata: { allowNestedReadAgents: true, allow_nested_read_agents: true } },
|
|
1073
|
+
{ ...parent, delegationDepth: 1 },
|
|
1074
|
+
{ ...parent, modelSlot: "write-patch" },
|
|
1075
|
+
{ ...parent, modelSlot: "write-system" },
|
|
1076
|
+
{ ...parent, role: "read" as const, modelSlot: "read-critical" },
|
|
1077
|
+
]) {
|
|
1078
|
+
expect(harness.teamToolsRuntime.createNestedReadAgentTools({ ...binding, parent: ineligibleParent })).toEqual([]);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
await expect(nestedTools.get("spawn_agent")!.execute("write-tier", {
|
|
1082
|
+
name: "writer-child",
|
|
1083
|
+
prompt: "do not admit",
|
|
1084
|
+
model_slot: "write-feature",
|
|
1085
|
+
})).rejects.toThrow("requires a canonical read-* model_slot");
|
|
1086
|
+
await expect(nestedTools.get("spawn_agent")!.execute("legacy-read-tier", {
|
|
1087
|
+
name: "legacy-child",
|
|
1088
|
+
prompt: "do not admit",
|
|
1089
|
+
model_slot: "reading-fast",
|
|
1090
|
+
})).rejects.toThrow("requires a canonical read-* model_slot");
|
|
1091
|
+
await expect(nestedTools.get("spawn_agent")!.execute("cwd-override", {
|
|
1092
|
+
name: "escaped-child",
|
|
1093
|
+
prompt: "do not admit",
|
|
1094
|
+
model_slot: "read-review",
|
|
1095
|
+
cwd: path.join(root, "elsewhere"),
|
|
1096
|
+
})).rejects.toThrow("accepts only name, prompt, model_slot");
|
|
1097
|
+
await expect(nestedTools.get("spawn_agent")!.execute("session-context-override", {
|
|
1098
|
+
name: "context-child",
|
|
1099
|
+
prompt: "do not admit",
|
|
1100
|
+
model_slot: "read-review",
|
|
1101
|
+
session_context: "lazy",
|
|
1102
|
+
})).rejects.toThrow("accepts only name, prompt, model_slot");
|
|
1103
|
+
await expect(nestedTools.get("spawn_swarm_agents")!.execute("invalid-swarm", {
|
|
1104
|
+
defaults: { model_slot: "read-review" },
|
|
1105
|
+
agents: [
|
|
1106
|
+
{ name: "valid", prompt: "would be valid" },
|
|
1107
|
+
{ name: "invalid", prompt: "must reject whole request", model_slot: "write-critical" },
|
|
1108
|
+
],
|
|
1109
|
+
})).rejects.toThrow("requires a canonical read-* model_slot");
|
|
1110
|
+
expect(harness.runReadAgentInProcess).not.toHaveBeenCalled();
|
|
1111
|
+
|
|
1112
|
+
harness.runningReadAgents.get("session-test-session:writer")!.runId = "replacement-run";
|
|
1113
|
+
await expect(nestedTools.get("spawn_agent")!.execute("stale-parent", {
|
|
1114
|
+
name: "stale-child",
|
|
1115
|
+
prompt: "do not admit",
|
|
1116
|
+
model_slot: "read-collect",
|
|
1117
|
+
})).rejects.toThrow("bound parent lifecycle is not active");
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it("admits single and swarm children with forced parent provenance and never replaces a duplicate", async () => {
|
|
1121
|
+
writeFavoriteLevels();
|
|
1122
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 8, queueOverflow: true } });
|
|
1123
|
+
const harness = registerTools();
|
|
1124
|
+
const parent = await admitNestedReadParent(harness);
|
|
1125
|
+
const outerCtx = makeCtx();
|
|
1126
|
+
const nestedTools = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1127
|
+
teamName: "session-test-session",
|
|
1128
|
+
parent,
|
|
1129
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1130
|
+
outerCtx,
|
|
1131
|
+
}).map((tool: any) => [tool.name, tool]));
|
|
1132
|
+
|
|
1133
|
+
const single = await nestedTools.get("spawn_agent")!.execute("single", {
|
|
1134
|
+
name: "child-one",
|
|
1135
|
+
prompt: "inspect one",
|
|
1136
|
+
model_slot: "read-critical",
|
|
1137
|
+
});
|
|
1138
|
+
expect(single.details).toMatchObject({ name: "child-one", role: "read", queued: false, modelSlot: "read-critical" });
|
|
1139
|
+
expect(harness.runReadAgentInProcess).toHaveBeenLastCalledWith(
|
|
1140
|
+
"session-test-session",
|
|
1141
|
+
expect.objectContaining({
|
|
1142
|
+
name: "child-one",
|
|
1143
|
+
role: "read",
|
|
1144
|
+
cwd: root,
|
|
1145
|
+
modelSlot: "read-critical",
|
|
1146
|
+
delegationDepth: 1,
|
|
1147
|
+
allowNestedReadAgents: false,
|
|
1148
|
+
parentAgentName: "writer",
|
|
1149
|
+
parentLifecycleRunId: parent.lifecycleRunId,
|
|
1150
|
+
requestedBy: "writer",
|
|
1151
|
+
helperKind: "read_helper",
|
|
1152
|
+
}),
|
|
1153
|
+
"inspect one",
|
|
1154
|
+
outerCtx,
|
|
1155
|
+
expect.any(Object),
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
await expect(nestedTools.get("spawn_agent")!.execute("duplicate", {
|
|
1159
|
+
name: "child-one",
|
|
1160
|
+
prompt: "replace one",
|
|
1161
|
+
model_slot: "read-review",
|
|
1162
|
+
})).rejects.toThrow("nested delegation cannot replace an existing run");
|
|
1163
|
+
expect(harness.shutdownTeammate).not.toHaveBeenCalled();
|
|
1164
|
+
|
|
1165
|
+
const swarm = await nestedTools.get("spawn_swarm_agents")!.execute("swarm", {
|
|
1166
|
+
defaults: { model_slot: "read-collect" },
|
|
1167
|
+
agents: [
|
|
1168
|
+
{ name: "child-two", prompt: "inspect two" },
|
|
1169
|
+
{ name: "child-three", prompt: "inspect three", model_slot: "read-analyze" },
|
|
1170
|
+
{ name: "child-four", prompt: "inspect four", model_slot: "read-review" },
|
|
1171
|
+
],
|
|
1172
|
+
});
|
|
1173
|
+
expect(swarm.details.failed).toEqual([]);
|
|
1174
|
+
expect(swarm.details.spawned).toHaveLength(3);
|
|
1175
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(4);
|
|
1176
|
+
|
|
1177
|
+
const childCalls = harness.runReadAgentInProcess.mock.calls.map((call) => call[1]);
|
|
1178
|
+
expect(childCalls.map((child) => child.name)).toEqual(["child-one", "child-two", "child-three", "child-four"]);
|
|
1179
|
+
expect(childCalls.map((child) => child.modelSlot)).toEqual(["read-critical", "read-collect", "read-analyze", "read-review"]);
|
|
1180
|
+
expect(childCalls.every((child) => child.delegationDepth === 1
|
|
1181
|
+
&& child.parentAgentName === "writer"
|
|
1182
|
+
&& child.parentLifecycleRunId === parent.lifecycleRunId
|
|
1183
|
+
&& child.cwd === root)).toBe(true);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it("registers an immediate child before launch so a fast completion cannot be lost", async () => {
|
|
1187
|
+
writeFavoriteLevels();
|
|
1188
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 8, queueOverflow: true } });
|
|
1189
|
+
const harness = registerTools();
|
|
1190
|
+
const parent = await admitNestedReadParent(harness);
|
|
1191
|
+
const identity = {
|
|
1192
|
+
teamName: "session-test-session",
|
|
1193
|
+
parentName: parent.name,
|
|
1194
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1195
|
+
};
|
|
1196
|
+
harness.runReadAgentInProcess.mockImplementationOnce(() => Promise.resolve());
|
|
1197
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1198
|
+
teamName: identity.teamName,
|
|
1199
|
+
parent,
|
|
1200
|
+
parentRunId: identity.parentRunId,
|
|
1201
|
+
outerCtx: makeCtx(),
|
|
1202
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1203
|
+
|
|
1204
|
+
await nestedSpawn.execute("fast", {
|
|
1205
|
+
name: "fast-child",
|
|
1206
|
+
prompt: "finish immediately",
|
|
1207
|
+
model_slot: "read-collect",
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
await vi.waitFor(() => expect(harness.pendingChildController.pendingCount(identity)).toBe(0));
|
|
1211
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledOnce();
|
|
1212
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(1);
|
|
1213
|
+
expect(harness.pendingChildController.forgetParent(identity)).toBe(true);
|
|
1214
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
it("denies nested spawning immediately when the live parent recipient closes", async () => {
|
|
1218
|
+
writeFavoriteLevels();
|
|
1219
|
+
const harness = registerTools();
|
|
1220
|
+
const parent = await admitNestedReadParent(harness);
|
|
1221
|
+
const nestedTools = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1222
|
+
teamName: "session-test-session",
|
|
1223
|
+
parent,
|
|
1224
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1225
|
+
outerCtx: makeCtx(),
|
|
1226
|
+
}).map((tool: any) => [tool.name, tool]));
|
|
1227
|
+
const parentState = harness.runningReadAgents.get("session-test-session:writer")!;
|
|
1228
|
+
const spawn = nestedTools.get("spawn_agent")!;
|
|
1229
|
+
|
|
1230
|
+
parentState.messageDeliveryClosed = true;
|
|
1231
|
+
await expect(spawn.execute("closed-delivery", {
|
|
1232
|
+
name: "closed-delivery-child",
|
|
1233
|
+
prompt: "must not start after delivery closes",
|
|
1234
|
+
model_slot: "read-review",
|
|
1235
|
+
})).rejects.toThrow("bound parent lifecycle is not active");
|
|
1236
|
+
parentState.messageDeliveryClosed = false;
|
|
1237
|
+
|
|
1238
|
+
parentState.persistedRecipientClosed = true;
|
|
1239
|
+
await expect(spawn.execute("closed-recipient", {
|
|
1240
|
+
name: "closed-recipient-child",
|
|
1241
|
+
prompt: "must not start after recipient closes",
|
|
1242
|
+
model_slot: "read-review",
|
|
1243
|
+
})).rejects.toThrow("bound parent lifecycle is not active");
|
|
1244
|
+
|
|
1245
|
+
expect(harness.runReadAgentInProcess).not.toHaveBeenCalled();
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("denies and removes a queued nested child when recipient closure precedes capacity drain", async () => {
|
|
1249
|
+
writeFavoriteLevels();
|
|
1250
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1251
|
+
const harness = registerTools();
|
|
1252
|
+
const parent = await admitNestedReadParent(harness);
|
|
1253
|
+
const ctx = makeCtx();
|
|
1254
|
+
const abort = new AbortController().signal;
|
|
1255
|
+
|
|
1256
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1257
|
+
name: "capacity-blocker",
|
|
1258
|
+
prompt: "hold read capacity",
|
|
1259
|
+
cwd: root,
|
|
1260
|
+
model_slot: "read-review",
|
|
1261
|
+
}, abort, undefined, ctx);
|
|
1262
|
+
const nestedTools = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1263
|
+
teamName: "session-test-session",
|
|
1264
|
+
parent,
|
|
1265
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1266
|
+
outerCtx: ctx,
|
|
1267
|
+
}).map((tool: any) => [tool.name, tool]));
|
|
1268
|
+
const queued = await nestedTools.get("spawn_agent")!.execute("queued-child", {
|
|
1269
|
+
name: "queued-child",
|
|
1270
|
+
prompt: "wait for admission",
|
|
1271
|
+
model_slot: "read-collect",
|
|
1272
|
+
});
|
|
1273
|
+
expect(queued.details).toMatchObject({ name: "queued-child", queued: true, role: "read" });
|
|
1274
|
+
|
|
1275
|
+
harness.runningReadAgents.get("session-test-session:writer")!.persistedRecipientClosed = true;
|
|
1276
|
+
harness.completions.get("capacity-blocker")!();
|
|
1277
|
+
|
|
1278
|
+
await vi.waitFor(() => {
|
|
1279
|
+
const respond = vi.fn();
|
|
1280
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1281
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1282
|
+
});
|
|
1283
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
1284
|
+
expect((await teams.readConfig("session-test-session")).members.some((member) => member.name === "queued-child")).toBe(false);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("drops a queued child immediately when its exact parent run closes", async () => {
|
|
1288
|
+
writeFavoriteLevels();
|
|
1289
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1290
|
+
const harness = registerTools();
|
|
1291
|
+
const parent = await admitNestedReadParent(harness);
|
|
1292
|
+
const ctx = makeCtx();
|
|
1293
|
+
const abort = new AbortController().signal;
|
|
1294
|
+
const identity = {
|
|
1295
|
+
teamName: "session-test-session",
|
|
1296
|
+
parentName: parent.name,
|
|
1297
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1301
|
+
name: "capacity-blocker",
|
|
1302
|
+
prompt: "hold read capacity",
|
|
1303
|
+
cwd: root,
|
|
1304
|
+
model_slot: "read-review",
|
|
1305
|
+
}, abort, undefined, ctx);
|
|
1306
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1307
|
+
teamName: identity.teamName,
|
|
1308
|
+
parent,
|
|
1309
|
+
parentRunId: identity.parentRunId,
|
|
1310
|
+
outerCtx: ctx,
|
|
1311
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1312
|
+
await nestedSpawn.execute("queued", {
|
|
1313
|
+
name: "drop-on-close",
|
|
1314
|
+
prompt: "must never promote",
|
|
1315
|
+
model_slot: "read-collect",
|
|
1316
|
+
});
|
|
1317
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(1);
|
|
1318
|
+
|
|
1319
|
+
const lifecycle = createLifecycleRuntime({
|
|
1320
|
+
isTeammate: false,
|
|
1321
|
+
terminal: null,
|
|
1322
|
+
runningReadAgents: harness.runningReadAgents,
|
|
1323
|
+
readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
|
|
1324
|
+
isCurrentReadAgentRun: (key, state) => harness.runningReadAgents.get(key) === state,
|
|
1325
|
+
renderReadAgentStatus: vi.fn(),
|
|
1326
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1327
|
+
drainWriteQueue: vi.fn(async () => {}),
|
|
1328
|
+
getSessionCwd: () => root,
|
|
1329
|
+
getTeamName: () => identity.teamName,
|
|
1330
|
+
onTeammateClosing: (teamName, member) => {
|
|
1331
|
+
harness.pendingChildController.cancelParent({
|
|
1332
|
+
teamName,
|
|
1333
|
+
parentName: member.name,
|
|
1334
|
+
parentRunId: member.lifecycleRunId!,
|
|
1335
|
+
});
|
|
1336
|
+
},
|
|
1337
|
+
onTeammateSettled: (teamName, member) => {
|
|
1338
|
+
harness.pendingChildController.forgetParent({
|
|
1339
|
+
teamName,
|
|
1340
|
+
parentName: member.name,
|
|
1341
|
+
parentRunId: member.lifecycleRunId!,
|
|
1342
|
+
});
|
|
1343
|
+
},
|
|
1344
|
+
});
|
|
1345
|
+
await expect(lifecycle.shutdownTeammate(identity.teamName, parent)).resolves.toMatchObject({
|
|
1346
|
+
status: "settled",
|
|
1347
|
+
finalized: true,
|
|
1348
|
+
removedMember: true,
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
const respond = vi.fn();
|
|
1352
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1353
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1354
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(0);
|
|
1355
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1356
|
+
expect(harness.runReadAgentInProcess.mock.calls.some(call => call[1].name === "drop-on-close")).toBe(false);
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it("keeps a queued acceptance across capacity promotion and rekeys it to the assigned exact run", async () => {
|
|
1360
|
+
writeFavoriteLevels();
|
|
1361
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1362
|
+
const harness = registerTools();
|
|
1363
|
+
const parent = await admitNestedReadParent(harness);
|
|
1364
|
+
const ctx = makeCtx();
|
|
1365
|
+
const abort = new AbortController().signal;
|
|
1366
|
+
const identity = {
|
|
1367
|
+
teamName: "session-test-session",
|
|
1368
|
+
parentName: parent.name,
|
|
1369
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1373
|
+
name: "capacity-blocker",
|
|
1374
|
+
prompt: "hold read capacity",
|
|
1375
|
+
cwd: root,
|
|
1376
|
+
model_slot: "read-review",
|
|
1377
|
+
}, abort, undefined, ctx);
|
|
1378
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1379
|
+
teamName: identity.teamName,
|
|
1380
|
+
parent,
|
|
1381
|
+
parentRunId: identity.parentRunId,
|
|
1382
|
+
outerCtx: ctx,
|
|
1383
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1384
|
+
await nestedSpawn.execute("queued", {
|
|
1385
|
+
name: "promoted-child",
|
|
1386
|
+
prompt: "wait then run",
|
|
1387
|
+
model_slot: "read-collect",
|
|
1388
|
+
});
|
|
1389
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(1);
|
|
1390
|
+
|
|
1391
|
+
harness.completions.get("capacity-blocker")!();
|
|
1392
|
+
await vi.waitFor(() => expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(2));
|
|
1393
|
+
const promoted = (await teams.readConfig(identity.teamName)).members.find(member => member.name === "promoted-child")!;
|
|
1394
|
+
expect(promoted.lifecycleRunId).toBeTruthy();
|
|
1395
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(1);
|
|
1396
|
+
expect(harness.pendingChildController.settleChildRun({
|
|
1397
|
+
...identity,
|
|
1398
|
+
childName: promoted.name,
|
|
1399
|
+
childRunId: "stale-run",
|
|
1400
|
+
})).toBe(false);
|
|
1401
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(1);
|
|
1402
|
+
|
|
1403
|
+
harness.completions.get("promoted-child")!();
|
|
1404
|
+
await vi.waitFor(() => expect(harness.pendingChildController.pendingCount(identity)).toBe(0));
|
|
1405
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(1);
|
|
1406
|
+
expect(harness.pendingChildController.forgetParent(identity)).toBe(true);
|
|
1407
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
it("rechecks parent authorization at queued lead admission and hard-stops delegation", async () => {
|
|
1411
|
+
writeFavoriteLevels();
|
|
1412
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1413
|
+
const harness = registerTools();
|
|
1414
|
+
const parent = await admitNestedReadParent(harness);
|
|
1415
|
+
const ctx = makeCtx();
|
|
1416
|
+
const abort = new AbortController().signal;
|
|
1417
|
+
const identity = {
|
|
1418
|
+
teamName: "session-test-session",
|
|
1419
|
+
parentName: parent.name,
|
|
1420
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1424
|
+
name: "capacity-blocker",
|
|
1425
|
+
prompt: "hold read capacity",
|
|
1426
|
+
cwd: root,
|
|
1427
|
+
model_slot: "read-review",
|
|
1428
|
+
}, abort, undefined, ctx);
|
|
1429
|
+
const nestedTools = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1430
|
+
teamName: "session-test-session",
|
|
1431
|
+
parent,
|
|
1432
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1433
|
+
outerCtx: ctx,
|
|
1434
|
+
}).map((tool: any) => [tool.name, tool]));
|
|
1435
|
+
const queued = await nestedTools.get("spawn_agent")!.execute("queued-child", {
|
|
1436
|
+
name: "queued-child",
|
|
1437
|
+
prompt: "wait for admission",
|
|
1438
|
+
model_slot: "read-collect",
|
|
1439
|
+
});
|
|
1440
|
+
expect(queued.details).toMatchObject({ name: "queued-child", queued: true, role: "read" });
|
|
1441
|
+
await expect(nestedTools.get("spawn_agent")!.execute("queued-duplicate", {
|
|
1442
|
+
name: "queued-child",
|
|
1443
|
+
prompt: "must not replace queued work",
|
|
1444
|
+
model_slot: "read-review",
|
|
1445
|
+
})).rejects.toThrow("nested delegation cannot replace an existing run");
|
|
1446
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
1447
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(1);
|
|
1448
|
+
|
|
1449
|
+
const parentState = harness.runningReadAgents.get("session-test-session:writer")!;
|
|
1450
|
+
parentState.stopRequested = true;
|
|
1451
|
+
parentState.teardownState = "stopping";
|
|
1452
|
+
harness.completions.get("capacity-blocker")!();
|
|
1453
|
+
|
|
1454
|
+
await vi.waitFor(() => {
|
|
1455
|
+
const respond = vi.fn();
|
|
1456
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1457
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1458
|
+
});
|
|
1459
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
1460
|
+
expect((await teams.readConfig("session-test-session")).members.some((member) => member.name === "queued-child")).toBe(false);
|
|
1461
|
+
expect(harness.pendingChildController.pendingCount(identity)).toBe(0);
|
|
1462
|
+
await expect(nestedTools.get("spawn_agent")!.execute("after-stop", {
|
|
1463
|
+
name: "after-stop",
|
|
1464
|
+
prompt: "must not start",
|
|
1465
|
+
model_slot: "read-review",
|
|
1466
|
+
})).rejects.toThrow("bound parent lifecycle is not active");
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
it("rolls back only a deferred direct child admission when parent closure starts during add", async () => {
|
|
1470
|
+
writeFavoriteLevels();
|
|
1471
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 8, queueOverflow: true } });
|
|
1472
|
+
const harness = registerTools();
|
|
1473
|
+
const parent = await admitNestedReadParent(harness);
|
|
1474
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1475
|
+
teamName: "session-test-session",
|
|
1476
|
+
parent,
|
|
1477
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1478
|
+
outerCtx: makeCtx(),
|
|
1479
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1480
|
+
const originalAddMember = teams.addMember;
|
|
1481
|
+
let markAddStarted!: () => void;
|
|
1482
|
+
const addStarted = new Promise<void>((resolve) => { markAddStarted = resolve; });
|
|
1483
|
+
let releaseAdd!: () => void;
|
|
1484
|
+
const addGate = new Promise<void>((resolve) => { releaseAdd = resolve; });
|
|
1485
|
+
vi.spyOn(teams, "addMember").mockImplementation(async (teamName, member) => {
|
|
1486
|
+
if (member.name === "racing-child") {
|
|
1487
|
+
markAddStarted();
|
|
1488
|
+
await addGate;
|
|
1489
|
+
}
|
|
1490
|
+
return originalAddMember(teamName, member);
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
const spawning = nestedSpawn.execute("racing-child", {
|
|
1494
|
+
name: "racing-child",
|
|
1495
|
+
prompt: "must lose to parent closure",
|
|
1496
|
+
model_slot: "read-review",
|
|
1497
|
+
});
|
|
1498
|
+
await addStarted;
|
|
1499
|
+
harness.runningReadAgents.get("session-test-session:writer")!.messageDeliveryClosed = true;
|
|
1500
|
+
const closing = closePersistedRecipient(
|
|
1501
|
+
"session-test-session",
|
|
1502
|
+
"writer",
|
|
1503
|
+
parent.lifecycleRunId!,
|
|
1504
|
+
{ role: "write", removeOnFailure: true }
|
|
1505
|
+
);
|
|
1506
|
+
releaseAdd();
|
|
1507
|
+
|
|
1508
|
+
await expect(spawning).rejects.toThrow("bound parent lifecycle is not active");
|
|
1509
|
+
await closing;
|
|
1510
|
+
expect(harness.runReadAgentInProcess).not.toHaveBeenCalled();
|
|
1511
|
+
expect((await teams.readConfig("session-test-session")).members.some((member) => member.name === "racing-child")).toBe(false);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it("blocks immediate admission when successful parent stop wins before the first acceptance without leaking resources", async () => {
|
|
1515
|
+
writeFavoriteLevels();
|
|
1516
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 8, queueOverflow: true } });
|
|
1517
|
+
const harness = registerTools();
|
|
1518
|
+
const parent = await admitNestedReadParent(harness);
|
|
1519
|
+
const ctx = makeCtx();
|
|
1520
|
+
const identity = {
|
|
1521
|
+
teamName: "session-test-session",
|
|
1522
|
+
parentName: parent.name,
|
|
1523
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1524
|
+
};
|
|
1525
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1526
|
+
teamName: identity.teamName,
|
|
1527
|
+
parent,
|
|
1528
|
+
parentRunId: identity.parentRunId,
|
|
1529
|
+
outerCtx: ctx,
|
|
1530
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1531
|
+
let markModelLookupStarted!: () => void;
|
|
1532
|
+
const modelLookupStarted = new Promise<void>((resolve) => { markModelLookupStarted = resolve; });
|
|
1533
|
+
let releaseModels!: (models: any[]) => void;
|
|
1534
|
+
ctx.modelRegistry.getAvailable.mockImplementation(() => {
|
|
1535
|
+
markModelLookupStarted();
|
|
1536
|
+
return new Promise<any[]>((resolve) => { releaseModels = resolve; });
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
const spawning = nestedSpawn.execute("preaccept-direct", {
|
|
1540
|
+
name: "preaccept-direct",
|
|
1541
|
+
prompt: "must never be admitted",
|
|
1542
|
+
model_slot: "read-collect",
|
|
1543
|
+
});
|
|
1544
|
+
await modelLookupStarted;
|
|
1545
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1546
|
+
|
|
1547
|
+
const lifecycle = createLifecycleRuntime({
|
|
1548
|
+
isTeammate: false,
|
|
1549
|
+
terminal: null,
|
|
1550
|
+
runningReadAgents: harness.runningReadAgents,
|
|
1551
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
1552
|
+
isCurrentReadAgentRun: (key, state) => harness.runningReadAgents.get(key) === state,
|
|
1553
|
+
renderReadAgentStatus: vi.fn(),
|
|
1554
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1555
|
+
drainWriteQueue: vi.fn(async () => {}),
|
|
1556
|
+
getSessionCwd: () => root,
|
|
1557
|
+
getTeamName: () => identity.teamName,
|
|
1558
|
+
onTeammateClosing: () => { harness.pendingChildController.cancelParent(identity); },
|
|
1559
|
+
onTeammateSettled: () => { harness.pendingChildController.forgetParent(identity); },
|
|
1560
|
+
});
|
|
1561
|
+
await expect(lifecycle.shutdownTeammate(identity.teamName, parent)).resolves.toMatchObject({
|
|
1562
|
+
status: "settled",
|
|
1563
|
+
finalized: true,
|
|
1564
|
+
removedMember: true,
|
|
1565
|
+
});
|
|
1566
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1567
|
+
|
|
1568
|
+
releaseModels([{ provider: "provider", id: "model" }]);
|
|
1569
|
+
await expect(spawning).rejects.toThrow("bound parent lifecycle is not active");
|
|
1570
|
+
expect(harness.runReadAgentInProcess).not.toHaveBeenCalled();
|
|
1571
|
+
expect((await teams.readConfig(identity.teamName)).members.some(member => member.name === "preaccept-direct")).toBe(false);
|
|
1572
|
+
const respond = vi.fn();
|
|
1573
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1574
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 0, queued: 0 });
|
|
1575
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
it("rejects a queued request when successful parent stop wins before the first acceptance", async () => {
|
|
1579
|
+
writeFavoriteLevels();
|
|
1580
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1581
|
+
const harness = registerTools();
|
|
1582
|
+
const parent = await admitNestedReadParent(harness);
|
|
1583
|
+
const ctx = makeCtx();
|
|
1584
|
+
const abort = new AbortController().signal;
|
|
1585
|
+
const identity = {
|
|
1586
|
+
teamName: "session-test-session",
|
|
1587
|
+
parentName: parent.name,
|
|
1588
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1589
|
+
};
|
|
1590
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1591
|
+
name: "capacity-blocker",
|
|
1592
|
+
prompt: "hold capacity",
|
|
1593
|
+
cwd: root,
|
|
1594
|
+
model_slot: "read-review",
|
|
1595
|
+
}, abort, undefined, ctx);
|
|
1596
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1597
|
+
teamName: "session-test-session",
|
|
1598
|
+
parent,
|
|
1599
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1600
|
+
outerCtx: ctx,
|
|
1601
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1602
|
+
let markModelLookupStarted!: () => void;
|
|
1603
|
+
const modelLookupStarted = new Promise<void>((resolve) => { markModelLookupStarted = resolve; });
|
|
1604
|
+
let releaseModels!: (models: any[]) => void;
|
|
1605
|
+
ctx.modelRegistry.getAvailable.mockImplementation(() => {
|
|
1606
|
+
markModelLookupStarted();
|
|
1607
|
+
return new Promise<any[]>((resolve) => { releaseModels = resolve; });
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
const spawning = nestedSpawn.execute("precommit-child", {
|
|
1611
|
+
name: "precommit-child",
|
|
1612
|
+
prompt: "must not enter the queue",
|
|
1613
|
+
model_slot: "read-collect",
|
|
1614
|
+
});
|
|
1615
|
+
await modelLookupStarted;
|
|
1616
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1617
|
+
const lifecycle = createLifecycleRuntime({
|
|
1618
|
+
isTeammate: false,
|
|
1619
|
+
terminal: null,
|
|
1620
|
+
runningReadAgents: harness.runningReadAgents,
|
|
1621
|
+
readAgentKey: (teamName, agentName) => `${teamName}:${agentName}`,
|
|
1622
|
+
isCurrentReadAgentRun: (key, state) => harness.runningReadAgents.get(key) === state,
|
|
1623
|
+
renderReadAgentStatus: vi.fn(),
|
|
1624
|
+
releaseAllClaimsForAgent: vi.fn(async () => []),
|
|
1625
|
+
drainWriteQueue: vi.fn(async () => {}),
|
|
1626
|
+
getSessionCwd: () => root,
|
|
1627
|
+
getTeamName: () => identity.teamName,
|
|
1628
|
+
onTeammateClosing: () => { harness.pendingChildController.cancelParent(identity); },
|
|
1629
|
+
onTeammateSettled: () => { harness.pendingChildController.forgetParent(identity); },
|
|
1630
|
+
});
|
|
1631
|
+
await expect(lifecycle.shutdownTeammate(identity.teamName, parent)).resolves.toMatchObject({
|
|
1632
|
+
status: "settled",
|
|
1633
|
+
finalized: true,
|
|
1634
|
+
removedMember: true,
|
|
1635
|
+
});
|
|
1636
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1637
|
+
releaseModels([{ provider: "provider", id: "model" }]);
|
|
1638
|
+
|
|
1639
|
+
await expect(spawning).rejects.toThrow("bound parent lifecycle is not active");
|
|
1640
|
+
const respond = vi.fn();
|
|
1641
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1642
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1643
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
1644
|
+
expect((await teams.readConfig(identity.teamName)).members.some(member => member.name === "precommit-child")).toBe(false);
|
|
1645
|
+
expect(harness.pendingChildController.trackedParentCount()).toBe(0);
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
it("rolls back a queued child when parent closure starts during drain admission", async () => {
|
|
1649
|
+
writeFavoriteLevels();
|
|
1650
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1651
|
+
const harness = registerTools();
|
|
1652
|
+
const parent = await admitNestedReadParent(harness);
|
|
1653
|
+
const ctx = makeCtx();
|
|
1654
|
+
const abort = new AbortController().signal;
|
|
1655
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1656
|
+
name: "capacity-blocker",
|
|
1657
|
+
prompt: "hold capacity",
|
|
1658
|
+
cwd: root,
|
|
1659
|
+
model_slot: "read-review",
|
|
1660
|
+
}, abort, undefined, ctx);
|
|
1661
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1662
|
+
teamName: "session-test-session",
|
|
1663
|
+
parent,
|
|
1664
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1665
|
+
outerCtx: ctx,
|
|
1666
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1667
|
+
await nestedSpawn.execute("drain-child", {
|
|
1668
|
+
name: "drain-child",
|
|
1669
|
+
prompt: "wait for drain",
|
|
1670
|
+
model_slot: "read-collect",
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
const originalAddMember = teams.addMember;
|
|
1674
|
+
let markAddStarted!: () => void;
|
|
1675
|
+
const addStarted = new Promise<void>((resolve) => { markAddStarted = resolve; });
|
|
1676
|
+
let releaseAdd!: () => void;
|
|
1677
|
+
const addGate = new Promise<void>((resolve) => { releaseAdd = resolve; });
|
|
1678
|
+
vi.spyOn(teams, "addMember").mockImplementation(async (teamName, member) => {
|
|
1679
|
+
if (member.name === "drain-child") {
|
|
1680
|
+
markAddStarted();
|
|
1681
|
+
await addGate;
|
|
1682
|
+
}
|
|
1683
|
+
return originalAddMember(teamName, member);
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
harness.completions.get("capacity-blocker")!();
|
|
1687
|
+
await addStarted;
|
|
1688
|
+
harness.runningReadAgents.get("session-test-session:writer")!.messageDeliveryClosed = true;
|
|
1689
|
+
const closing = closePersistedRecipient(
|
|
1690
|
+
"session-test-session",
|
|
1691
|
+
"writer",
|
|
1692
|
+
parent.lifecycleRunId!,
|
|
1693
|
+
{ role: "write", removeOnFailure: true }
|
|
1694
|
+
);
|
|
1695
|
+
releaseAdd();
|
|
1696
|
+
await closing;
|
|
1697
|
+
|
|
1698
|
+
await vi.waitFor(() => {
|
|
1699
|
+
const respond = vi.fn();
|
|
1700
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1701
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1702
|
+
});
|
|
1703
|
+
expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(1);
|
|
1704
|
+
expect((await teams.readConfig("session-test-session")).members.some((member) => member.name === "drain-child")).toBe(false);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
it("admits exactly one same-name request at capacity and launches it once", async () => {
|
|
1708
|
+
writeFavoriteLevels();
|
|
1709
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1710
|
+
const harness = registerTools();
|
|
1711
|
+
const parent = await admitNestedReadParent(harness);
|
|
1712
|
+
const ctx = makeCtx();
|
|
1713
|
+
const abort = new AbortController().signal;
|
|
1714
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1715
|
+
name: "capacity-blocker",
|
|
1716
|
+
prompt: "hold capacity",
|
|
1717
|
+
cwd: root,
|
|
1718
|
+
model_slot: "read-review",
|
|
1719
|
+
}, abort, undefined, ctx);
|
|
1720
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1721
|
+
teamName: "session-test-session",
|
|
1722
|
+
parent,
|
|
1723
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1724
|
+
outerCtx: ctx,
|
|
1725
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1726
|
+
|
|
1727
|
+
const settled = await Promise.allSettled([
|
|
1728
|
+
nestedSpawn.execute("same-name-a", { name: "same-name", prompt: "first", model_slot: "read-review" }),
|
|
1729
|
+
nestedSpawn.execute("same-name-b", { name: "same-name", prompt: "second", model_slot: "read-review" }),
|
|
1730
|
+
]);
|
|
1731
|
+
expect(settled.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
1732
|
+
expect(settled.filter((result) => result.status === "rejected")).toHaveLength(1);
|
|
1733
|
+
expect((settled.find((result) => result.status === "fulfilled") as PromiseFulfilledResult<any>).value.details)
|
|
1734
|
+
.toMatchObject({ name: "same-name", queued: true });
|
|
1735
|
+
expect((settled.find((result) => result.status === "rejected") as PromiseRejectedResult).reason.message)
|
|
1736
|
+
.toContain("nested delegation cannot replace an existing run");
|
|
1737
|
+
|
|
1738
|
+
harness.completions.get("capacity-blocker")!();
|
|
1739
|
+
await vi.waitFor(() => expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(2));
|
|
1740
|
+
expect(harness.runReadAgentInProcess.mock.calls.filter((call) => call[1].name === "same-name")).toHaveLength(1);
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it("rejects a stale same-name queue request after a direct winner releases its reservation", async () => {
|
|
1744
|
+
writeFavoriteLevels();
|
|
1745
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1746
|
+
const harness = registerTools();
|
|
1747
|
+
const parent = await admitNestedReadParent(harness);
|
|
1748
|
+
const ctx = makeCtx();
|
|
1749
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1750
|
+
teamName: "session-test-session",
|
|
1751
|
+
parent,
|
|
1752
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1753
|
+
outerCtx: ctx,
|
|
1754
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1755
|
+
|
|
1756
|
+
const originalFindQueuedWriteSpawn = writeQueue.findQueuedWriteSpawn;
|
|
1757
|
+
let lookupCount = 0;
|
|
1758
|
+
let markWinnerLookupReached!: () => void;
|
|
1759
|
+
const winnerLookupReached = new Promise<void>((resolve) => { markWinnerLookupReached = resolve; });
|
|
1760
|
+
let markLoserLookupReached!: () => void;
|
|
1761
|
+
const loserLookupReached = new Promise<void>((resolve) => { markLoserLookupReached = resolve; });
|
|
1762
|
+
let releaseLoserLookup!: () => void;
|
|
1763
|
+
const loserLookupGate = new Promise<void>((resolve) => { releaseLoserLookup = resolve; });
|
|
1764
|
+
vi.spyOn(writeQueue, "findQueuedWriteSpawn").mockImplementation(async (...args) => {
|
|
1765
|
+
const result = await originalFindQueuedWriteSpawn(...args);
|
|
1766
|
+
lookupCount += 1;
|
|
1767
|
+
if (lookupCount === 1) {
|
|
1768
|
+
markWinnerLookupReached();
|
|
1769
|
+
await loserLookupReached;
|
|
1770
|
+
} else if (lookupCount === 2) {
|
|
1771
|
+
markLoserLookupReached();
|
|
1772
|
+
await loserLookupGate;
|
|
1773
|
+
}
|
|
1774
|
+
return result;
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
const winnerPromise = nestedSpawn.execute("same-name-direct", {
|
|
1778
|
+
name: "same-name-transfer",
|
|
1779
|
+
prompt: "start directly",
|
|
1780
|
+
model_slot: "read-review",
|
|
1781
|
+
});
|
|
1782
|
+
await winnerLookupReached;
|
|
1783
|
+
const loserPromise = nestedSpawn.execute("same-name-stale", {
|
|
1784
|
+
name: "same-name-transfer",
|
|
1785
|
+
prompt: "must not queue from a stale roster",
|
|
1786
|
+
model_slot: "read-review",
|
|
1787
|
+
});
|
|
1788
|
+
await loserLookupReached;
|
|
1789
|
+
|
|
1790
|
+
const settled = await Promise.allSettled([
|
|
1791
|
+
winnerPromise.finally(releaseLoserLookup),
|
|
1792
|
+
loserPromise,
|
|
1793
|
+
]);
|
|
1794
|
+
expect(settled.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
1795
|
+
expect(settled.filter((result) => result.status === "rejected")).toHaveLength(1);
|
|
1796
|
+
expect((settled.find((result) => result.status === "fulfilled") as PromiseFulfilledResult<any>).value.details)
|
|
1797
|
+
.toMatchObject({ name: "same-name-transfer", queued: false });
|
|
1798
|
+
expect((settled.find((result) => result.status === "rejected") as PromiseRejectedResult).reason.message)
|
|
1799
|
+
.toContain("nested delegation cannot replace an existing run");
|
|
1800
|
+
|
|
1801
|
+
const activeRespond = vi.fn();
|
|
1802
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond: activeRespond });
|
|
1803
|
+
expect(activeRespond).toHaveBeenCalledWith({ sessionId: "test-session", running: 2, queued: 0 });
|
|
1804
|
+
expect(harness.runReadAgentInProcess.mock.calls.filter((call) => call[1].name === "same-name-transfer")).toHaveLength(1);
|
|
1805
|
+
|
|
1806
|
+
harness.completions.get("same-name-transfer")!();
|
|
1807
|
+
await vi.waitFor(() => {
|
|
1808
|
+
const completedRespond = vi.fn();
|
|
1809
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond: completedRespond });
|
|
1810
|
+
expect(completedRespond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1811
|
+
});
|
|
1812
|
+
expect(harness.runReadAgentInProcess.mock.calls.filter((call) => call[1].name === "same-name-transfer")).toHaveLength(1);
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
it("preserves a concurrent enqueue while the current queue head is draining", async () => {
|
|
1816
|
+
writeFavoriteLevels();
|
|
1817
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1818
|
+
const harness = registerTools();
|
|
1819
|
+
const parent = await admitNestedReadParent(harness);
|
|
1820
|
+
const ctx = makeCtx();
|
|
1821
|
+
const abort = new AbortController().signal;
|
|
1822
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1823
|
+
name: "capacity-blocker",
|
|
1824
|
+
prompt: "hold capacity",
|
|
1825
|
+
cwd: root,
|
|
1826
|
+
model_slot: "read-review",
|
|
1827
|
+
}, abort, undefined, ctx);
|
|
1828
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1829
|
+
teamName: "session-test-session",
|
|
1830
|
+
parent,
|
|
1831
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1832
|
+
outerCtx: ctx,
|
|
1833
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1834
|
+
await nestedSpawn.execute("queued-one", { name: "queued-one", prompt: "first", model_slot: "read-review" });
|
|
1835
|
+
|
|
1836
|
+
const originalAddMember = teams.addMember;
|
|
1837
|
+
let markAddStarted!: () => void;
|
|
1838
|
+
const addStarted = new Promise<void>((resolve) => { markAddStarted = resolve; });
|
|
1839
|
+
let releaseAdd!: () => void;
|
|
1840
|
+
const addGate = new Promise<void>((resolve) => { releaseAdd = resolve; });
|
|
1841
|
+
vi.spyOn(teams, "addMember").mockImplementation(async (teamName, member) => {
|
|
1842
|
+
if (member.name === "queued-one") {
|
|
1843
|
+
markAddStarted();
|
|
1844
|
+
await addGate;
|
|
1845
|
+
}
|
|
1846
|
+
return originalAddMember(teamName, member);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
harness.completions.get("capacity-blocker")!();
|
|
1850
|
+
await addStarted;
|
|
1851
|
+
const queuedTwo = await harness.tools.get("spawn_agent")!.execute("queued-two", {
|
|
1852
|
+
name: "queued-two",
|
|
1853
|
+
prompt: "second",
|
|
1854
|
+
cwd: root,
|
|
1855
|
+
model_slot: "read-review",
|
|
1856
|
+
}, abort, undefined, ctx);
|
|
1857
|
+
expect(queuedTwo.details).toMatchObject({ queued: true, queuePosition: 2 });
|
|
1858
|
+
releaseAdd();
|
|
1859
|
+
|
|
1860
|
+
await vi.waitFor(() => expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(2));
|
|
1861
|
+
const respond = vi.fn();
|
|
1862
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1863
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 2, queued: 1 });
|
|
1864
|
+
|
|
1865
|
+
harness.completions.get("queued-one")!();
|
|
1866
|
+
await vi.waitFor(() => expect(harness.runReadAgentInProcess).toHaveBeenCalledTimes(3));
|
|
1867
|
+
expect(harness.runReadAgentInProcess.mock.calls[2][1].name).toBe("queued-two");
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
it("releases a queued nested-name reservation when the item is dropped", async () => {
|
|
1871
|
+
writeFavoriteLevels();
|
|
1872
|
+
writeProjectSettings({ readAgents: { maxConcurrent: 1, queueOverflow: true } });
|
|
1873
|
+
const harness = registerTools();
|
|
1874
|
+
const parent = await admitNestedReadParent(harness);
|
|
1875
|
+
const ctx = makeCtx();
|
|
1876
|
+
const abort = new AbortController().signal;
|
|
1877
|
+
await harness.tools.get("spawn_agent")!.execute("blocker", {
|
|
1878
|
+
name: "capacity-blocker",
|
|
1879
|
+
prompt: "hold capacity",
|
|
1880
|
+
cwd: root,
|
|
1881
|
+
model_slot: "read-review",
|
|
1882
|
+
}, abort, undefined, ctx);
|
|
1883
|
+
const nestedSpawn = new Map(harness.teamToolsRuntime.createNestedReadAgentTools({
|
|
1884
|
+
teamName: "session-test-session",
|
|
1885
|
+
parent,
|
|
1886
|
+
parentRunId: parent.lifecycleRunId!,
|
|
1887
|
+
outerCtx: ctx,
|
|
1888
|
+
}).map((tool: any) => [tool.name, tool])).get("spawn_agent")!;
|
|
1889
|
+
await nestedSpawn.execute("reserved-child", {
|
|
1890
|
+
name: "reserved-child",
|
|
1891
|
+
prompt: "queued request",
|
|
1892
|
+
model_slot: "read-review",
|
|
1893
|
+
});
|
|
1894
|
+
const duplicate: Member = {
|
|
1895
|
+
agentId: "reserved-child@session-test-session",
|
|
1896
|
+
name: "reserved-child",
|
|
1897
|
+
agentType: "teammate",
|
|
1898
|
+
role: "read",
|
|
1899
|
+
model: "provider/model",
|
|
1900
|
+
thinking: "xhigh",
|
|
1901
|
+
modelSlot: "read-review",
|
|
1902
|
+
joinedAt: Date.now(),
|
|
1903
|
+
tmuxPaneId: "",
|
|
1904
|
+
cwd: root,
|
|
1905
|
+
subscriptions: [],
|
|
1906
|
+
};
|
|
1907
|
+
await teams.addMember("session-test-session", duplicate);
|
|
1908
|
+
|
|
1909
|
+
harness.completions.get("capacity-blocker")!();
|
|
1910
|
+
await vi.waitFor(() => {
|
|
1911
|
+
const respond = vi.fn();
|
|
1912
|
+
harness.emit(CHILD_AGENT_LIFECYCLE_PROBE, { sessionId: "test-session", respond });
|
|
1913
|
+
expect(respond).toHaveBeenCalledWith({ sessionId: "test-session", running: 1, queued: 0 });
|
|
1914
|
+
});
|
|
1915
|
+
await teams.removeMemberMatchingRun("session-test-session", duplicate.name, duplicate.lifecycleRunId!);
|
|
1916
|
+
|
|
1917
|
+
await expect(nestedSpawn.execute("reserved-child-retry", {
|
|
1918
|
+
name: "reserved-child",
|
|
1919
|
+
prompt: "reservation must be reusable",
|
|
1920
|
+
model_slot: "read-review",
|
|
1921
|
+
})).resolves.toMatchObject({ details: { name: "reserved-child", queued: false } });
|
|
1922
|
+
expect(harness.runReadAgentInProcess.mock.calls.filter((call) => call[1].name === "reserved-child")).toHaveLength(1);
|
|
1923
|
+
});
|
|
1924
|
+
});
|