pi-herdr-subagents 0.1.0
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/.github/workflows/publish.yml +55 -0
- package/.pi/settings.json +8 -0
- package/.pi/skills/run-integration-tests/SKILL.md +28 -0
- package/LICENSE +21 -0
- package/README.md +483 -0
- package/RELEASING.md +103 -0
- package/agents/planner.md +546 -0
- package/agents/reviewer.md +150 -0
- package/agents/scout.md +104 -0
- package/agents/visual-tester.md +197 -0
- package/agents/worker.md +103 -0
- package/config.json.example +5 -0
- package/package.json +34 -0
- package/pi-extension/subagents/activity.ts +511 -0
- package/pi-extension/subagents/completion.ts +114 -0
- package/pi-extension/subagents/herdr.ts +200 -0
- package/pi-extension/subagents/index.ts +2182 -0
- package/pi-extension/subagents/plan-skill.md +203 -0
- package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
- package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
- package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
- package/pi-extension/subagents/session.ts +180 -0
- package/pi-extension/subagents/status.ts +513 -0
- package/pi-extension/subagents/subagent-done.ts +324 -0
- package/pi-extension/subagents/terminal.ts +106 -0
- package/test/integration/agents/test-echo.md +13 -0
- package/test/integration/agents/test-ping.md +11 -0
- package/test/integration/harness.ts +319 -0
- package/test/integration/mux-surface.test.ts +225 -0
- package/test/integration/subagent-lifecycle.test.ts +329 -0
- package/test/system-prompt-mode.test.ts +163 -0
- package/test/test.ts +2190 -0
package/test/test.ts
ADDED
|
@@ -0,0 +1,2190 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync, mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import * as subagentsModule from "../pi-extension/subagents/index.ts";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getLeafId,
|
|
12
|
+
getNewEntries,
|
|
13
|
+
findLastAssistantMessage,
|
|
14
|
+
appendBranchSummary,
|
|
15
|
+
copySessionFile,
|
|
16
|
+
mergeNewEntries,
|
|
17
|
+
seedSubagentSessionFile,
|
|
18
|
+
} from "../pi-extension/subagents/session.ts";
|
|
19
|
+
|
|
20
|
+
import { shellQuote } from "../pi-extension/subagents/terminal.ts";
|
|
21
|
+
import { isHerdrAvailable, __herdrTest__ } from "../pi-extension/subagents/herdr.ts";
|
|
22
|
+
import {
|
|
23
|
+
advanceStatusState,
|
|
24
|
+
capStatusLines,
|
|
25
|
+
classifyStatus,
|
|
26
|
+
createStatusState,
|
|
27
|
+
forceStatusAfterInterrupt,
|
|
28
|
+
formatStatusAggregate,
|
|
29
|
+
formatStatusLine,
|
|
30
|
+
formatTransitionLine,
|
|
31
|
+
observeStatus,
|
|
32
|
+
loadStatusConfig,
|
|
33
|
+
parseStatusConfig,
|
|
34
|
+
} from "../pi-extension/subagents/status.ts";
|
|
35
|
+
import {
|
|
36
|
+
createSubagentActivityRecorder,
|
|
37
|
+
getSubagentActivityFile,
|
|
38
|
+
readSubagentActivityFile,
|
|
39
|
+
} from "../pi-extension/subagents/activity.ts";
|
|
40
|
+
import {
|
|
41
|
+
shouldMarkUserTookOver,
|
|
42
|
+
shouldAutoExitOnAgentEnd,
|
|
43
|
+
findLatestAssistantError,
|
|
44
|
+
} from "../pi-extension/subagents/subagent-done.ts";
|
|
45
|
+
import { interpretExitSidecar, waitForCompletion } from "../pi-extension/subagents/completion.ts";
|
|
46
|
+
|
|
47
|
+
// --- Helpers ---
|
|
48
|
+
|
|
49
|
+
function createTestDir(): string {
|
|
50
|
+
return mkdtempSync(join(tmpdir(), "subagents-test-"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createSessionFile(dir: string, entries: object[]): string {
|
|
54
|
+
const file = join(dir, "test-session.jsonl");
|
|
55
|
+
const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
56
|
+
writeFileSync(file, content);
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function withTempDir(run: (dir: string) => void) {
|
|
61
|
+
const dir = createTestDir();
|
|
62
|
+
try {
|
|
63
|
+
run(dir);
|
|
64
|
+
} finally {
|
|
65
|
+
rmSync(dir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createMockExtensionApi() {
|
|
70
|
+
const registeredTools: Array<any> = [];
|
|
71
|
+
const registeredCommands: Array<any> = [];
|
|
72
|
+
const registeredMessageRenderers: Array<any> = [];
|
|
73
|
+
const sentUserMessages: string[] = [];
|
|
74
|
+
const sentMessages: Array<any> = [];
|
|
75
|
+
return {
|
|
76
|
+
registeredTools,
|
|
77
|
+
registeredCommands,
|
|
78
|
+
registeredMessageRenderers,
|
|
79
|
+
sentUserMessages,
|
|
80
|
+
sentMessages,
|
|
81
|
+
api: {
|
|
82
|
+
on() {},
|
|
83
|
+
registerTool(tool: any) {
|
|
84
|
+
registeredTools.push(tool);
|
|
85
|
+
},
|
|
86
|
+
registerCommand(name: string, command: any) {
|
|
87
|
+
registeredCommands.push({ name, ...command });
|
|
88
|
+
},
|
|
89
|
+
registerMessageRenderer(name: string, renderer: any) {
|
|
90
|
+
registeredMessageRenderers.push({ name, renderer });
|
|
91
|
+
},
|
|
92
|
+
registerShortcut() {},
|
|
93
|
+
sendUserMessage(message: string) {
|
|
94
|
+
sentUserMessages.push(message);
|
|
95
|
+
},
|
|
96
|
+
sendMessage(message: any, options?: any) {
|
|
97
|
+
sentMessages.push({ message, options });
|
|
98
|
+
},
|
|
99
|
+
getAllTools() {
|
|
100
|
+
return [];
|
|
101
|
+
},
|
|
102
|
+
} as any,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function restoreEnvVar(name: string, value: string | undefined) {
|
|
107
|
+
if (value === undefined) {
|
|
108
|
+
delete process.env[name];
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
process.env[name] = value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function withMockedNow<T>(now: number, fn: () => T): T {
|
|
115
|
+
const originalNow = Date.now;
|
|
116
|
+
Date.now = () => now;
|
|
117
|
+
try {
|
|
118
|
+
return fn();
|
|
119
|
+
} finally {
|
|
120
|
+
Date.now = originalNow;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeAgentFile(
|
|
125
|
+
agentsDir: string,
|
|
126
|
+
name: string,
|
|
127
|
+
frontmatter: string,
|
|
128
|
+
body = "You are a test agent.",
|
|
129
|
+
) {
|
|
130
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
131
|
+
writeFileSync(join(agentsDir, `${name}.md`), `---\n${frontmatter}\n---\n\n${body}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function withIsolatedAgentEnv(
|
|
135
|
+
fn: (paths: {
|
|
136
|
+
projectDir: string;
|
|
137
|
+
projectAgentsDir: string;
|
|
138
|
+
globalDir: string;
|
|
139
|
+
globalAgentsDir: string;
|
|
140
|
+
}) => Promise<void> | void,
|
|
141
|
+
) {
|
|
142
|
+
const root = createTestDir();
|
|
143
|
+
const previousCwd = process.cwd();
|
|
144
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
145
|
+
const projectDir = join(root, "project");
|
|
146
|
+
const projectAgentsDir = join(projectDir, ".pi", "agents");
|
|
147
|
+
const globalDir = join(root, "global");
|
|
148
|
+
const globalAgentsDir = join(globalDir, "agents");
|
|
149
|
+
|
|
150
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
151
|
+
mkdirSync(globalAgentsDir, { recursive: true });
|
|
152
|
+
process.chdir(projectDir);
|
|
153
|
+
process.env.PI_CODING_AGENT_DIR = globalDir;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await fn({ projectDir, projectAgentsDir, globalDir, globalAgentsDir });
|
|
157
|
+
} finally {
|
|
158
|
+
process.chdir(previousCwd);
|
|
159
|
+
restoreEnvVar("PI_CODING_AGENT_DIR", previousAgentDir);
|
|
160
|
+
rmSync(root, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const SESSION_HEADER = { type: "session", id: "sess-001", version: 3 };
|
|
164
|
+
const MODEL_CHANGE = { type: "model_change", id: "mc-001", parentId: null };
|
|
165
|
+
const USER_MSG = {
|
|
166
|
+
type: "message",
|
|
167
|
+
id: "user-001",
|
|
168
|
+
parentId: "mc-001",
|
|
169
|
+
message: {
|
|
170
|
+
role: "user",
|
|
171
|
+
content: [{ type: "text", text: "Hello, plan something" }],
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
const ASSISTANT_MSG = {
|
|
175
|
+
type: "message",
|
|
176
|
+
id: "asst-001",
|
|
177
|
+
parentId: "user-001",
|
|
178
|
+
message: {
|
|
179
|
+
role: "assistant",
|
|
180
|
+
content: [{ type: "text", text: "Here is my plan..." }],
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
const ASSISTANT_MSG_2 = {
|
|
184
|
+
type: "message",
|
|
185
|
+
id: "asst-002",
|
|
186
|
+
parentId: "asst-001",
|
|
187
|
+
message: {
|
|
188
|
+
role: "assistant",
|
|
189
|
+
content: [
|
|
190
|
+
{ type: "thinking", thinking: "Let me think..." },
|
|
191
|
+
{ type: "text", text: "Updated plan with details." },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const TOOL_RESULT = {
|
|
196
|
+
type: "message",
|
|
197
|
+
id: "tool-001",
|
|
198
|
+
parentId: "asst-001",
|
|
199
|
+
message: {
|
|
200
|
+
role: "toolResult",
|
|
201
|
+
toolCallId: "tc-001",
|
|
202
|
+
toolName: "bash",
|
|
203
|
+
content: [{ type: "text", text: "output here" }],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// --- Tests ---
|
|
208
|
+
|
|
209
|
+
describe("session.ts", () => {
|
|
210
|
+
let dir: string;
|
|
211
|
+
|
|
212
|
+
before(() => {
|
|
213
|
+
dir = createTestDir();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
after(() => {
|
|
217
|
+
rmSync(dir, { recursive: true, force: true });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("getLeafId", () => {
|
|
221
|
+
it("returns last entry id", () => {
|
|
222
|
+
const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]);
|
|
223
|
+
assert.equal(getLeafId(file), "asst-001");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns null for empty file", () => {
|
|
227
|
+
const file = join(dir, "empty.jsonl");
|
|
228
|
+
writeFileSync(file, "");
|
|
229
|
+
assert.equal(getLeafId(file), null);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("getNewEntries", () => {
|
|
234
|
+
it("returns entries after a given line", () => {
|
|
235
|
+
const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]);
|
|
236
|
+
const entries = getNewEntries(file, 2);
|
|
237
|
+
assert.equal(entries.length, 2);
|
|
238
|
+
assert.equal(entries[0].id, "user-001");
|
|
239
|
+
assert.equal(entries[1].id, "asst-001");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns empty array when no new entries", () => {
|
|
243
|
+
const file = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE]);
|
|
244
|
+
const entries = getNewEntries(file, 2);
|
|
245
|
+
assert.equal(entries.length, 0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("findLastAssistantMessage", () => {
|
|
250
|
+
it("finds last assistant text", () => {
|
|
251
|
+
const entries = [USER_MSG, ASSISTANT_MSG, ASSISTANT_MSG_2] as any[];
|
|
252
|
+
const text = findLastAssistantMessage(entries);
|
|
253
|
+
assert.equal(text, "Updated plan with details.");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("skips thinking blocks, gets text only", () => {
|
|
257
|
+
const entries = [ASSISTANT_MSG_2] as any[];
|
|
258
|
+
const text = findLastAssistantMessage(entries);
|
|
259
|
+
assert.equal(text, "Updated plan with details.");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("skips tool results", () => {
|
|
263
|
+
const entries = [ASSISTANT_MSG, TOOL_RESULT] as any[];
|
|
264
|
+
const text = findLastAssistantMessage(entries);
|
|
265
|
+
assert.equal(text, "Here is my plan...");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("returns null when no assistant messages", () => {
|
|
269
|
+
const entries = [USER_MSG] as any[];
|
|
270
|
+
assert.equal(findLastAssistantMessage(entries), null);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("returns null for empty array", () => {
|
|
274
|
+
assert.equal(findLastAssistantMessage([]), null);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("skips empty assistant messages and returns real content above", () => {
|
|
278
|
+
const realMsg = {
|
|
279
|
+
type: "message",
|
|
280
|
+
message: {
|
|
281
|
+
role: "assistant",
|
|
282
|
+
content: [{ type: "text", text: "Real summary content." }],
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
const emptyMsg = {
|
|
286
|
+
type: "message",
|
|
287
|
+
message: {
|
|
288
|
+
role: "assistant",
|
|
289
|
+
content: [],
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
const entries = [realMsg, emptyMsg] as any[];
|
|
293
|
+
assert.equal(findLastAssistantMessage(entries), "Real summary content.");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("surfaces errorMessage when last assistant ended with stopReason=error and no text", () => {
|
|
297
|
+
// Reproduces the overload-exhaustion case: an earlier turn looked
|
|
298
|
+
// normal, then the provider went 529 and auto-retry gave up. Without
|
|
299
|
+
// the errorMessage fallback we'd return the stale earlier summary and
|
|
300
|
+
// the orchestrator would believe the subagent completed.
|
|
301
|
+
const earlierGood = {
|
|
302
|
+
type: "message",
|
|
303
|
+
message: {
|
|
304
|
+
role: "assistant",
|
|
305
|
+
content: [{ type: "text", text: "Investigating the bug..." }],
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
const overloadError = {
|
|
309
|
+
type: "message",
|
|
310
|
+
message: {
|
|
311
|
+
role: "assistant",
|
|
312
|
+
content: [],
|
|
313
|
+
stopReason: "error",
|
|
314
|
+
errorMessage: "Anthropic 529 Overloaded after 3 retries",
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
const entries = [earlierGood, overloadError] as any[];
|
|
318
|
+
assert.equal(
|
|
319
|
+
findLastAssistantMessage(entries),
|
|
320
|
+
"Subagent error: Anthropic 529 Overloaded after 3 retries",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("prefers text content even when an error stopReason is set", () => {
|
|
325
|
+
// If the model produced text before the error (rare but possible), we
|
|
326
|
+
// prefer the actual content over the synthetic error fallback.
|
|
327
|
+
const msg = {
|
|
328
|
+
type: "message",
|
|
329
|
+
message: {
|
|
330
|
+
role: "assistant",
|
|
331
|
+
content: [{ type: "text", text: "Here is partial output." }],
|
|
332
|
+
stopReason: "error",
|
|
333
|
+
errorMessage: "stream interrupted",
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
assert.equal(findLastAssistantMessage([msg] as any[]), "Here is partial output.");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("does not invent a summary for a stop=error message with no errorMessage", () => {
|
|
340
|
+
const msg = {
|
|
341
|
+
type: "message",
|
|
342
|
+
message: {
|
|
343
|
+
role: "assistant",
|
|
344
|
+
content: [],
|
|
345
|
+
stopReason: "error",
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
assert.equal(findLastAssistantMessage([msg] as any[]), null);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("appendBranchSummary", () => {
|
|
353
|
+
it("appends valid branch_summary entry", () => {
|
|
354
|
+
const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG, ASSISTANT_MSG]);
|
|
355
|
+
const id = appendBranchSummary(file, "user-001", "asst-001", "The plan was created.");
|
|
356
|
+
|
|
357
|
+
assert.ok(id, "should return an id");
|
|
358
|
+
assert.equal(typeof id, "string");
|
|
359
|
+
|
|
360
|
+
// Read back and verify
|
|
361
|
+
const lines = readFileSync(file, "utf8").trim().split("\n");
|
|
362
|
+
assert.equal(lines.length, 4); // 3 original + 1 summary
|
|
363
|
+
|
|
364
|
+
const summary = JSON.parse(lines[3]);
|
|
365
|
+
assert.equal(summary.type, "branch_summary");
|
|
366
|
+
assert.equal(summary.id, id);
|
|
367
|
+
assert.equal(summary.parentId, "user-001");
|
|
368
|
+
assert.equal(summary.fromId, "asst-001");
|
|
369
|
+
assert.equal(summary.summary, "The plan was created.");
|
|
370
|
+
assert.ok(summary.timestamp);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("uses branchPointId as fromId fallback", () => {
|
|
374
|
+
const file = createSessionFile(dir, [SESSION_HEADER]);
|
|
375
|
+
appendBranchSummary(file, "branch-pt", null, "summary");
|
|
376
|
+
|
|
377
|
+
const lines = readFileSync(file, "utf8").trim().split("\n");
|
|
378
|
+
const summary = JSON.parse(lines[1]);
|
|
379
|
+
assert.equal(summary.fromId, "branch-pt");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("copySessionFile", () => {
|
|
384
|
+
it("creates a copy with different path", () => {
|
|
385
|
+
const file = createSessionFile(dir, [SESSION_HEADER, USER_MSG]);
|
|
386
|
+
const copyDir = join(dir, "copies");
|
|
387
|
+
mkdirSync(copyDir, { recursive: true });
|
|
388
|
+
const copy = copySessionFile(file, copyDir);
|
|
389
|
+
|
|
390
|
+
assert.notEqual(copy, file);
|
|
391
|
+
assert.ok(copy.endsWith(".jsonl"));
|
|
392
|
+
assert.equal(readFileSync(copy, "utf8"), readFileSync(file, "utf8"));
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("seedSubagentSessionFile", () => {
|
|
397
|
+
it("creates a lineage-only child session with parent linkage and no copied turns", () => {
|
|
398
|
+
const parentFile = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]);
|
|
399
|
+
const childFile = join(dir, "lineage-child.jsonl");
|
|
400
|
+
|
|
401
|
+
seedSubagentSessionFile({
|
|
402
|
+
mode: "lineage-only",
|
|
403
|
+
parentSessionFile: parentFile,
|
|
404
|
+
childSessionFile: childFile,
|
|
405
|
+
childCwd: "/tmp/child-cwd",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const lines = readFileSync(childFile, "utf8").trim().split("\n");
|
|
409
|
+
assert.equal(lines.length, 1);
|
|
410
|
+
|
|
411
|
+
const header = JSON.parse(lines[0]);
|
|
412
|
+
assert.equal(header.type, "session");
|
|
413
|
+
assert.equal(header.parentSession, parentFile);
|
|
414
|
+
assert.equal(header.cwd, "/tmp/child-cwd");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("creates a forked child session with copied context before the triggering user turn", () => {
|
|
418
|
+
const parentFile = createSessionFile(dir, [SESSION_HEADER, MODEL_CHANGE, USER_MSG, ASSISTANT_MSG]);
|
|
419
|
+
const childFile = join(dir, "fork-child.jsonl");
|
|
420
|
+
|
|
421
|
+
seedSubagentSessionFile({
|
|
422
|
+
mode: "fork",
|
|
423
|
+
parentSessionFile: parentFile,
|
|
424
|
+
childSessionFile: childFile,
|
|
425
|
+
childCwd: "/tmp/fork-child-cwd",
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const entries = readFileSync(childFile, "utf8")
|
|
429
|
+
.trim()
|
|
430
|
+
.split("\n")
|
|
431
|
+
.map((line) => JSON.parse(line));
|
|
432
|
+
assert.equal(entries.length, 2);
|
|
433
|
+
assert.equal(entries[0].type, "session");
|
|
434
|
+
assert.equal(entries[0].parentSession, parentFile);
|
|
435
|
+
assert.equal(entries[0].cwd, "/tmp/fork-child-cwd");
|
|
436
|
+
assert.equal(entries[1].type, "model_change");
|
|
437
|
+
assert.equal(entries.some((entry) => entry.type === "session" && entry.parentSession !== parentFile), false);
|
|
438
|
+
assert.equal(entries.some((entry) => entry.type === "message"), false);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("mergeNewEntries", () => {
|
|
443
|
+
it("appends new entries from source to target", () => {
|
|
444
|
+
// Source starts with same base (2 entries), then has 1 new entry
|
|
445
|
+
const sourceFile = join(dir, "merge-source.jsonl");
|
|
446
|
+
const targetFile = join(dir, "merge-target.jsonl");
|
|
447
|
+
writeFileSync(
|
|
448
|
+
sourceFile,
|
|
449
|
+
[SESSION_HEADER, USER_MSG, ASSISTANT_MSG].map((e) => JSON.stringify(e)).join("\n") + "\n",
|
|
450
|
+
);
|
|
451
|
+
writeFileSync(
|
|
452
|
+
targetFile,
|
|
453
|
+
[SESSION_HEADER, USER_MSG].map((e) => JSON.stringify(e)).join("\n") + "\n",
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// Merge entries after line 2 (the shared base)
|
|
457
|
+
const merged = mergeNewEntries(sourceFile, targetFile, 2);
|
|
458
|
+
assert.equal(merged.length, 1);
|
|
459
|
+
assert.equal(merged[0].id, "asst-001");
|
|
460
|
+
|
|
461
|
+
// Target should now have 3 entries
|
|
462
|
+
const targetLines = readFileSync(targetFile, "utf8").trim().split("\n");
|
|
463
|
+
assert.equal(targetLines.length, 3);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe("status.ts", () => {
|
|
469
|
+
it("parses strict config objects", () => {
|
|
470
|
+
const disabled = parseStatusConfig({ status: { enabled: false } });
|
|
471
|
+
|
|
472
|
+
assert.deepEqual(disabled, {
|
|
473
|
+
enabled: false,
|
|
474
|
+
lineLimit: 4,
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("loads a valid config file", () => {
|
|
479
|
+
const examplePath = fileURLToPath(new URL("../config.json.example", import.meta.url));
|
|
480
|
+
const config = loadStatusConfig(examplePath);
|
|
481
|
+
|
|
482
|
+
assert.deepEqual(config, {
|
|
483
|
+
enabled: true,
|
|
484
|
+
lineLimit: 4,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("loads the shared example when local config is absent", () => {
|
|
489
|
+
withTempDir((dir) => {
|
|
490
|
+
const examplePath = join(dir, "config.json.example");
|
|
491
|
+
writeFileSync(
|
|
492
|
+
examplePath,
|
|
493
|
+
JSON.stringify({ status: { enabled: true } }, null, 2) + "\n",
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const config = loadStatusConfig(join(dir, "config.json"), examplePath);
|
|
497
|
+
|
|
498
|
+
assert.deepEqual(config, {
|
|
499
|
+
enabled: true,
|
|
500
|
+
lineLimit: 4,
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("fails fast for invalid config shapes", () => {
|
|
506
|
+
assert.throws(
|
|
507
|
+
() => parseStatusConfig({ status: { enabled: "false" } }),
|
|
508
|
+
/status\.enabled must be a boolean/,
|
|
509
|
+
);
|
|
510
|
+
assert.throws(
|
|
511
|
+
() => parseStatusConfig({ status: { enabled: true, defaultCadenceSeconds: 60 } }),
|
|
512
|
+
/status has unsupported key\(s\): defaultCadenceSeconds/,
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("reports when neither local nor shared config exists", () => {
|
|
517
|
+
withTempDir((dir) => {
|
|
518
|
+
assert.throws(
|
|
519
|
+
() => loadStatusConfig(join(dir, "config.json"), join(dir, "config.json.example")),
|
|
520
|
+
/Missing subagent status config\. Expected .*config\.json.*or.*config\.json\.example/,
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("reports invalid JSON from the shared example path", () => {
|
|
526
|
+
withTempDir((dir) => {
|
|
527
|
+
const examplePath = join(dir, "config.json.example");
|
|
528
|
+
writeFileSync(examplePath, "{\n");
|
|
529
|
+
|
|
530
|
+
assert.throws(
|
|
531
|
+
() => loadStatusConfig(join(dir, "config.json"), examplePath),
|
|
532
|
+
/Invalid JSON in subagent config .*config\.json\.example/,
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("fails on invalid local config instead of falling back to the shared example", () => {
|
|
538
|
+
withTempDir((dir) => {
|
|
539
|
+
const configPath = join(dir, "config.json");
|
|
540
|
+
const examplePath = join(dir, "config.json.example");
|
|
541
|
+
writeFileSync(configPath, "{\n");
|
|
542
|
+
writeFileSync(
|
|
543
|
+
examplePath,
|
|
544
|
+
JSON.stringify({ status: { enabled: true } }, null, 2) + "\n",
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
assert.throws(
|
|
548
|
+
() => loadStatusConfig(configPath, examplePath),
|
|
549
|
+
/Invalid JSON in subagent config .*config\.json/,
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("keeps a missing snapshot as starting until the fixed watchdog threshold", () => {
|
|
555
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
556
|
+
state = observeStatus(state, { snapshot: "missing" }, 1_000);
|
|
557
|
+
|
|
558
|
+
assert.equal(classifyStatus(state, 60_999).kind, "starting");
|
|
559
|
+
const stalled = classifyStatus(state, 61_000);
|
|
560
|
+
assert.equal(stalled.kind, "stalled");
|
|
561
|
+
assert.equal(stalled.statusLabel, null);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("classifies active snapshots without aging into stalled", () => {
|
|
565
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
566
|
+
state = observeStatus(state, {
|
|
567
|
+
snapshot: "present",
|
|
568
|
+
updatedAt: 5_000,
|
|
569
|
+
sequence: 1,
|
|
570
|
+
phase: "active",
|
|
571
|
+
active: true,
|
|
572
|
+
activeScope: "tool",
|
|
573
|
+
activeSince: 5_000,
|
|
574
|
+
activityLabel: "bash",
|
|
575
|
+
latestEvent: "tool_execution_start",
|
|
576
|
+
}, 5_000);
|
|
577
|
+
|
|
578
|
+
const snapshot = classifyStatus(state, 240_000);
|
|
579
|
+
assert.equal(snapshot.kind, "active");
|
|
580
|
+
assert.equal(snapshot.activityLabel, "bash");
|
|
581
|
+
assert.equal(snapshot.activeDurationText, "3m");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("classifies waiting snapshots as healthy idle without becoming stalled", () => {
|
|
585
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
586
|
+
state = observeStatus(state, {
|
|
587
|
+
snapshot: "present",
|
|
588
|
+
updatedAt: 10_000,
|
|
589
|
+
sequence: 1,
|
|
590
|
+
phase: "waiting",
|
|
591
|
+
waitingSince: 10_000,
|
|
592
|
+
latestEvent: "agent_end",
|
|
593
|
+
}, 10_000);
|
|
594
|
+
|
|
595
|
+
const snapshot = classifyStatus(state, 240_000);
|
|
596
|
+
assert.equal(snapshot.kind, "waiting");
|
|
597
|
+
assert.equal(snapshot.waitingDurationText, "3m");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("uses elapsed-only fallback for claude-backed subagents", () => {
|
|
601
|
+
const state = createStatusState({ source: "claude", startTimeMs: 0 });
|
|
602
|
+
const snapshot = classifyStatus(state, 125_000);
|
|
603
|
+
|
|
604
|
+
assert.equal(snapshot.kind, "running");
|
|
605
|
+
assert.equal(snapshot.elapsedText, "2m");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("detects stalled transitions and recovery", () => {
|
|
609
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
610
|
+
state = observeStatus(state, { snapshot: "missing" }, 1_000);
|
|
611
|
+
|
|
612
|
+
let advanced = advanceStatusState(state, 95_000);
|
|
613
|
+
assert.equal(advanced.transition, "stalled");
|
|
614
|
+
assert.equal(advanced.snapshot.kind, "stalled");
|
|
615
|
+
|
|
616
|
+
state = observeStatus(advanced.nextState, {
|
|
617
|
+
snapshot: "present",
|
|
618
|
+
updatedAt: 96_000,
|
|
619
|
+
sequence: 1,
|
|
620
|
+
phase: "waiting",
|
|
621
|
+
waitingSince: 96_000,
|
|
622
|
+
latestEvent: "agent_end",
|
|
623
|
+
}, 96_000);
|
|
624
|
+
advanced = advanceStatusState(state, 97_000);
|
|
625
|
+
assert.equal(advanced.transition, "recovered");
|
|
626
|
+
assert.equal(advanced.snapshot.kind, "waiting");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("keeps the last healthy kind during transient snapshot loss", () => {
|
|
630
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
631
|
+
state = observeStatus(state, {
|
|
632
|
+
snapshot: "present",
|
|
633
|
+
updatedAt: 5_000,
|
|
634
|
+
sequence: 1,
|
|
635
|
+
phase: "active",
|
|
636
|
+
active: true,
|
|
637
|
+
activeScope: "streaming",
|
|
638
|
+
activeSince: 5_000,
|
|
639
|
+
}, 5_000);
|
|
640
|
+
state = advanceStatusState(state, 6_000).nextState;
|
|
641
|
+
state = observeStatus(state, { snapshot: "missing" }, 10_000);
|
|
642
|
+
|
|
643
|
+
const snapshot = classifyStatus(state, 20_000);
|
|
644
|
+
assert.equal(snapshot.kind, "active");
|
|
645
|
+
assert.equal(snapshot.statusLabel, null);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("forces an active state to waiting after interrupt", () => {
|
|
649
|
+
const now = 20_000;
|
|
650
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
651
|
+
state = observeStatus(state, {
|
|
652
|
+
snapshot: "present",
|
|
653
|
+
updatedAt: 5_000,
|
|
654
|
+
sequence: 1,
|
|
655
|
+
phase: "active",
|
|
656
|
+
active: true,
|
|
657
|
+
activeScope: "tool",
|
|
658
|
+
activeSince: 5_000,
|
|
659
|
+
activityLabel: "bash",
|
|
660
|
+
}, 5_000);
|
|
661
|
+
|
|
662
|
+
assert.equal(classifyStatus(state, now).kind, "active");
|
|
663
|
+
|
|
664
|
+
const forced = forceStatusAfterInterrupt(state, now);
|
|
665
|
+
const snapshot = classifyStatus(forced, now);
|
|
666
|
+
|
|
667
|
+
assert.equal(snapshot.kind, "waiting");
|
|
668
|
+
assert.equal(snapshot.activityLabel, "interrupted");
|
|
669
|
+
assert.equal(snapshot.waitingDurationText, "0s");
|
|
670
|
+
assert.equal(forced.activeNow, false);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("orders same-millisecond snapshots by sequence", () => {
|
|
674
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
675
|
+
state = observeStatus(state, {
|
|
676
|
+
snapshot: "present",
|
|
677
|
+
updatedAt: 10_000,
|
|
678
|
+
sequence: 2,
|
|
679
|
+
phase: "active",
|
|
680
|
+
active: true,
|
|
681
|
+
activeScope: "tool",
|
|
682
|
+
activeSince: 10_000,
|
|
683
|
+
activityLabel: "bash",
|
|
684
|
+
}, 10_000);
|
|
685
|
+
|
|
686
|
+
state = observeStatus(state, {
|
|
687
|
+
snapshot: "present",
|
|
688
|
+
updatedAt: 10_000,
|
|
689
|
+
sequence: 3,
|
|
690
|
+
phase: "waiting",
|
|
691
|
+
waitingSince: 10_000,
|
|
692
|
+
latestEvent: "agent_end",
|
|
693
|
+
}, 10_001);
|
|
694
|
+
|
|
695
|
+
const snapshot = classifyStatus(state, 11_000);
|
|
696
|
+
assert.equal(snapshot.kind, "waiting");
|
|
697
|
+
assert.equal(snapshot.latestEvent, "agent_end");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("recovers from a transient snapshot read failure with the same valid snapshot", () => {
|
|
701
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
702
|
+
state = observeStatus(state, {
|
|
703
|
+
snapshot: "present",
|
|
704
|
+
updatedAt: 5_000,
|
|
705
|
+
sequence: 2,
|
|
706
|
+
phase: "active",
|
|
707
|
+
active: true,
|
|
708
|
+
activeScope: "tool",
|
|
709
|
+
activeSince: 5_000,
|
|
710
|
+
activityLabel: "bash",
|
|
711
|
+
}, 5_000);
|
|
712
|
+
state = observeStatus(state, { snapshot: "missing" }, 10_000);
|
|
713
|
+
assert.equal(classifyStatus(state, 10_000).statusLabel, null);
|
|
714
|
+
|
|
715
|
+
state = observeStatus(state, {
|
|
716
|
+
snapshot: "present",
|
|
717
|
+
updatedAt: 5_000,
|
|
718
|
+
sequence: 2,
|
|
719
|
+
phase: "active",
|
|
720
|
+
active: true,
|
|
721
|
+
activeScope: "tool",
|
|
722
|
+
activeSince: 5_000,
|
|
723
|
+
activityLabel: "bash",
|
|
724
|
+
}, 11_000);
|
|
725
|
+
|
|
726
|
+
const snapshot = classifyStatus(state, 11_000);
|
|
727
|
+
assert.equal(snapshot.kind, "active");
|
|
728
|
+
assert.equal(snapshot.statusLabel, null);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("ignores stale and exact old snapshots after interrupt and accepts newer snapshots", () => {
|
|
732
|
+
let state = createStatusState({ source: "pi", startTimeMs: 0 });
|
|
733
|
+
state = observeStatus(state, {
|
|
734
|
+
snapshot: "present",
|
|
735
|
+
updatedAt: 5_000,
|
|
736
|
+
sequence: 1,
|
|
737
|
+
phase: "active",
|
|
738
|
+
active: true,
|
|
739
|
+
activeScope: "tool",
|
|
740
|
+
activeSince: 5_000,
|
|
741
|
+
activityLabel: "bash",
|
|
742
|
+
}, 5_000);
|
|
743
|
+
state = forceStatusAfterInterrupt(state, 20_000);
|
|
744
|
+
|
|
745
|
+
const stale = observeStatus(state, {
|
|
746
|
+
snapshot: "present",
|
|
747
|
+
updatedAt: 5_000,
|
|
748
|
+
sequence: 1,
|
|
749
|
+
phase: "active",
|
|
750
|
+
active: true,
|
|
751
|
+
activeScope: "tool",
|
|
752
|
+
activeSince: 5_000,
|
|
753
|
+
activityLabel: "bash",
|
|
754
|
+
}, 21_000);
|
|
755
|
+
let snapshot = classifyStatus(stale, 21_000);
|
|
756
|
+
assert.equal(snapshot.kind, "waiting");
|
|
757
|
+
assert.equal(snapshot.activityLabel, "interrupted");
|
|
758
|
+
|
|
759
|
+
const sameTimestamp = observeStatus(stale, {
|
|
760
|
+
snapshot: "present",
|
|
761
|
+
updatedAt: 20_000,
|
|
762
|
+
sequence: 1,
|
|
763
|
+
phase: "active",
|
|
764
|
+
active: true,
|
|
765
|
+
activeScope: "tool",
|
|
766
|
+
activeSince: 20_000,
|
|
767
|
+
activityLabel: "bash",
|
|
768
|
+
}, 22_000);
|
|
769
|
+
snapshot = classifyStatus(sameTimestamp, 22_000);
|
|
770
|
+
assert.equal(snapshot.kind, "waiting");
|
|
771
|
+
assert.equal(snapshot.activityLabel, "interrupted");
|
|
772
|
+
|
|
773
|
+
const resumed = observeStatus(sameTimestamp, {
|
|
774
|
+
snapshot: "present",
|
|
775
|
+
sequence: 2,
|
|
776
|
+
updatedAt: 25_000,
|
|
777
|
+
phase: "active",
|
|
778
|
+
active: true,
|
|
779
|
+
activeScope: "streaming",
|
|
780
|
+
activeSince: 25_000,
|
|
781
|
+
activityLabel: "streaming",
|
|
782
|
+
}, 25_000);
|
|
783
|
+
snapshot = classifyStatus(resumed, 25_000);
|
|
784
|
+
assert.equal(snapshot.kind, "active");
|
|
785
|
+
assert.equal(resumed.activeScope, "streaming");
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("normalizes and truncates long newline-heavy names", () => {
|
|
789
|
+
const longName = `Worker\n\n${"very-long-name-".repeat(12)}`;
|
|
790
|
+
const stalledState = observeStatus(
|
|
791
|
+
createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
792
|
+
{ snapshot: "missing" },
|
|
793
|
+
1_000,
|
|
794
|
+
);
|
|
795
|
+
const activeState = observeStatus(
|
|
796
|
+
createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
797
|
+
{
|
|
798
|
+
snapshot: "present",
|
|
799
|
+
updatedAt: 299_000,
|
|
800
|
+
sequence: 1,
|
|
801
|
+
phase: "active",
|
|
802
|
+
active: true,
|
|
803
|
+
activeScope: "tool",
|
|
804
|
+
activeSince: 299_000,
|
|
805
|
+
activityLabel: "write",
|
|
806
|
+
},
|
|
807
|
+
299_000,
|
|
808
|
+
);
|
|
809
|
+
const line = formatStatusLine(longName, classifyStatus(stalledState, 240_000));
|
|
810
|
+
const recovered = formatTransitionLine(longName, classifyStatus(activeState, 300_000), "recovered");
|
|
811
|
+
|
|
812
|
+
assert.doesNotMatch(line, /\n/);
|
|
813
|
+
assert.doesNotMatch(recovered, /\n/);
|
|
814
|
+
assert.ok(line.length <= 120, `expected bounded line length, got ${line.length}`);
|
|
815
|
+
assert.ok(recovered.length <= 120, `expected bounded line length, got ${recovered.length}`);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it("caps visible status lines and reports overflow consistently", () => {
|
|
819
|
+
const waitingState = observeStatus(
|
|
820
|
+
createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
821
|
+
{ snapshot: "present", updatedAt: 180_000, sequence: 1, phase: "waiting", waitingSince: 180_000 },
|
|
822
|
+
180_000,
|
|
823
|
+
);
|
|
824
|
+
const activeState = observeStatus(
|
|
825
|
+
createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
826
|
+
{
|
|
827
|
+
snapshot: "present",
|
|
828
|
+
updatedAt: 419_000,
|
|
829
|
+
sequence: 1,
|
|
830
|
+
phase: "active",
|
|
831
|
+
active: true,
|
|
832
|
+
activeScope: "tool",
|
|
833
|
+
activeSince: 419_000,
|
|
834
|
+
activityLabel: "bash",
|
|
835
|
+
},
|
|
836
|
+
419_000,
|
|
837
|
+
);
|
|
838
|
+
const waitingLine = formatStatusLine("Worker", classifyStatus(waitingState, 300_000));
|
|
839
|
+
const recoveredLine = formatTransitionLine("Worker", classifyStatus(activeState, 420_000), "recovered");
|
|
840
|
+
const lines = [waitingLine, recoveredLine, "Scout running 2m.", "Reviewer running 4m.", "Planner running 6m."];
|
|
841
|
+
const capped = capStatusLines(lines, 3);
|
|
842
|
+
const aggregate = formatStatusAggregate(lines, 3);
|
|
843
|
+
|
|
844
|
+
assert.equal(waitingLine, "Worker running 5m, waiting 2m.");
|
|
845
|
+
assert.equal(recoveredLine, "Worker running 7m, recovered; active (bash 1s).");
|
|
846
|
+
assert.deepEqual(capped.visibleLines, [waitingLine, recoveredLine, "Scout running 2m."]);
|
|
847
|
+
assert.equal(capped.overflow, 2);
|
|
848
|
+
assert.match(aggregate, /^Subagent status:/);
|
|
849
|
+
assert.match(aggregate, /\+2 more running\./);
|
|
850
|
+
assert.doesNotMatch(aggregate, /\/tmp|\.jsonl/);
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
describe("subagent discovery", () => {
|
|
855
|
+
const testApi = (subagentsModule as any).__test__;
|
|
856
|
+
|
|
857
|
+
it("loads session-mode from frontmatter", async () => {
|
|
858
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir }) => {
|
|
859
|
+
writeAgentFile(
|
|
860
|
+
projectAgentsDir,
|
|
861
|
+
"lineage-mode-test-agent",
|
|
862
|
+
[
|
|
863
|
+
"name: lineage-mode-test-agent",
|
|
864
|
+
"model: anthropic/test-lineage",
|
|
865
|
+
"session-mode: lineage-only",
|
|
866
|
+
].join("\n"),
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
const loaded = testApi.loadAgentDefaults("lineage-mode-test-agent");
|
|
870
|
+
assert.ok(loaded, "expected agent to load");
|
|
871
|
+
assert.equal(loaded.sessionMode, "lineage-only");
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it("loads explicit interactive flag from frontmatter", async () => {
|
|
876
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir }) => {
|
|
877
|
+
writeAgentFile(
|
|
878
|
+
projectAgentsDir,
|
|
879
|
+
"interactive-true-test-agent",
|
|
880
|
+
[
|
|
881
|
+
"name: interactive-true-test-agent",
|
|
882
|
+
"model: anthropic/test-interactive-true",
|
|
883
|
+
"interactive: true",
|
|
884
|
+
].join("\n"),
|
|
885
|
+
);
|
|
886
|
+
writeAgentFile(
|
|
887
|
+
projectAgentsDir,
|
|
888
|
+
"interactive-false-test-agent",
|
|
889
|
+
[
|
|
890
|
+
"name: interactive-false-test-agent",
|
|
891
|
+
"model: anthropic/test-interactive-false",
|
|
892
|
+
"interactive: false",
|
|
893
|
+
].join("\n"),
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
const loadedTrue = testApi.loadAgentDefaults("interactive-true-test-agent");
|
|
897
|
+
assert.equal(loadedTrue?.interactive, true);
|
|
898
|
+
|
|
899
|
+
const loadedFalse = testApi.loadAgentDefaults("interactive-false-test-agent");
|
|
900
|
+
assert.equal(loadedFalse?.interactive, false);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("leaves interactive undefined when not set in frontmatter", async () => {
|
|
905
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir }) => {
|
|
906
|
+
writeAgentFile(
|
|
907
|
+
projectAgentsDir,
|
|
908
|
+
"interactive-unset-test-agent",
|
|
909
|
+
[
|
|
910
|
+
"name: interactive-unset-test-agent",
|
|
911
|
+
"model: anthropic/test-interactive-unset",
|
|
912
|
+
].join("\n"),
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
const loaded = testApi.loadAgentDefaults("interactive-unset-test-agent");
|
|
916
|
+
assert.equal(loaded?.interactive, undefined);
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it("resolveEffectiveInteractive defaults to the inverse of auto-exit", () => {
|
|
921
|
+
// Autonomous agents (auto-exit: true) are NOT interactive — parent gets stall pings.
|
|
922
|
+
assert.equal(
|
|
923
|
+
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: true }),
|
|
924
|
+
false,
|
|
925
|
+
);
|
|
926
|
+
// Agents without auto-exit ARE interactive — parent does not receive status transition pings.
|
|
927
|
+
assert.equal(
|
|
928
|
+
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: false }),
|
|
929
|
+
true,
|
|
930
|
+
);
|
|
931
|
+
assert.equal(
|
|
932
|
+
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, {}),
|
|
933
|
+
true,
|
|
934
|
+
);
|
|
935
|
+
// Bare spawn with no agent defs (e.g. /iterate fork) is interactive by default.
|
|
936
|
+
assert.equal(
|
|
937
|
+
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, null),
|
|
938
|
+
true,
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("resolveEffectiveInteractive honors explicit frontmatter over the auto-exit default", () => {
|
|
943
|
+
// Autonomous agent that still wants to be treated as interactive.
|
|
944
|
+
assert.equal(
|
|
945
|
+
testApi.resolveEffectiveInteractive(
|
|
946
|
+
{ name: "A", task: "T" },
|
|
947
|
+
{ autoExit: true, interactive: true },
|
|
948
|
+
),
|
|
949
|
+
true,
|
|
950
|
+
);
|
|
951
|
+
// Non-auto-exit agent that opts back into stall pings.
|
|
952
|
+
assert.equal(
|
|
953
|
+
testApi.resolveEffectiveInteractive(
|
|
954
|
+
{ name: "A", task: "T" },
|
|
955
|
+
{ interactive: false },
|
|
956
|
+
),
|
|
957
|
+
false,
|
|
958
|
+
);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("resolveEffectiveInteractive honors the explicit tool parameter over all else", () => {
|
|
962
|
+
assert.equal(
|
|
963
|
+
testApi.resolveEffectiveInteractive(
|
|
964
|
+
{ name: "A", task: "T", interactive: false },
|
|
965
|
+
{ autoExit: false, interactive: true },
|
|
966
|
+
),
|
|
967
|
+
false,
|
|
968
|
+
);
|
|
969
|
+
assert.equal(
|
|
970
|
+
testApi.resolveEffectiveInteractive(
|
|
971
|
+
{ name: "A", task: "T", interactive: true },
|
|
972
|
+
{ autoExit: true, interactive: false },
|
|
973
|
+
),
|
|
974
|
+
true,
|
|
975
|
+
);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("bundled scout/worker/reviewer agents resolve as non-interactive; planner resolves as interactive", () => {
|
|
979
|
+
for (const name of ["scout", "worker", "reviewer"]) {
|
|
980
|
+
const defs = testApi.loadAgentDefaults(name);
|
|
981
|
+
assert.ok(defs, `expected bundled agent ${name} to be discoverable`);
|
|
982
|
+
assert.equal(
|
|
983
|
+
testApi.resolveEffectiveInteractive({ name, task: "" }, defs),
|
|
984
|
+
false,
|
|
985
|
+
`${name} should resolve as non-interactive (autonomous)`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const planner = testApi.loadAgentDefaults("planner");
|
|
990
|
+
assert.ok(planner, "expected bundled planner to be discoverable");
|
|
991
|
+
assert.equal(
|
|
992
|
+
testApi.resolveEffectiveInteractive({ name: "planner", task: "" }, planner),
|
|
993
|
+
true,
|
|
994
|
+
"planner should resolve as interactive (no auto-exit)",
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it("ignores invalid session-mode values", async () => {
|
|
999
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir }) => {
|
|
1000
|
+
writeAgentFile(
|
|
1001
|
+
projectAgentsDir,
|
|
1002
|
+
"invalid-mode-test-agent",
|
|
1003
|
+
[
|
|
1004
|
+
"name: invalid-mode-test-agent",
|
|
1005
|
+
"model: anthropic/test-invalid",
|
|
1006
|
+
"session-mode: sideways",
|
|
1007
|
+
].join("\n"),
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
const loaded = testApi.loadAgentDefaults("invalid-mode-test-agent");
|
|
1011
|
+
assert.ok(loaded, "expected agent to load");
|
|
1012
|
+
assert.equal(loaded.sessionMode, undefined);
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("resolves session mode with fork override precedence", () => {
|
|
1017
|
+
assert.equal(testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, null), "standalone");
|
|
1018
|
+
assert.equal(
|
|
1019
|
+
testApi.resolveEffectiveSessionMode({ name: "A", task: "T" }, { sessionMode: "lineage-only" }),
|
|
1020
|
+
"lineage-only",
|
|
1021
|
+
);
|
|
1022
|
+
assert.equal(
|
|
1023
|
+
testApi.resolveEffectiveSessionMode(
|
|
1024
|
+
{ name: "A", task: "T", fork: true },
|
|
1025
|
+
{ sessionMode: "lineage-only" },
|
|
1026
|
+
),
|
|
1027
|
+
"fork",
|
|
1028
|
+
);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("resolves launch behavior for standalone, lineage-only, and fork modes", () => {
|
|
1032
|
+
assert.deepEqual(testApi.resolveLaunchBehavior({ name: "A", task: "T" }, null), {
|
|
1033
|
+
sessionMode: "standalone",
|
|
1034
|
+
seededSessionMode: null,
|
|
1035
|
+
inheritsConversationContext: false,
|
|
1036
|
+
taskDelivery: "artifact",
|
|
1037
|
+
});
|
|
1038
|
+
assert.deepEqual(
|
|
1039
|
+
testApi.resolveLaunchBehavior({ name: "A", task: "T" }, { sessionMode: "lineage-only" }),
|
|
1040
|
+
{
|
|
1041
|
+
sessionMode: "lineage-only",
|
|
1042
|
+
seededSessionMode: "lineage-only",
|
|
1043
|
+
inheritsConversationContext: false,
|
|
1044
|
+
taskDelivery: "artifact",
|
|
1045
|
+
},
|
|
1046
|
+
);
|
|
1047
|
+
assert.deepEqual(
|
|
1048
|
+
testApi.resolveLaunchBehavior({ name: "A", task: "T" }, { sessionMode: "fork" }),
|
|
1049
|
+
{
|
|
1050
|
+
sessionMode: "fork",
|
|
1051
|
+
seededSessionMode: "fork",
|
|
1052
|
+
inheritsConversationContext: true,
|
|
1053
|
+
taskDelivery: "direct",
|
|
1054
|
+
},
|
|
1055
|
+
);
|
|
1056
|
+
assert.deepEqual(
|
|
1057
|
+
testApi.resolveLaunchBehavior(
|
|
1058
|
+
{ name: "A", task: "T", fork: true },
|
|
1059
|
+
{ sessionMode: "lineage-only" },
|
|
1060
|
+
),
|
|
1061
|
+
{
|
|
1062
|
+
sessionMode: "fork",
|
|
1063
|
+
seededSessionMode: "fork",
|
|
1064
|
+
inheritsConversationContext: true,
|
|
1065
|
+
taskDelivery: "direct",
|
|
1066
|
+
},
|
|
1067
|
+
);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("buildSubagentToolAllowlist preserves requested tools and adds child control tools", () => {
|
|
1071
|
+
assert.equal(
|
|
1072
|
+
testApi.buildSubagentToolAllowlist("read,bash,web_search"),
|
|
1073
|
+
"read,bash,web_search,caller_ping,subagent_done",
|
|
1074
|
+
);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("buildSubagentToolAllowlist returns null without an explicit tool restriction", () => {
|
|
1078
|
+
assert.equal(testApi.buildSubagentToolAllowlist(undefined), null);
|
|
1079
|
+
assert.equal(testApi.buildSubagentToolAllowlist(""), null);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("buildPiPromptArgs inserts separator for artifact-backed launches with skills", () => {
|
|
1083
|
+
assert.deepEqual(
|
|
1084
|
+
testApi.buildPiPromptArgs({ effectiveSkills: "review,lint", taskDelivery: "artifact", taskArg: "@artifact.md" }),
|
|
1085
|
+
["", "/skill:review", "/skill:lint", "@artifact.md"],
|
|
1086
|
+
);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("buildPiPromptArgs omits separator for artifact-backed launches without skills", () => {
|
|
1090
|
+
assert.deepEqual(
|
|
1091
|
+
testApi.buildPiPromptArgs({ effectiveSkills: undefined, taskDelivery: "artifact", taskArg: "@artifact.md" }),
|
|
1092
|
+
["@artifact.md"],
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it("buildPiPromptArgs omits separator for direct launches with skills", () => {
|
|
1097
|
+
assert.deepEqual(
|
|
1098
|
+
testApi.buildPiPromptArgs({ effectiveSkills: "review", taskDelivery: "direct", taskArg: "do the task" }),
|
|
1099
|
+
["/skill:review", "do the task"],
|
|
1100
|
+
);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it("lists visible agents from discovery", async () => {
|
|
1104
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir }) => {
|
|
1105
|
+
writeAgentFile(
|
|
1106
|
+
projectAgentsDir,
|
|
1107
|
+
"visible-discovery-test-agent",
|
|
1108
|
+
[
|
|
1109
|
+
"name: visible-discovery-test-agent",
|
|
1110
|
+
"description: Visible test agent",
|
|
1111
|
+
"model: anthropic/test-visible",
|
|
1112
|
+
].join("\n"),
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
const { api, registeredTools } = createMockExtensionApi();
|
|
1116
|
+
(subagentsModule as any).default(api);
|
|
1117
|
+
|
|
1118
|
+
const tool = registeredTools.find((tool) => tool.name === "subagents_list");
|
|
1119
|
+
assert.ok(tool, "expected subagents_list to be registered");
|
|
1120
|
+
|
|
1121
|
+
const result = await tool.execute();
|
|
1122
|
+
const agents = result.details?.agents ?? [];
|
|
1123
|
+
|
|
1124
|
+
assert.ok(agents.some((agent: any) => agent.name === "visible-discovery-test-agent"));
|
|
1125
|
+
assert.match(result.content[0].text, /visible-discovery-test-agent/);
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it("hides disable-model-invocation agents from listings but keeps direct loading", async () => {
|
|
1130
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir }) => {
|
|
1131
|
+
writeAgentFile(
|
|
1132
|
+
projectAgentsDir,
|
|
1133
|
+
"hidden-discovery-test-agent",
|
|
1134
|
+
[
|
|
1135
|
+
"name: hidden-discovery-test-agent",
|
|
1136
|
+
"description: Hidden test agent",
|
|
1137
|
+
"model: anthropic/test-hidden",
|
|
1138
|
+
"disable-model-invocation: true",
|
|
1139
|
+
].join("\n"),
|
|
1140
|
+
"You are the hidden agent.",
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
const { api, registeredTools } = createMockExtensionApi();
|
|
1144
|
+
(subagentsModule as any).default(api);
|
|
1145
|
+
|
|
1146
|
+
const tool = registeredTools.find((tool) => tool.name === "subagents_list");
|
|
1147
|
+
assert.ok(tool, "expected subagents_list to be registered");
|
|
1148
|
+
|
|
1149
|
+
const result = await tool.execute();
|
|
1150
|
+
const agents = result.details?.agents ?? [];
|
|
1151
|
+
|
|
1152
|
+
assert.equal(agents.some((agent: any) => agent.name === "hidden-discovery-test-agent"), false);
|
|
1153
|
+
assert.doesNotMatch(result.content[0].text, /hidden-discovery-test-agent/);
|
|
1154
|
+
|
|
1155
|
+
const loaded = testApi.loadAgentDefaults("hidden-discovery-test-agent");
|
|
1156
|
+
assert.ok(loaded, "expected hidden agent to remain directly loadable");
|
|
1157
|
+
assert.equal(loaded.model, "anthropic/test-hidden");
|
|
1158
|
+
assert.equal(loaded.body, "You are the hidden agent.");
|
|
1159
|
+
assert.equal(loaded.disableModelInvocation, true);
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it("lets a hidden project agent shadow a visible global agent", async () => {
|
|
1164
|
+
await withIsolatedAgentEnv(async ({ projectAgentsDir, globalAgentsDir }) => {
|
|
1165
|
+
writeAgentFile(
|
|
1166
|
+
globalAgentsDir,
|
|
1167
|
+
"shadowed-discovery-test-agent",
|
|
1168
|
+
[
|
|
1169
|
+
"name: shadowed-discovery-test-agent",
|
|
1170
|
+
"description: Global visible agent",
|
|
1171
|
+
"model: anthropic/test-global",
|
|
1172
|
+
].join("\n"),
|
|
1173
|
+
"You are the global visible agent.",
|
|
1174
|
+
);
|
|
1175
|
+
writeAgentFile(
|
|
1176
|
+
projectAgentsDir,
|
|
1177
|
+
"shadowed-discovery-test-agent",
|
|
1178
|
+
[
|
|
1179
|
+
"name: shadowed-discovery-test-agent",
|
|
1180
|
+
"description: Project hidden agent",
|
|
1181
|
+
"model: anthropic/test-project",
|
|
1182
|
+
"disable-model-invocation: true",
|
|
1183
|
+
].join("\n"),
|
|
1184
|
+
"You are the project hidden agent.",
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
const { api, registeredTools } = createMockExtensionApi();
|
|
1188
|
+
(subagentsModule as any).default(api);
|
|
1189
|
+
|
|
1190
|
+
const tool = registeredTools.find((tool) => tool.name === "subagents_list");
|
|
1191
|
+
assert.ok(tool, "expected subagents_list to be registered");
|
|
1192
|
+
|
|
1193
|
+
const result = await tool.execute();
|
|
1194
|
+
const agents = result.details?.agents ?? [];
|
|
1195
|
+
|
|
1196
|
+
assert.equal(agents.some((agent: any) => agent.name === "shadowed-discovery-test-agent"), false);
|
|
1197
|
+
assert.doesNotMatch(result.content[0].text, /shadowed-discovery-test-agent/);
|
|
1198
|
+
|
|
1199
|
+
const loaded = testApi.loadAgentDefaults("shadowed-discovery-test-agent");
|
|
1200
|
+
assert.ok(loaded, "expected project override to remain directly loadable");
|
|
1201
|
+
assert.equal(loaded.model, "anthropic/test-project");
|
|
1202
|
+
assert.equal(loaded.body, "You are the project hidden agent.");
|
|
1203
|
+
assert.equal(loaded.disableModelInvocation, true);
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
describe("subagent-done.ts", () => {
|
|
1208
|
+
describe("shouldMarkUserTookOver", () => {
|
|
1209
|
+
it("ignores the initial injected task before the first agent run", () => {
|
|
1210
|
+
assert.equal(shouldMarkUserTookOver(false), false);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("treats later input as manual takeover", () => {
|
|
1214
|
+
assert.equal(shouldMarkUserTookOver(true), true);
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
describe("shouldAutoExitOnAgentEnd", () => {
|
|
1219
|
+
it("auto-exits after normal completion when there was no takeover", () => {
|
|
1220
|
+
const messages = [{ role: "assistant", stopReason: "stop" }];
|
|
1221
|
+
assert.equal(shouldAutoExitOnAgentEnd(false, messages), true);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it("auto-exits after normal completion even when the user sent the prompt", () => {
|
|
1225
|
+
const messages = [{ role: "assistant", stopReason: "stop" }];
|
|
1226
|
+
assert.equal(shouldAutoExitOnAgentEnd(true, messages), true);
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it("stays open after Escape aborts the run", () => {
|
|
1230
|
+
const messages = [{ role: "assistant", stopReason: "aborted" }];
|
|
1231
|
+
assert.equal(shouldAutoExitOnAgentEnd(false, messages), false);
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
it("still exits when the latest turn ended with stopReason=error", () => {
|
|
1235
|
+
// Auto-exit subagents must shut down on retry-exhaustion errors so the
|
|
1236
|
+
// parent is woken. The error sidecar (written separately) carries the
|
|
1237
|
+
// failure detail; staying open would just strand the worker.
|
|
1238
|
+
const messages = [{ role: "assistant", stopReason: "error", errorMessage: "529 overloaded" }];
|
|
1239
|
+
assert.equal(shouldAutoExitOnAgentEnd(false, messages), true);
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
describe("findLatestAssistantError", () => {
|
|
1244
|
+
it("returns the error info from a stopReason=error message", () => {
|
|
1245
|
+
const messages = [
|
|
1246
|
+
{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "ok" }] },
|
|
1247
|
+
{ role: "toolResult", content: [] },
|
|
1248
|
+
{ role: "assistant", stopReason: "error", errorMessage: "Anthropic 529 Overloaded" },
|
|
1249
|
+
];
|
|
1250
|
+
assert.deepEqual(findLatestAssistantError(messages), {
|
|
1251
|
+
errorMessage: "Anthropic 529 Overloaded",
|
|
1252
|
+
stopReason: "error",
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it("returns null when the latest assistant turn completed normally", () => {
|
|
1257
|
+
const messages = [
|
|
1258
|
+
{ role: "assistant", stopReason: "error", errorMessage: "old failure" },
|
|
1259
|
+
{ role: "user", content: [] },
|
|
1260
|
+
{ role: "assistant", stopReason: "stop", content: [{ type: "text", text: "done" }] },
|
|
1261
|
+
];
|
|
1262
|
+
assert.equal(findLatestAssistantError(messages), null);
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it("returns null when the latest assistant turn was aborted by the user", () => {
|
|
1266
|
+
const messages = [{ role: "assistant", stopReason: "aborted" }];
|
|
1267
|
+
assert.equal(findLatestAssistantError(messages), null);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it("falls back to a placeholder when stopReason=error has no errorMessage field", () => {
|
|
1271
|
+
const messages = [{ role: "assistant", stopReason: "error" }];
|
|
1272
|
+
const info = findLatestAssistantError(messages);
|
|
1273
|
+
assert.ok(info);
|
|
1274
|
+
assert.equal(info!.stopReason, "error");
|
|
1275
|
+
assert.match(info!.errorMessage, /stopReason=error/);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it("returns null when messages is undefined or empty", () => {
|
|
1279
|
+
assert.equal(findLatestAssistantError(undefined), null);
|
|
1280
|
+
assert.equal(findLatestAssistantError([]), null);
|
|
1281
|
+
});
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
describe("completion.ts", () => {
|
|
1286
|
+
|
|
1287
|
+
it("decodes ping payloads", () => {
|
|
1288
|
+
assert.deepEqual(
|
|
1289
|
+
interpretExitSidecar({ type: "ping", name: "Worker", message: "need help" }),
|
|
1290
|
+
{
|
|
1291
|
+
reason: "ping",
|
|
1292
|
+
exitCode: 0,
|
|
1293
|
+
ping: { name: "Worker", message: "need help" },
|
|
1294
|
+
},
|
|
1295
|
+
);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("decodes done payloads", () => {
|
|
1299
|
+
assert.deepEqual(interpretExitSidecar({ type: "done" }), {
|
|
1300
|
+
reason: "done",
|
|
1301
|
+
exitCode: 0,
|
|
1302
|
+
});
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("decodes error payloads and propagates the message with a non-zero exit code", () => {
|
|
1306
|
+
assert.deepEqual(
|
|
1307
|
+
interpretExitSidecar({
|
|
1308
|
+
type: "error",
|
|
1309
|
+
errorMessage: "Anthropic 529 Overloaded after 3 retries",
|
|
1310
|
+
stopReason: "error",
|
|
1311
|
+
}),
|
|
1312
|
+
{
|
|
1313
|
+
reason: "error",
|
|
1314
|
+
exitCode: 1,
|
|
1315
|
+
errorMessage: "Anthropic 529 Overloaded after 3 retries",
|
|
1316
|
+
},
|
|
1317
|
+
);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it("falls back to a placeholder when error payload has no errorMessage", () => {
|
|
1321
|
+
const result = interpretExitSidecar({ type: "error" });
|
|
1322
|
+
assert.equal(result.reason, "error");
|
|
1323
|
+
assert.equal(result.exitCode, 1);
|
|
1324
|
+
assert.match(result.errorMessage ?? "", /no errorMessage/);
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it("treats unknown payload shapes as done", () => {
|
|
1328
|
+
assert.deepEqual(interpretExitSidecar({}), { reason: "done", exitCode: 0 });
|
|
1329
|
+
assert.deepEqual(interpretExitSidecar(null), { reason: "done", exitCode: 0 });
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it("consumes a sidecar and removes it", async () => {
|
|
1333
|
+
const dir = mkdtempSync(join(tmpdir(), "completion-sidecar-"));
|
|
1334
|
+
const sessionFile = join(dir, "session.jsonl");
|
|
1335
|
+
const exitFile = `${sessionFile}.exit`;
|
|
1336
|
+
writeFileSync(exitFile, JSON.stringify({ type: "ping", name: "Scout", message: "ready" }));
|
|
1337
|
+
try {
|
|
1338
|
+
const result = await waitForCompletion(new AbortController().signal, {
|
|
1339
|
+
intervalMs: 1,
|
|
1340
|
+
sessionFile,
|
|
1341
|
+
readTerminalTail: async () => "",
|
|
1342
|
+
});
|
|
1343
|
+
assert.deepEqual(result, {
|
|
1344
|
+
reason: "ping",
|
|
1345
|
+
exitCode: 0,
|
|
1346
|
+
ping: { name: "Scout", message: "ready" },
|
|
1347
|
+
});
|
|
1348
|
+
assert.equal(existsSync(exitFile), false);
|
|
1349
|
+
} finally {
|
|
1350
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("returns the terminal sentinel exit code", async () => {
|
|
1355
|
+
const result = await waitForCompletion(new AbortController().signal, {
|
|
1356
|
+
intervalMs: 1,
|
|
1357
|
+
readTerminalTail: async () => "output\n__SUBAGENT_DONE_17__\n",
|
|
1358
|
+
});
|
|
1359
|
+
assert.deepEqual(result, { reason: "sentinel", exitCode: 17 });
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it("returns when an external sentinel file appears", async () => {
|
|
1363
|
+
const dir = mkdtempSync(join(tmpdir(), "completion-sentinel-"));
|
|
1364
|
+
const sentinelFile = join(dir, "done");
|
|
1365
|
+
writeFileSync(sentinelFile, "complete");
|
|
1366
|
+
try {
|
|
1367
|
+
const result = await waitForCompletion(new AbortController().signal, {
|
|
1368
|
+
intervalMs: 1,
|
|
1369
|
+
sentinelFile,
|
|
1370
|
+
readTerminalTail: async () => "",
|
|
1371
|
+
});
|
|
1372
|
+
assert.deepEqual(result, { reason: "sentinel", exitCode: 0 });
|
|
1373
|
+
} finally {
|
|
1374
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("retries transient terminal read failures and reports ticks", async () => {
|
|
1379
|
+
let reads = 0;
|
|
1380
|
+
let ticks = 0;
|
|
1381
|
+
const result = await waitForCompletion(new AbortController().signal, {
|
|
1382
|
+
intervalMs: 1,
|
|
1383
|
+
readTerminalTail: async () => {
|
|
1384
|
+
reads += 1;
|
|
1385
|
+
if (reads === 1) throw new Error("pane temporarily unavailable");
|
|
1386
|
+
return "__SUBAGENT_DONE_0__";
|
|
1387
|
+
},
|
|
1388
|
+
onTick: () => {
|
|
1389
|
+
ticks += 1;
|
|
1390
|
+
},
|
|
1391
|
+
});
|
|
1392
|
+
assert.deepEqual(result, { reason: "sentinel", exitCode: 0 });
|
|
1393
|
+
assert.equal(reads, 2);
|
|
1394
|
+
assert.equal(ticks, 1);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
it("rejects promptly when aborted", async () => {
|
|
1398
|
+
const controller = new AbortController();
|
|
1399
|
+
const completion = waitForCompletion(controller.signal, {
|
|
1400
|
+
intervalMs: 10_000,
|
|
1401
|
+
readTerminalTail: async () => "",
|
|
1402
|
+
});
|
|
1403
|
+
controller.abort();
|
|
1404
|
+
await assert.rejects(completion, /Aborted while waiting for subagent to finish/);
|
|
1405
|
+
});
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
describe("commands", () => {
|
|
1409
|
+
it("/iterate always emits a full-context fork tool call", () => {
|
|
1410
|
+
const { api, registeredCommands, sentUserMessages } = createMockExtensionApi();
|
|
1411
|
+
|
|
1412
|
+
(subagentsModule as any).default(api);
|
|
1413
|
+
|
|
1414
|
+
const iterate = registeredCommands.find((command) => command.name === "iterate");
|
|
1415
|
+
assert.ok(iterate, "expected /iterate to be registered");
|
|
1416
|
+
|
|
1417
|
+
iterate.handler("Fix the bug", {});
|
|
1418
|
+
|
|
1419
|
+
assert.equal(sentUserMessages.length, 1);
|
|
1420
|
+
assert.match(sentUserMessages[0], /fork: true/);
|
|
1421
|
+
assert.match(sentUserMessages[0], /name: "Iterate"/);
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
describe("tool registration", () => {
|
|
1426
|
+
it("defaults resumed subagents to auto-exit and non-interactive tracking", () => {
|
|
1427
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1428
|
+
|
|
1429
|
+
assert.deepEqual(testApi.resolveResumeLaunchBehavior({}), {
|
|
1430
|
+
autoExit: true,
|
|
1431
|
+
interactive: false,
|
|
1432
|
+
});
|
|
1433
|
+
assert.deepEqual(testApi.resolveResumeLaunchBehavior({ autoExit: false }), {
|
|
1434
|
+
autoExit: false,
|
|
1435
|
+
interactive: true,
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
it("expands spawning false to deny subagent interruption", () => {
|
|
1440
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1441
|
+
const denied = testApi.resolveDenyTools({ spawning: false });
|
|
1442
|
+
|
|
1443
|
+
assert.equal(denied.has("subagent"), true);
|
|
1444
|
+
assert.equal(denied.has("subagent_interrupt"), true);
|
|
1445
|
+
assert.equal(denied.has("subagent_resume"), true);
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
it("renders partial subagent tool-call args without throwing", () => {
|
|
1449
|
+
const { api, registeredTools } = createMockExtensionApi();
|
|
1450
|
+
(subagentsModule as any).default(api);
|
|
1451
|
+
|
|
1452
|
+
const subagentTool = registeredTools.find((tool) => tool.name === "subagent");
|
|
1453
|
+
assert.ok(subagentTool, "expected subagent tool to be registered");
|
|
1454
|
+
|
|
1455
|
+
const theme = {
|
|
1456
|
+
fg(_color: string, text: string) {
|
|
1457
|
+
return text;
|
|
1458
|
+
},
|
|
1459
|
+
bold(text: string) {
|
|
1460
|
+
return text;
|
|
1461
|
+
},
|
|
1462
|
+
};
|
|
1463
|
+
const rendered = subagentTool.renderCall({}, theme);
|
|
1464
|
+
const output = rendered.render(80).join("\n");
|
|
1465
|
+
|
|
1466
|
+
assert.match(output, /\(unnamed\)/);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
it("registers subagent_resume with an autoExit override", () => {
|
|
1470
|
+
const { api, registeredTools } = createMockExtensionApi();
|
|
1471
|
+
(subagentsModule as any).default(api);
|
|
1472
|
+
|
|
1473
|
+
const resumeTool = registeredTools.find((tool) => tool.name === "subagent_resume");
|
|
1474
|
+
assert.ok(resumeTool, "expected subagent_resume tool to be registered");
|
|
1475
|
+
|
|
1476
|
+
const autoExitSchema = resumeTool.parameters.properties.autoExit;
|
|
1477
|
+
assert.equal(autoExitSchema.type, "boolean");
|
|
1478
|
+
assert.match(autoExitSchema.description, /Defaults to true/);
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
describe("subagent activity snapshots", () => {
|
|
1483
|
+
function validActivity(overrides: Record<string, unknown> = {}) {
|
|
1484
|
+
return {
|
|
1485
|
+
version: 1,
|
|
1486
|
+
runningChildId: "child-1",
|
|
1487
|
+
createdAt: 1_000,
|
|
1488
|
+
updatedAt: 1_000,
|
|
1489
|
+
sequence: 1,
|
|
1490
|
+
latestEvent: "session_start",
|
|
1491
|
+
phase: "starting",
|
|
1492
|
+
agentActive: false,
|
|
1493
|
+
turnActive: false,
|
|
1494
|
+
providerActive: false,
|
|
1495
|
+
toolActive: false,
|
|
1496
|
+
...overrides,
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
it("writes and validates activity files by running child id", () => {
|
|
1501
|
+
withTempDir((dir) => {
|
|
1502
|
+
const activityFile = getSubagentActivityFile(dir, "child-1");
|
|
1503
|
+
const recorder = createSubagentActivityRecorder({
|
|
1504
|
+
runningChildId: "child-1",
|
|
1505
|
+
activityFile,
|
|
1506
|
+
now: () => 1_000,
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
recorder.sessionStart();
|
|
1510
|
+
recorder.toolExecutionStart("tool-1", "bash");
|
|
1511
|
+
|
|
1512
|
+
const read = readSubagentActivityFile(activityFile, "child-1");
|
|
1513
|
+
assert.ok(read.ok);
|
|
1514
|
+
assert.equal(read.activity.phase, "active");
|
|
1515
|
+
assert.equal(read.activity.activeScope, "tool");
|
|
1516
|
+
assert.equal(read.activity.toolName, "bash");
|
|
1517
|
+
|
|
1518
|
+
assert.deepEqual(readSubagentActivityFile(activityFile, "other-child"), {
|
|
1519
|
+
ok: false,
|
|
1520
|
+
reason: "wrong-id",
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
it("records waiting and final done states", () => {
|
|
1526
|
+
withTempDir((dir) => {
|
|
1527
|
+
let currentNow = 2_000;
|
|
1528
|
+
const activityFile = getSubagentActivityFile(dir, "child-2");
|
|
1529
|
+
const recorder = createSubagentActivityRecorder({
|
|
1530
|
+
runningChildId: "child-2",
|
|
1531
|
+
activityFile,
|
|
1532
|
+
now: () => currentNow,
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
recorder.sessionStart();
|
|
1536
|
+
currentNow = 3_000;
|
|
1537
|
+
recorder.agentEndWaiting();
|
|
1538
|
+
let read = readSubagentActivityFile(activityFile, "child-2");
|
|
1539
|
+
assert.ok(read.ok);
|
|
1540
|
+
assert.equal(read.activity.phase, "waiting");
|
|
1541
|
+
assert.equal(read.activity.waitingSince, 3_000);
|
|
1542
|
+
|
|
1543
|
+
currentNow = 4_000;
|
|
1544
|
+
recorder.subagentDone();
|
|
1545
|
+
read = readSubagentActivityFile(activityFile, "child-2");
|
|
1546
|
+
assert.ok(read.ok);
|
|
1547
|
+
assert.equal(read.activity.phase, "done");
|
|
1548
|
+
assert.equal(read.activity.agentActive, false);
|
|
1549
|
+
});
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
it("rejects malformed activity fields used by classification and rendering", () => {
|
|
1553
|
+
withTempDir((dir) => {
|
|
1554
|
+
mkdirSync(join(dir, "subagent-activity"), { recursive: true });
|
|
1555
|
+
const cases = [
|
|
1556
|
+
{ activeSince: "bad" },
|
|
1557
|
+
{ waitingSince: "bad" },
|
|
1558
|
+
{ activeScope: "database" },
|
|
1559
|
+
{ latestEvent: "unknown" },
|
|
1560
|
+
{ runningChildId: 42 },
|
|
1561
|
+
{ toolActive: "yes" },
|
|
1562
|
+
{ toolName: "bad\nname" },
|
|
1563
|
+
];
|
|
1564
|
+
|
|
1565
|
+
for (const [index, overrides] of cases.entries()) {
|
|
1566
|
+
const activityFile = getSubagentActivityFile(dir, `child-${index}`);
|
|
1567
|
+
const activity = validActivity({ runningChildId: `child-${index}`, ...overrides });
|
|
1568
|
+
writeFileSync(activityFile, `${JSON.stringify(activity)}\n`);
|
|
1569
|
+
|
|
1570
|
+
const read = readSubagentActivityFile(activityFile, `child-${index}`);
|
|
1571
|
+
assert.equal(read.ok, false);
|
|
1572
|
+
assert.equal((read as { ok: false; reason: string }).reason, "invalid");
|
|
1573
|
+
}
|
|
1574
|
+
});
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it("does not let tool_result resurrect finished tool activity", () => {
|
|
1578
|
+
withTempDir((dir) => {
|
|
1579
|
+
let currentNow = 1_000;
|
|
1580
|
+
const activityFile = getSubagentActivityFile(dir, "child-3");
|
|
1581
|
+
const recorder = createSubagentActivityRecorder({
|
|
1582
|
+
runningChildId: "child-3",
|
|
1583
|
+
activityFile,
|
|
1584
|
+
now: () => currentNow,
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
recorder.sessionStart();
|
|
1588
|
+
recorder.agentStart();
|
|
1589
|
+
recorder.turnStart(1);
|
|
1590
|
+
currentNow = 2_000;
|
|
1591
|
+
recorder.toolExecutionStart("tool-1", "bash");
|
|
1592
|
+
currentNow = 3_000;
|
|
1593
|
+
recorder.toolExecutionEnd("tool-1", "bash");
|
|
1594
|
+
currentNow = 4_000;
|
|
1595
|
+
recorder.toolResult("tool-1", "bash");
|
|
1596
|
+
|
|
1597
|
+
const read = readSubagentActivityFile(activityFile, "child-3");
|
|
1598
|
+
assert.ok(read.ok);
|
|
1599
|
+
assert.equal(read.activity.toolActive, false);
|
|
1600
|
+
assert.equal(read.activity.activeScope, "turn");
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it("does not mark reload shutdown as the final done snapshot", () => {
|
|
1605
|
+
withTempDir((dir) => {
|
|
1606
|
+
const activityFile = getSubagentActivityFile(dir, "child-4");
|
|
1607
|
+
const recorder = createSubagentActivityRecorder({
|
|
1608
|
+
runningChildId: "child-4",
|
|
1609
|
+
activityFile,
|
|
1610
|
+
now: () => 1_000,
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
recorder.sessionStart();
|
|
1614
|
+
recorder.sessionShutdown("reload");
|
|
1615
|
+
|
|
1616
|
+
const read = readSubagentActivityFile(activityFile, "child-4");
|
|
1617
|
+
assert.ok(read.ok);
|
|
1618
|
+
assert.equal(read.activity.phase, "starting");
|
|
1619
|
+
assert.equal(read.activity.latestEvent, "session_start");
|
|
1620
|
+
});
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
it("cancels pending throttled writes on reload shutdown", async () => {
|
|
1624
|
+
const dir = createTestDir();
|
|
1625
|
+
try {
|
|
1626
|
+
await new Promise<void>((resolve) => {
|
|
1627
|
+
let currentNow = 1_000;
|
|
1628
|
+
const activityFile = getSubagentActivityFile(dir, "child-5");
|
|
1629
|
+
const recorder = createSubagentActivityRecorder({
|
|
1630
|
+
runningChildId: "child-5",
|
|
1631
|
+
activityFile,
|
|
1632
|
+
now: () => currentNow,
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
recorder.sessionStart();
|
|
1636
|
+
currentNow = 1_100;
|
|
1637
|
+
recorder.messageUpdate("delta");
|
|
1638
|
+
recorder.sessionShutdown("reload");
|
|
1639
|
+
|
|
1640
|
+
setTimeout(() => {
|
|
1641
|
+
const read = readSubagentActivityFile(activityFile, "child-5");
|
|
1642
|
+
assert.ok(read.ok);
|
|
1643
|
+
assert.equal(read.activity.phase, "starting");
|
|
1644
|
+
assert.equal(read.activity.latestEvent, "session_start");
|
|
1645
|
+
resolve();
|
|
1646
|
+
}, 650);
|
|
1647
|
+
});
|
|
1648
|
+
} finally {
|
|
1649
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
describe("subagent interruption", () => {
|
|
1655
|
+
function makeRunning(overrides: Record<string, unknown> = {}) {
|
|
1656
|
+
return {
|
|
1657
|
+
id: "a1",
|
|
1658
|
+
name: "Worker",
|
|
1659
|
+
task: "",
|
|
1660
|
+
surface: "pane-1",
|
|
1661
|
+
startTime: 0,
|
|
1662
|
+
sessionFile: "worker.jsonl",
|
|
1663
|
+
interactive: false,
|
|
1664
|
+
statusState: createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
1665
|
+
...overrides,
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
it("registers subagent_interrupt in the main session extension", () => {
|
|
1670
|
+
const { api, registeredTools } = createMockExtensionApi();
|
|
1671
|
+
|
|
1672
|
+
(subagentsModule as any).default(api);
|
|
1673
|
+
|
|
1674
|
+
assert.equal(registeredTools.some((tool) => tool.name === "subagent_interrupt"), true);
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
it("resolves interrupt targets by exact id and reports name ambiguity", () => {
|
|
1678
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1679
|
+
const runningMap = testApi.runningSubagents as Map<string, any>;
|
|
1680
|
+
runningMap.clear();
|
|
1681
|
+
|
|
1682
|
+
try {
|
|
1683
|
+
runningMap.set("a1", makeRunning({ id: "a1", name: "Worker", surface: "a1", sessionFile: "a1.jsonl" }));
|
|
1684
|
+
runningMap.set("b2", makeRunning({ id: "b2", name: "Worker", surface: "b2", sessionFile: "b2.jsonl" }));
|
|
1685
|
+
runningMap.set("c3", makeRunning({ id: "c3", name: "Scout", surface: "c3", sessionFile: "c3.jsonl" }));
|
|
1686
|
+
|
|
1687
|
+
const byId = testApi.resolveInterruptTarget({ id: "c3", name: "Worker" });
|
|
1688
|
+
assert.equal(byId.running.id, "c3");
|
|
1689
|
+
|
|
1690
|
+
const ambiguous = testApi.resolveInterruptTarget({ name: "Worker" });
|
|
1691
|
+
assert.match(ambiguous.error, /Ambiguous subagent name/);
|
|
1692
|
+
} finally {
|
|
1693
|
+
runningMap.clear();
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
it("returns an explicit error when Escape delivery fails", () => {
|
|
1698
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1699
|
+
let aborted = false;
|
|
1700
|
+
const running = makeRunning({
|
|
1701
|
+
abortController: {
|
|
1702
|
+
abort() {
|
|
1703
|
+
aborted = true;
|
|
1704
|
+
},
|
|
1705
|
+
},
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
const result = testApi.requestSubagentInterrupt(running, () => {
|
|
1709
|
+
throw new Error("mux write failed");
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
assert.match(result.error, /Failed to send Escape/);
|
|
1713
|
+
assert.equal(aborted, false);
|
|
1714
|
+
assert.equal("interruptRequested" in running, false);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it("leaves status unchanged when Escape delivery fails in the tool path", () => {
|
|
1718
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1719
|
+
const runningMap = testApi.runningSubagents as Map<string, any>;
|
|
1720
|
+
runningMap.clear();
|
|
1721
|
+
|
|
1722
|
+
const activeState = observeStatus(
|
|
1723
|
+
createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
1724
|
+
{
|
|
1725
|
+
snapshot: "present",
|
|
1726
|
+
updatedAt: 5_000,
|
|
1727
|
+
sequence: 1,
|
|
1728
|
+
phase: "active",
|
|
1729
|
+
active: true,
|
|
1730
|
+
activeScope: "tool",
|
|
1731
|
+
activeSince: 5_000,
|
|
1732
|
+
activityLabel: "bash",
|
|
1733
|
+
},
|
|
1734
|
+
5_000,
|
|
1735
|
+
);
|
|
1736
|
+
|
|
1737
|
+
try {
|
|
1738
|
+
runningMap.set("a1", makeRunning({ statusState: activeState }));
|
|
1739
|
+
|
|
1740
|
+
const result = withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, () => {
|
|
1741
|
+
throw new Error("mux write failed");
|
|
1742
|
+
}));
|
|
1743
|
+
|
|
1744
|
+
assert.match(result.content[0].text, /Failed to send Escape/);
|
|
1745
|
+
assert.equal(classifyStatus(runningMap.get("a1").statusState, 20_000).kind, "active");
|
|
1746
|
+
} finally {
|
|
1747
|
+
runningMap.clear();
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
it("sends Escape without aborting or mutating running state", () => {
|
|
1752
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1753
|
+
let aborted = false;
|
|
1754
|
+
let sentSurface = "";
|
|
1755
|
+
const running = makeRunning({
|
|
1756
|
+
abortController: {
|
|
1757
|
+
abort() {
|
|
1758
|
+
aborted = true;
|
|
1759
|
+
},
|
|
1760
|
+
},
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
const result = testApi.requestSubagentInterrupt(running, (surface: string) => {
|
|
1764
|
+
sentSurface = surface;
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
assert.deepEqual(result, { ok: true });
|
|
1768
|
+
assert.equal(sentSurface, "pane-1");
|
|
1769
|
+
assert.equal(aborted, false);
|
|
1770
|
+
assert.equal("interruptRequested" in running, false);
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it("refreshes the latest activity snapshot before forcing local interrupt waiting", () => {
|
|
1774
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1775
|
+
const runningMap = testApi.runningSubagents as Map<string, any>;
|
|
1776
|
+
let sentSurface = "";
|
|
1777
|
+
runningMap.clear();
|
|
1778
|
+
|
|
1779
|
+
withTempDir((dir) => {
|
|
1780
|
+
mkdirSync(join(dir, "subagent-activity"), { recursive: true });
|
|
1781
|
+
const activityFile = getSubagentActivityFile(dir, "a1");
|
|
1782
|
+
const activity = {
|
|
1783
|
+
version: 1,
|
|
1784
|
+
runningChildId: "a1",
|
|
1785
|
+
createdAt: 1_000,
|
|
1786
|
+
updatedAt: 19_000,
|
|
1787
|
+
sequence: 7,
|
|
1788
|
+
latestEvent: "tool_execution_start",
|
|
1789
|
+
phase: "active",
|
|
1790
|
+
agentActive: true,
|
|
1791
|
+
turnActive: true,
|
|
1792
|
+
providerActive: false,
|
|
1793
|
+
toolActive: true,
|
|
1794
|
+
activeScope: "tool",
|
|
1795
|
+
activeSince: 19_000,
|
|
1796
|
+
toolName: "bash",
|
|
1797
|
+
};
|
|
1798
|
+
writeFileSync(activityFile, `${JSON.stringify(activity)}\n`);
|
|
1799
|
+
|
|
1800
|
+
try {
|
|
1801
|
+
runningMap.set("a1", makeRunning({
|
|
1802
|
+
activityFile,
|
|
1803
|
+
statusState: createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
1804
|
+
}));
|
|
1805
|
+
|
|
1806
|
+
withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => {
|
|
1807
|
+
sentSurface = surface;
|
|
1808
|
+
}));
|
|
1809
|
+
|
|
1810
|
+
assert.equal(sentSurface, "pane-1");
|
|
1811
|
+
const state = runningMap.get("a1").statusState;
|
|
1812
|
+
const snapshot = classifyStatus(state, 20_000);
|
|
1813
|
+
assert.equal(snapshot.kind, "waiting");
|
|
1814
|
+
assert.equal(snapshot.activityLabel, "interrupted");
|
|
1815
|
+
assert.equal(state.lastActivityAtMs, 20_000);
|
|
1816
|
+
assert.equal(state.lastActivitySequence, 7);
|
|
1817
|
+
assert.equal(state.localOverrideSequence, 7);
|
|
1818
|
+
} finally {
|
|
1819
|
+
runningMap.clear();
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
it("acknowledges Pi-backed interrupt requests and forces local status waiting", () => {
|
|
1825
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1826
|
+
const runningMap = testApi.runningSubagents as Map<string, any>;
|
|
1827
|
+
let sentSurface = "";
|
|
1828
|
+
runningMap.clear();
|
|
1829
|
+
|
|
1830
|
+
const activeState = observeStatus(
|
|
1831
|
+
createStatusState({ source: "pi", startTimeMs: 0 }),
|
|
1832
|
+
{
|
|
1833
|
+
snapshot: "present",
|
|
1834
|
+
updatedAt: 5_000,
|
|
1835
|
+
sequence: 1,
|
|
1836
|
+
phase: "active",
|
|
1837
|
+
active: true,
|
|
1838
|
+
activeScope: "tool",
|
|
1839
|
+
activeSince: 5_000,
|
|
1840
|
+
activityLabel: "bash",
|
|
1841
|
+
},
|
|
1842
|
+
5_000,
|
|
1843
|
+
);
|
|
1844
|
+
|
|
1845
|
+
try {
|
|
1846
|
+
runningMap.set("a1", makeRunning({ statusState: activeState }));
|
|
1847
|
+
|
|
1848
|
+
const result = withMockedNow(20_000, () => testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => {
|
|
1849
|
+
sentSurface = surface;
|
|
1850
|
+
}));
|
|
1851
|
+
|
|
1852
|
+
assert.equal(sentSurface, "pane-1");
|
|
1853
|
+
assert.equal(result.content[0].text, 'Interrupt requested for subagent "Worker".');
|
|
1854
|
+
assert.deepEqual(result.details, { id: "a1", name: "Worker", status: "interrupt_requested" });
|
|
1855
|
+
const snapshot = classifyStatus(runningMap.get("a1").statusState, 20_000);
|
|
1856
|
+
assert.equal(snapshot.kind, "waiting");
|
|
1857
|
+
assert.equal(snapshot.activityLabel, "interrupted");
|
|
1858
|
+
assert.equal(runningMap.has("a1"), true);
|
|
1859
|
+
} finally {
|
|
1860
|
+
runningMap.clear();
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it("sends Escape again for repeated interrupt requests", () => {
|
|
1865
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1866
|
+
const runningMap = testApi.runningSubagents as Map<string, any>;
|
|
1867
|
+
const surfaces: string[] = [];
|
|
1868
|
+
runningMap.clear();
|
|
1869
|
+
|
|
1870
|
+
try {
|
|
1871
|
+
runningMap.set("a1", makeRunning());
|
|
1872
|
+
|
|
1873
|
+
testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => {
|
|
1874
|
+
surfaces.push(surface);
|
|
1875
|
+
});
|
|
1876
|
+
testApi.handleSubagentInterrupt({ name: "Worker" }, (surface: string) => {
|
|
1877
|
+
surfaces.push(surface);
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
assert.deepEqual(surfaces, ["pane-1", "pane-1"]);
|
|
1881
|
+
assert.equal(runningMap.has("a1"), true);
|
|
1882
|
+
} finally {
|
|
1883
|
+
runningMap.clear();
|
|
1884
|
+
}
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it("rejects Claude-backed interrupt requests before delivery", () => {
|
|
1888
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1889
|
+
const runningMap = testApi.runningSubagents as Map<string, any>;
|
|
1890
|
+
let delivered = false;
|
|
1891
|
+
runningMap.clear();
|
|
1892
|
+
|
|
1893
|
+
try {
|
|
1894
|
+
runningMap.set("a1", makeRunning({ cli: "claude" }));
|
|
1895
|
+
|
|
1896
|
+
const result = testApi.handleSubagentInterrupt({ name: "Worker" }, () => {
|
|
1897
|
+
delivered = true;
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
assert.equal(delivered, false);
|
|
1901
|
+
assert.match(result.content[0].text, /currently supported only for Pi-backed subagents/i);
|
|
1902
|
+
assert.deepEqual(result.details, {
|
|
1903
|
+
error: "claude interrupt unsupported",
|
|
1904
|
+
id: "a1",
|
|
1905
|
+
name: "Worker",
|
|
1906
|
+
});
|
|
1907
|
+
} finally {
|
|
1908
|
+
runningMap.clear();
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
it("formats exit code 130 as an ordinary failure", () => {
|
|
1913
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1914
|
+
const presentation = testApi.resolveResultPresentation(
|
|
1915
|
+
{
|
|
1916
|
+
exitCode: 130,
|
|
1917
|
+
elapsed: 61,
|
|
1918
|
+
summary: "Sub-agent exited with code 130",
|
|
1919
|
+
sessionFile: "/tmp/subagent.jsonl",
|
|
1920
|
+
},
|
|
1921
|
+
"Worker",
|
|
1922
|
+
);
|
|
1923
|
+
|
|
1924
|
+
assert.match(presentation, /failed \(exit code 130\)/);
|
|
1925
|
+
assert.doesNotMatch(presentation, /interrupted/);
|
|
1926
|
+
assert.match(presentation, /Resume: pi --session/);
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
it("renders a clear provider/agent error when errorMessage is set", () => {
|
|
1930
|
+
// Previously, an overload retry-exhaustion produced exitCode 0 with a
|
|
1931
|
+
// stale summary — the orchestrator thought the subagent finished
|
|
1932
|
+
// quickly. With the error sidecar plumbed through, the presentation
|
|
1933
|
+
// must call out the failure, include the underlying error, and tell the
|
|
1934
|
+
// orchestrator how to recover.
|
|
1935
|
+
const testApi = (subagentsModule as any).__test__;
|
|
1936
|
+
const presentation = testApi.resolveResultPresentation(
|
|
1937
|
+
{
|
|
1938
|
+
exitCode: 1,
|
|
1939
|
+
elapsed: 14,
|
|
1940
|
+
summary: "ignored when errorMessage is present",
|
|
1941
|
+
sessionFile: "/tmp/subagent.jsonl",
|
|
1942
|
+
errorMessage: "Anthropic 529 Overloaded after 3 retries",
|
|
1943
|
+
},
|
|
1944
|
+
"Worker",
|
|
1945
|
+
);
|
|
1946
|
+
|
|
1947
|
+
assert.match(presentation, /Sub-agent "Worker" failed/);
|
|
1948
|
+
assert.match(presentation, /provider\/agent error — auto-retry exhausted/);
|
|
1949
|
+
assert.match(presentation, /Error: Anthropic 529 Overloaded after 3 retries/);
|
|
1950
|
+
assert.match(presentation, /subagent_resume/);
|
|
1951
|
+
assert.match(presentation, /Resume: pi --session/);
|
|
1952
|
+
assert.doesNotMatch(presentation, /ignored when errorMessage is present/);
|
|
1953
|
+
});
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
describe("subagent status renderer", () => {
|
|
1957
|
+
function createTheme() {
|
|
1958
|
+
return {
|
|
1959
|
+
fg(_color: string, text: string) {
|
|
1960
|
+
return text;
|
|
1961
|
+
},
|
|
1962
|
+
bg(_color: string, text: string) {
|
|
1963
|
+
return text;
|
|
1964
|
+
},
|
|
1965
|
+
bold(text: string) {
|
|
1966
|
+
return text;
|
|
1967
|
+
},
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
it("renders only capped lines plus overflow", () => {
|
|
1972
|
+
const { api, registeredMessageRenderers } = createMockExtensionApi();
|
|
1973
|
+
(subagentsModule as any).default(api);
|
|
1974
|
+
|
|
1975
|
+
const rendererEntry = registeredMessageRenderers.find((entry) => entry.name === "subagent_status");
|
|
1976
|
+
assert.ok(rendererEntry, "expected subagent_status renderer to be registered");
|
|
1977
|
+
|
|
1978
|
+
const visibleLines = [
|
|
1979
|
+
"Worker running 5m, active (bash 2m).",
|
|
1980
|
+
"Scout running 3m, waiting 1m.",
|
|
1981
|
+
"Reviewer running 2m, active (streaming 30s).",
|
|
1982
|
+
"Planner running 4m, waiting 2m.",
|
|
1983
|
+
];
|
|
1984
|
+
const rendered = rendererEntry.renderer(
|
|
1985
|
+
{
|
|
1986
|
+
customType: "subagent_status",
|
|
1987
|
+
content: "Subagent status:\n• Worker running 5m, active (bash 2m).",
|
|
1988
|
+
details: {
|
|
1989
|
+
lines: visibleLines,
|
|
1990
|
+
overflow: 2,
|
|
1991
|
+
},
|
|
1992
|
+
},
|
|
1993
|
+
{ expanded: true },
|
|
1994
|
+
createTheme(),
|
|
1995
|
+
);
|
|
1996
|
+
const output = rendered.render(80).join("\n");
|
|
1997
|
+
|
|
1998
|
+
assert.match(output, /Subagent status/);
|
|
1999
|
+
for (const line of visibleLines) {
|
|
2000
|
+
assert.match(output, new RegExp(line.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
2001
|
+
}
|
|
2002
|
+
assert.match(output, /\+2 more running\./);
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
it("stays within narrow widths", () => {
|
|
2006
|
+
const { api, registeredMessageRenderers } = createMockExtensionApi();
|
|
2007
|
+
(subagentsModule as any).default(api);
|
|
2008
|
+
|
|
2009
|
+
const rendererEntry = registeredMessageRenderers.find((entry) => entry.name === "subagent_status");
|
|
2010
|
+
assert.ok(rendererEntry, "expected subagent_status renderer to be registered");
|
|
2011
|
+
|
|
2012
|
+
const rendered = rendererEntry.renderer(
|
|
2013
|
+
{
|
|
2014
|
+
customType: "subagent_status",
|
|
2015
|
+
content: "Subagent status:\n• Worker running 5m, active (bash 2m).",
|
|
2016
|
+
details: { lines: ["Worker running 5m, active (bash 2m)."], overflow: 0 },
|
|
2017
|
+
},
|
|
2018
|
+
{ expanded: true },
|
|
2019
|
+
createTheme(),
|
|
2020
|
+
);
|
|
2021
|
+
|
|
2022
|
+
for (const width of [4, 5, 6]) {
|
|
2023
|
+
for (const line of rendered.render(width)) {
|
|
2024
|
+
assert.ok(
|
|
2025
|
+
visibleWidth(line) <= width,
|
|
2026
|
+
`expected line width <= ${width}, got ${visibleWidth(line)} for ${JSON.stringify(line)}`,
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
describe("subagent startup delay", () => {
|
|
2034
|
+
it("defaults to 500ms when no env var is set", () => {
|
|
2035
|
+
const testApi = (subagentsModule as any).__test__;
|
|
2036
|
+
assert.ok(testApi, "expected subagents test helpers to be exported");
|
|
2037
|
+
assert.equal(typeof testApi.getShellReadyDelayMs, "function");
|
|
2038
|
+
|
|
2039
|
+
const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
2040
|
+
delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
2041
|
+
try {
|
|
2042
|
+
assert.equal(testApi.getShellReadyDelayMs(), 500);
|
|
2043
|
+
} finally {
|
|
2044
|
+
if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
2045
|
+
else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original;
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
|
|
2049
|
+
it("uses PI_SUBAGENT_SHELL_READY_DELAY_MS when it is set", () => {
|
|
2050
|
+
const testApi = (subagentsModule as any).__test__;
|
|
2051
|
+
assert.ok(testApi, "expected subagents test helpers to be exported");
|
|
2052
|
+
assert.equal(typeof testApi.getShellReadyDelayMs, "function");
|
|
2053
|
+
|
|
2054
|
+
const original = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
2055
|
+
process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = "2500";
|
|
2056
|
+
try {
|
|
2057
|
+
assert.equal(testApi.getShellReadyDelayMs(), 2500);
|
|
2058
|
+
} finally {
|
|
2059
|
+
if (original == null) delete process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS;
|
|
2060
|
+
else process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS = original;
|
|
2061
|
+
}
|
|
2062
|
+
});
|
|
2063
|
+
});
|
|
2064
|
+
describe("subagents widget rendering", () => {
|
|
2065
|
+
it("keeps every rendered line within a very narrow width", () => {
|
|
2066
|
+
const testApi = (subagentsModule as any).__test__;
|
|
2067
|
+
assert.ok(testApi, "expected subagents test helpers to be exported");
|
|
2068
|
+
assert.equal(typeof testApi.renderSubagentWidgetLines, "function");
|
|
2069
|
+
|
|
2070
|
+
const originalNow = Date.now;
|
|
2071
|
+
Date.now = () => 1_000_000;
|
|
2072
|
+
try {
|
|
2073
|
+
const lines = testApi.renderSubagentWidgetLines([
|
|
2074
|
+
{
|
|
2075
|
+
id: "a1",
|
|
2076
|
+
name: "A",
|
|
2077
|
+
task: "",
|
|
2078
|
+
surface: "s1",
|
|
2079
|
+
startTime: 1_000_000 - 13_000,
|
|
2080
|
+
sessionFile: "sess1",
|
|
2081
|
+
statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 13_000 }),
|
|
2082
|
+
},
|
|
2083
|
+
{
|
|
2084
|
+
id: "a2",
|
|
2085
|
+
name: "B",
|
|
2086
|
+
task: "",
|
|
2087
|
+
surface: "s2",
|
|
2088
|
+
startTime: 1_000_000 - 21_000,
|
|
2089
|
+
sessionFile: "sess2",
|
|
2090
|
+
statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 21_000 }),
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
id: "a3",
|
|
2094
|
+
name: "C",
|
|
2095
|
+
task: "",
|
|
2096
|
+
surface: "s3",
|
|
2097
|
+
startTime: 1_000_000 - 27_000,
|
|
2098
|
+
sessionFile: "sess3",
|
|
2099
|
+
statusState: createStatusState({ source: "pi", startTimeMs: 1_000_000 - 27_000 }),
|
|
2100
|
+
},
|
|
2101
|
+
], 16);
|
|
2102
|
+
|
|
2103
|
+
assert.deepEqual(
|
|
2104
|
+
lines.map((line: string) => visibleWidth(line)),
|
|
2105
|
+
[16, 16, 16, 16, 16],
|
|
2106
|
+
);
|
|
2107
|
+
} finally {
|
|
2108
|
+
Date.now = originalNow;
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
|
2113
|
+
const testApi = (subagentsModule as any).__test__;
|
|
2114
|
+
assert.ok(testApi, "expected subagents test helpers to be exported");
|
|
2115
|
+
assert.equal(typeof testApi.borderLine, "function");
|
|
2116
|
+
|
|
2117
|
+
const line = testApi.borderLine(" A ", " 999 msgs (999.9KB) ", 16);
|
|
2118
|
+
assert.equal(visibleWidth(line), 16);
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
it("handles ultra-narrow widths without exceeding the width contract", () => {
|
|
2122
|
+
const testApi = (subagentsModule as any).__test__;
|
|
2123
|
+
assert.ok(testApi, "expected subagents test helpers to be exported");
|
|
2124
|
+
assert.equal(typeof testApi.renderSubagentWidgetLines, "function");
|
|
2125
|
+
|
|
2126
|
+
const widths = [0, 1, 2];
|
|
2127
|
+
for (const width of widths) {
|
|
2128
|
+
const startTime = Date.now() - 5_000;
|
|
2129
|
+
const lines = testApi.renderSubagentWidgetLines([
|
|
2130
|
+
{
|
|
2131
|
+
id: "a1",
|
|
2132
|
+
name: "A",
|
|
2133
|
+
task: "",
|
|
2134
|
+
surface: "s1",
|
|
2135
|
+
startTime,
|
|
2136
|
+
sessionFile: "sess1",
|
|
2137
|
+
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
|
2138
|
+
},
|
|
2139
|
+
], width);
|
|
2140
|
+
|
|
2141
|
+
for (const line of lines) {
|
|
2142
|
+
assert.ok(
|
|
2143
|
+
visibleWidth(line) <= width,
|
|
2144
|
+
`expected line width <= ${width}, got ${visibleWidth(line)} for ${JSON.stringify(line)}`,
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
});
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
describe("herdr.ts", () => {
|
|
2152
|
+
describe("isHerdrAvailable", () => {
|
|
2153
|
+
it("returns boolean based on HERDR_ENV", () => {
|
|
2154
|
+
const result = isHerdrAvailable();
|
|
2155
|
+
assert.equal(typeof result, "boolean");
|
|
2156
|
+
});
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
describe("herdr response parsing", () => {
|
|
2160
|
+
it("extracts pane id from a pane split response", () => {
|
|
2161
|
+
const output = JSON.stringify({
|
|
2162
|
+
result: {
|
|
2163
|
+
pane: {
|
|
2164
|
+
pane_id: "1-3",
|
|
2165
|
+
tab_id: "1:2",
|
|
2166
|
+
workspace_id: "1",
|
|
2167
|
+
},
|
|
2168
|
+
},
|
|
2169
|
+
});
|
|
2170
|
+
assert.equal(__herdrTest__.extractHerdrPaneId(output, "pane split"), "1-3");
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
it("extracts root pane id from a tab create response", () => {
|
|
2174
|
+
const output = JSON.stringify({
|
|
2175
|
+
result: {
|
|
2176
|
+
tab: { tab_id: "1:2" },
|
|
2177
|
+
root_pane: { pane_id: "1-2" },
|
|
2178
|
+
},
|
|
2179
|
+
});
|
|
2180
|
+
assert.equal(__herdrTest__.extractHerdrRootPaneId(output, "tab create"), "1-2");
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it("throws on malformed herdr JSON", () => {
|
|
2184
|
+
assert.throws(
|
|
2185
|
+
() => __herdrTest__.extractHerdrPaneId("not json", "pane split"),
|
|
2186
|
+
/Unexpected herdr pane split output/,
|
|
2187
|
+
);
|
|
2188
|
+
});
|
|
2189
|
+
});
|
|
2190
|
+
});
|